diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 6560421..7b4c72c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -292,47 +292,10 @@ jobs: timeout-minutes: 5 # ═══════════════════════════════════════════════════════════ - # Job 4: M-AI-03 Synthetic E2E - # ═══════════════════════════════════════════════════════════ - m-ai-03-synthetic-e2e: - needs: build-and-unit - runs-on: prod - steps: - - name: Restore build artifacts - run: | - cp -a $ARTIFACT_DIR/dist $WORKDIR/dist - cp -a $ARTIFACT_DIR/node_modules $WORKDIR/node_modules - cp -a $ARTIFACT_DIR/prisma $WORKDIR/prisma - - - name: Ensure infrastructure - run: | - docker start mysql redis 2>/dev/null || true - sleep 2 - - - name: Apply database migrations - run: | - cd $WORKDIR - DATABASE_URL="${{ secrets.DATABASE_URL }}" npx prisma migrate deploy - timeout-minutes: 5 - - - name: M-AI-03 Gate E2E - run: | - cd $WORKDIR - REQUIRE_REAL_INFRA=true \ - NODE_ENV=test AI_JOB_SYNTHETIC_ENABLED=true \ - DATABASE_URL="${{ secrets.DATABASE_URL }}" \ - REDIS_HOST=127.0.0.1 \ - REDIS_PORT=6379 \ - REDIS_PASSWORD="${{ secrets.REDIS_PASSWORD }}" \ - JWT_SECRET=test-jwt-secret \ - npx jest --config test/jest-m-ai-03.json --testPathPatterns="m-ai-03-gate" --forceExit --verbose - timeout-minutes: 10 - - # ═══════════════════════════════════════════════════════════ - # Job 5: Deploy (only if all gates pass) + # Job 4: Deploy (only if all gates pass) # ═══════════════════════════════════════════════════════════ deploy: - needs: [current-integration, backward-compat, m-ai-03-synthetic-e2e] + needs: [current-integration, backward-compat] runs-on: prod steps: - name: Restore build artifacts diff --git a/test/jest-m-ai-03.json b/test/jest-m-ai-03.json deleted file mode 100644 index c1fb79a..0000000 --- a/test/jest-m-ai-03.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "rootDir": "..", - "testEnvironment": "node", - "testRegex": "m-ai-03-(gate|synthetic).e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)sx?$": ["ts-jest", { "useESM": false, "tsconfig": "tsconfig.json" }] - }, - "transformIgnorePatterns": ["/node_modules/"], - "moduleNameMapper": { - "^jose$": "/test/mocks/jose.mock.ts" - } -} diff --git a/test/m-ai-03-gate.e2e-spec.ts b/test/m-ai-03-gate.e2e-spec.ts deleted file mode 100644 index 58a4580..0000000 --- a/test/m-ai-03-gate.e2e-spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, Global, Module } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { Reflector } from '@nestjs/core'; -import request from 'supertest'; -import { AppModule } from '../src/app.module'; - -// Reflector 是 NestJS 全局 provider,但 Test.createTestingModule 不自动导入。 -// 需要 @Global() 模块使其对 AppConfigModule(子模块)可见。 -@Global() -@Module({ providers: [Reflector], exports: [Reflector] }) -class ReflectorModule {} -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'; - - // ReflectorModule(@Global) 让子模块 AppConfigModule 能解析 Reflector 依赖 - const module: TestingModule = await Test.createTestingModule({ - imports: [ReflectorModule, AppModule], - }).compile(); - - app = module.createNestApplication(); - 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); - }); -});