api-server/test/m-ai-03-gate.e2e-spec.ts
wangdl afb90d690b
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 12s
Deploy API Server / deploy (push) Has been skipped
fix: Gate E2E — @Global() ReflectorModule 提供跨模块 Reflector
AppConfigModule 注册 AdminAuthGuard 需要 Reflector。
@Global() 装饰让 Reflector 对子模块可见——这是 NestJS 正确做法。

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

196 lines
7.7 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, 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<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';
// 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);
});
});