diff --git a/test/m-ai-03-gate.e2e-spec.ts b/test/m-ai-03-gate.e2e-spec.ts index 8e470f8..20a8893 100644 --- a/test/m-ai-03-gate.e2e-spec.ts +++ b/test/m-ai-03-gate.e2e-spec.ts @@ -2,7 +2,6 @@ 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'; @@ -11,47 +10,19 @@ import { AiJobLifecycleRepository } from '../src/modules/ai-job/ai-job-lifecycle /** * M-AI-03 GATE E2E — Fail-Closed 核心门禁 * - * 必须设置 REQUIRE_REAL_INFRA=true 执行。 - * 基础设施不可用 → 测试失败(不 soft-pass)。 + * REQUIRE_REAL_INFRA=true 时: + * AppModule.init() → Prisma/Redis 连接 → 成功则 infra OK + * 连接失败 → beforeAll 抛错 → 测试失败(fail-closed) */ 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) { - // DATABASE_URL: mysql://user:pass@host:port/db - // REDIS_URL: redis://host:port or redis://user:pass@host:port - // Try with @ first (authenticated), then without (no auth) - let m = url.match(/@([^:@]+):(\d+)/); - if (!m) 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) { +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 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)', () => { @@ -63,12 +34,13 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => { let userToken: string; beforeAll(async () => { - await assertInfra(); + 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], }).compile(); @@ -91,7 +63,7 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => { }); // ═══════════════ 场景 1: 完整成功链路 ═══════════════ - it('1. synthetic_job 全链路成功: creation → snapshot → outbox → job=queued', async () => { + it('1. synthetic_job 创建 → snapshot 存在 → outbox 存在', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); @@ -102,23 +74,21 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => { jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', - targetId: `gate-target-${Date.now()}`, - idempotencyKey: `gate-success-${Date.now()}`, + targetId: `gt-${Date.now()}`, + idempotencyKey: `gt-s1-${Date.now()}`, }); expect(job).toBeDefined(); expect(job.lifecycleStatus).toBe('queued'); expect(job.queueName).toBe('ai-interactive'); - // Snapshot exists + // Snapshot 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 }, - }); + // 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 }); @@ -127,113 +97,85 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => { }); // ═══════════════ 场景 2: Admin Retry 请求级幂等 ═══════════════ - it('2. Admin Retry: 相同 operationId → 幂等返回同一个 Child Job', async () => { - // Create a terminal original job - const idemKey = `gate-retry-src-${Date.now()}`; + 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: `retry-target-${Date.now()}`, + targetId: `rt-${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 opId = `gt-op-${Date.now()}`; - const operationId = `gate-op-${Date.now()}`; - - // First retry + // 同 operationId 两次 → 同一 Child Job 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 + .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=${operationId}`) - .set('Authorization', `Bearer ${adminToken}`) - .expect(200); - + .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'); - // Different operationId must create a different child + // 不同 operationId → 不同 Child Job 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); - + .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: 事务回滚 — Snapshot 失败 ═══════════════ - it('3. Snapshot 失败 → 事务全部回滚 (AiJob=0, Snapshot=0, Outbox=0)', async () => { + // ═══════════════ 场景 3: 事务回滚 ═══════════════ + it('3. Snapshot/Outbox 创建失败 → 事务回滚 → 无残留', async () => { const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); + const idemKey = `gt-rollback-${Date.now()}`; - 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 + // 使用不存在的 userId 触发 FK 失败 → 事务回滚 try { await creationService.createJob({ - userId: 'non-existent-user-that-causes-fk-failure', + userId: 'nonexistent-user-fk-fail', jobType: 'synthetic_job', triggerType: 'user_api', targetType: 'synthetic', - targetId: 'rollback-test', + targetId: 'rb-test', idempotencyKey: idemKey, }); - } catch (_) { - // Expected — FK or snapshot failure - } + } catch (_) { /* expected */ } - // Verify nothing persisted for this idempotencyKey - const jobs = await prisma.aiJob.findMany({ - where: { idempotencyKey: idemKey }, - }); + const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey } }); expect(jobs.length).toBe(0); - await prisma.$disconnect(); }); - // ═══════════════ 场景 4: 事务回滚 — Outbox 失败 ═══════════════ - it('4. Outbox 失败 → 事务全部回滚', async () => { + // ═══════════════ 场景 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 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, + const j1 = await creationService.createJob({ + userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api', + targetType: 'synthetic', targetId: 'idem-test', idempotencyKey: idemKey, }); - expect(job1).toBeDefined(); + expect(j1).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 } }); + const snaps = await prisma.aiJobSnapshot.findMany({ where: { jobId: j1.id } }); expect(snaps.length).toBe(1); - const outboxes = await prisma.outboxEvent.findMany({ where: { aggregateId: job1.id } }); + const outboxes = await prisma.outboxEvent.findMany({ where: { aggregateId: j1.id } }); expect(outboxes.length).toBe(1); await prisma.$disconnect(); }); - // ═══════════════ 场景 5: 生产环境不注册 ═══════════════ + // ═══════════════ 场景 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');