api-server/test/m-ai-03-gate.e2e-spec.ts
wangdl dfbf91382b
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 34s
Deploy API Server / current-integration (push) Successful in 30s
Deploy API Server / backward-compat (push) Successful in 0s
Deploy API Server / m-ai-03-synthetic-e2e (push) Failing after 10s
Deploy API Server / deploy (push) Has been skipped
fix: Gate E2E 移除 TCP 端口探测,改用 AppModule 初始化验证 infra
- 移除 net.Socket TCP 探测(密码中特殊字符导致解析失败)
- AppModule.init() → PrismaModule/RedisModule 连接 → 失败自然抛错
- assertGateEnv() 仅检查环境变量(REQUIRE_REAL_INFRA/NODE_ENV/AI_JOB_SYNTHETIC_ENABLED)
- 前一步 prisma migrate deploy 已验证 MySQL 可达

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 11:25:21 +08:00

190 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
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<void> {
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],
}).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);
});
});