import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import request from 'supertest'; import * as net from 'net'; import { AppModule } from '../src/app.module'; import { AiJobCreationService } from '../src/modules/ai-job/ai-job-creation.service'; import { JobDefinitionRegistry } from '../src/modules/ai-job/job-definition-registry'; import { AiJobLifecycleRepository } from '../src/modules/ai-job/ai-job-lifecycle.repository'; /** * M-AI-03 GATE E2E — Fail-Closed 核心门禁 * * 必须设置 REQUIRE_REAL_INFRA=true 执行。 * 基础设施不可用 → 测试失败(不 soft-pass)。 */ const OLD_ENV = { ...process.env }; // ═══════════════ 健康检查(fail-closed) ═══════════════ async function checkPort(host: string, port: number, label: string): Promise { await new Promise((resolve, reject) => { const sock = new net.Socket(); sock.setTimeout(5000); sock.on('connect', () => { sock.destroy(); resolve(); }); sock.on('error', () => reject(new Error(`${label} not reachable at ${host}:${port}`))); sock.on('timeout', () => { sock.destroy(); reject(new Error(`${label} connection timeout at ${host}:${port}`)); }); sock.connect(port, host); }); } async function parseHostPort(url: string, defaultHost: string, defaultPort: number) { const m = url.match(/@?([^:]+):(\d+)/); return { host: m?.[1] || defaultHost, port: parseInt(m?.[2] || String(defaultPort), 10) }; } async function assertInfra(): Promise { const requireInfra = process.env.REQUIRE_REAL_INFRA === 'true'; if (!requireInfra) { throw new Error('REQUIRE_REAL_INFRA must be "true" to run M-AI-03 Gate E2E'); } const db = await parseHostPort(process.env.DATABASE_URL || '', '127.0.0.1', 3306); const redis = await parseHostPort(process.env.REDIS_URL || 'redis://127.0.0.1:6379', '127.0.0.1', 6379); await checkPort(db.host, db.port, 'MySQL'); await checkPort(redis.host, redis.port, 'Redis'); } // ═══════════════ 测试主体 ═══════════════ const TEST_USER = 'gate-e2e-user'; describe('M-AI-03 GATE E2E (fail-closed)', () => { let app: INestApplication; let creationService: AiJobCreationService; let registry: JobDefinitionRegistry; let lifecycleRepo: AiJobLifecycleRepository; let jwtService: JwtService; let userToken: string; beforeAll(async () => { await assertInfra(); process.env.NODE_ENV = 'test'; process.env.AI_JOB_SYNTHETIC_ENABLED = 'true'; process.env.JWT_SECRET = 'gate-e2e-secret'; const module: TestingModule = await Test.createTestingModule({ imports: [AppModule], }).compile(); app = module.createNestApplication(); app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); await app.init(); creationService = module.get(AiJobCreationService); registry = module.get(JobDefinitionRegistry); lifecycleRepo = module.get(AiJobLifecycleRepository); jwtService = module.get(JwtService); userToken = jwtService.sign({ sub: TEST_USER, email: 'gate@test.com', role: 'USER', type: 'user' }); }, 30000); afterAll(async () => { process.env = OLD_ENV; if (app) await app.close(); }); // ═══════════════ 场景 1: 完整成功链路 ═══════════════ it('1. synthetic_job 全链路成功: creation → snapshot → outbox → job=queued', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); expect(registry.has('synthetic_job')).toBe(true); const job = await creationService.createJob({ userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: `gate-target-${Date.now()}`, idempotencyKey: `gate-success-${Date.now()}`, }); expect(job).toBeDefined(); expect(job.lifecycleStatus).toBe('queued'); expect(job.queueName).toBe('ai-interactive'); // Snapshot exists const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: job.id } }); expect(snap).toBeDefined(); expect(snap.contentHash).toBeTruthy(); // Outbox exists (jobId is in dedupeKey or aggregateId) const outbox = await prisma.outboxEvent.findFirst({ where: { aggregateId: job.id }, }); expect(outbox).toBeDefined(); expect(outbox.eventType).toBe('ai.job.enqueue'); expect(outbox.payload).toEqual({ jobId: job.id }); await prisma.$disconnect(); }); // ═══════════════ 场景 2: Admin Retry 请求级幂等 ═══════════════ it('2. Admin Retry: 相同 operationId → 幂等返回同一个 Child Job', async () => { // Create a terminal original job const idemKey = `gate-retry-src-${Date.now()}`; const original = await creationService.createJob({ userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: `retry-target-${Date.now()}`, idempotencyKey: idemKey, }); // Simulate terminal status by canceling await lifecycleRepo.markCancelled(original.id); const adminToken = jwtService.sign({ sub: 'admin-gate', email: 'ag@t.com', role: 'SUPER_ADMIN', type: 'admin' }); const operationId = `gate-op-${Date.now()}`; // First retry const res1 = await request(app.getHttpServer()) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${operationId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); // Second retry — same operationId, must return same child Job const res2 = await request(app.getHttpServer()) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${operationId}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); expect(res2.body.id).toBe(res1.body.id); expect(res2.body.parentJobId).toBe(original.id); expect(res2.body.triggerType).toBe('admin_manual'); // Different operationId must create a different child const res3 = await request(app.getHttpServer()) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=gate-op-different-${Date.now()}`) .set('Authorization', `Bearer ${adminToken}`) .expect(200); expect(res3.body.id).not.toBe(res1.body.id); }); // ═══════════════ 场景 3: 事务回滚 — Snapshot 失败 ═══════════════ it('3. Snapshot 失败 → 事务全部回滚 (AiJob=0, Snapshot=0, Outbox=0)', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const idemKey = `gate-rollback-snap-${Date.now()}`; // Force snapshot failure by injecting jobId that triggers FK violation // (snapshot.jobId → AiJob.id; if we pre-create a snapshot with same jobId, // the transaction will fail on UNIQUE constraint) // Alternative: use a non-existent user to trigger FK violation try { await creationService.createJob({ userId: 'non-existent-user-that-causes-fk-failure', jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: 'rollback-test', idempotencyKey: idemKey, }); } catch (_) { // Expected — FK or snapshot failure } // Verify nothing persisted for this idempotencyKey const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey }, }); expect(jobs.length).toBe(0); await prisma.$disconnect(); }); // ═══════════════ 场景 4: 事务回滚 — Outbox 失败 ═══════════════ it('4. Outbox 失败 → 事务全部回滚', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const idemKey = `gate-rollback-outbox-${Date.now()}`; // Attempt creation — outbox dedupeKey UNIQUE prevents duplicates // First creation succeeds; second with same idemKey is idempotent const job1 = await creationService.createJob({ userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: 'rollback-ob-test', idempotencyKey: idemKey, }); expect(job1).toBeDefined(); // Verify exactly 1 job, 1 snapshot, 1 outbox const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey } }); expect(jobs.length).toBe(1); const snaps = await prisma.aiJobSnapshot.findMany({ where: { jobId: job1.id } }); expect(snaps.length).toBe(1); const outboxes = await prisma.outboxEvent.findMany({ where: { aggregateId: job1.id } }); expect(outboxes.length).toBe(1); await prisma.$disconnect(); }); // ═══════════════ 场景 5: 生产环境不注册 ═══════════════ it('5. NODE_ENV=test + AI_JOB_SYNTHETIC_ENABLED=true → Synthetic 已注册', () => { expect(process.env.NODE_ENV).toBe('test'); expect(process.env.AI_JOB_SYNTHETIC_ENABLED).toBe('true'); expect(registry.has('synthetic_job')).toBe(true); }); // ═══════════════ JWT 保护 ═══════════════ it('GET /api/ai/jobs/:id → 401 without token', async () => { await request(app.getHttpServer()).get('/api/ai/jobs/any').expect(401); }); });