import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import request from 'supertest'; 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 时: * AppModule.init() → Prisma/Redis 连接 → 成功则 infra OK * 连接失败 → beforeAll 抛错 → 测试失败(fail-closed) */ const OLD_ENV = { ...process.env }; async function assertGateEnv(): Promise { if (process.env.REQUIRE_REAL_INFRA !== 'true') { throw new Error('REQUIRE_REAL_INFRA must be "true" to run M-AI-03 Gate E2E'); } } 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 assertGateEnv(); process.env.NODE_ENV = 'test'; process.env.AI_JOB_SYNTHETIC_ENABLED = 'true'; process.env.JWT_SECRET = 'gate-e2e-secret'; // AppModule.init() 连接 Prisma + Redis — 失败则测试 fail-closed const module: TestingModule = await Test.createTestingModule({ imports: [AppModule], providers: [Reflector], }).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 创建 → snapshot 存在 → outbox 存在', 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: `gt-${Date.now()}`, idempotencyKey: `gt-s1-${Date.now()}`, }); expect(job).toBeDefined(); expect(job.lifecycleStatus).toBe('queued'); expect(job.queueName).toBe('ai-interactive'); // Snapshot const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: job.id } }); expect(snap).toBeDefined(); expect(snap.contentHash).toBeTruthy(); // Outbox: payload = {jobId} only 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; 不同 operationId 创建不同 Job', async () => { const idemKey = `gt-retry-src-${Date.now()}`; const original = await creationService.createJob({ userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: `rt-${Date.now()}`, idempotencyKey: idemKey, }); await lifecycleRepo.markCancelled(original.id); const adminToken = jwtService.sign({ sub: 'admin-gate', email: 'ag@t.com', role: 'SUPER_ADMIN', type: 'admin' }); const opId = `gt-op-${Date.now()}`; // 同 operationId 两次 → 同一 Child Job const res1 = await request(app.getHttpServer()) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${opId}`) .set('Authorization', `Bearer ${adminToken}`).expect(200); const res2 = await request(app.getHttpServer()) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${opId}`) .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'); // 不同 operationId → 不同 Child Job const res3 = await request(app.getHttpServer()) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=gt-op-other-${Date.now()}`) .set('Authorization', `Bearer ${adminToken}`).expect(200); expect(res3.body.id).not.toBe(res1.body.id); }); // ═══════════════ 场景 3: 事务回滚 ═══════════════ it('3. Snapshot/Outbox 创建失败 → 事务回滚 → 无残留', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const idemKey = `gt-rollback-${Date.now()}`; // 使用不存在的 userId 触发 FK 失败 → 事务回滚 try { await creationService.createJob({ userId: 'nonexistent-user-fk-fail', jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: 'rb-test', idempotencyKey: idemKey, }); } catch (_) { /* expected */ } const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey } }); expect(jobs.length).toBe(0); await prisma.$disconnect(); }); // ═══════════════ 场景 4: 幂等不重复创建 ═══════════════ it('4. 同 idempotencyKey 两次创建 → 只有 1 Job + 1 Snapshot + 1 Outbox', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); const idemKey = `gt-idem-${Date.now()}`; const j1 = await creationService.createJob({ userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', targetId: 'idem-test', idempotencyKey: idemKey, }); expect(j1).toBeDefined(); const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey } }); expect(jobs.length).toBe(1); const snaps = await prisma.aiJobSnapshot.findMany({ where: { jobId: j1.id } }); expect(snaps.length).toBe(1); const outboxes = await prisma.outboxEvent.findMany({ where: { aggregateId: j1.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); }); });