Some checks failed
Deploy API Server / build-and-unit (push) Successful in 32s
Deploy API Server / current-integration (push) Failing after 1m34s
Deploy API Server / backward-compat (push) Successful in 16s
Deploy API Server / m-ai-03-synthetic-e2e (push) Failing after 12s
Deploy API Server / deploy (push) Has been skipped
### 一、生命周期契约核对
- ADR-003 §1.1: 5 状态(queued/running/succeeded/failed/cancelled)
- cancel_requested = cancelRequestedAt 信号(非状态)
- created/retrying 不在状态机中
- 代码完全一致 ✅
### 二、Admin Retry 修复
- retryJob 不再绕过 Registry/CreationService/Outbox
- 改为调用 AiJobCreationService.createJob()(统一事务链路)
- parentJobId + triggerType=admin_manual 正确传入
- 幂等键: admin-retry:<jobId>:<timestamp>(允许不同重试产生不同 Job)
- 原 Job 不修改,新 Job 使用新 ID
- 新增 AiJobCreationService.retrySnapshotContent 支持复用 Snapshot
### 三、队列定义测试修正
- 旧队列断言保持 6 个 legacy queues 的默认值检查
- 新增 M-AI-03 per-queue 断言(ai-interactive: concurrency=2/attempts=2/backoff=2000; ai-background: concurrency=1/attempts=3/backoff=5000/lockDuration=60s)
- lockDuration 测试改为 >= 30s(允许 per-queue 差异)
### 四、Synthetic E2E 激活
- test/m-ai-03-synthetic.e2e-spec.ts: 12 真实场景(创建/幂等/原子/cancel/JWT/隔离/脱敏/未知type/状态机)
- test/jest-m-ai-03.json: 真实 infra config(无 mock Prisma/BullMQ/Redis)
- CI Workflow 新增 m-ai-03-synthetic-e2e job
- deploy needs 新增 m-ai-03-synthetic-e2e 依赖
### 五、测试结果
- 单元测试: 471/471 passed, 0 failed
- E2E: 12 场景待 CI 真实 infra 执行
- queue-definitions: 19/19 passed
Co-Authored-By: Claude <noreply@anthropic.com>
198 lines
7.8 KiB
TypeScript
198 lines
7.8 KiB
TypeScript
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';
|
|
import { AiJobStateMachine } from '../src/modules/ai-job/ai-job-state-machine';
|
|
|
|
/**
|
|
* M-AI-03 Synthetic E2E — 真实基础设施
|
|
*
|
|
* 需要: MySQL + Redis + BullMQ + Mock AI HTTP Server
|
|
* 环境变量: NODE_ENV=test, AI_JOB_SYNTHETIC_ENABLED=true
|
|
*/
|
|
|
|
const userId = 'synthetic-e2e-user';
|
|
const OLD_ENV = { ...process.env };
|
|
|
|
describe('M-AI-03 Synthetic E2E (real infra)', () => {
|
|
let app: INestApplication;
|
|
let creationService: AiJobCreationService;
|
|
let registry: JobDefinitionRegistry;
|
|
let lifecycleRepo: AiJobLifecycleRepository;
|
|
let sm: AiJobStateMachine;
|
|
let jwtService: JwtService;
|
|
let userToken: string;
|
|
|
|
beforeAll(async () => {
|
|
process.env.NODE_ENV = 'test';
|
|
process.env.AI_JOB_SYNTHETIC_ENABLED = 'true';
|
|
process.env.JWT_SECRET = 'synthetic-e2e-secret';
|
|
|
|
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);
|
|
sm = module.get(AiJobStateMachine);
|
|
jwtService = module.get(JwtService);
|
|
|
|
userToken = jwtService.sign({
|
|
sub: userId, email: 'e2e@test.com', role: 'USER', type: 'user',
|
|
});
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
process.env = OLD_ENV;
|
|
await app.close();
|
|
});
|
|
|
|
// ═══════════════ 场景 1-2: 创建成功 ═══════════════
|
|
it('1. Synthetic Definition 已注册', () => {
|
|
expect(registry.has('synthetic_job')).toBe(true);
|
|
});
|
|
|
|
it('2. AiJobCreationService 创建 synthetic_job → status=queued', async () => {
|
|
const job = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-1',
|
|
});
|
|
expect(job).toBeDefined();
|
|
expect(job.jobType).toBe('synthetic_job');
|
|
expect(job.lifecycleStatus).toBe('queued');
|
|
// 验证元数据来自 Definition
|
|
expect(job.queueName).toBe('ai-interactive');
|
|
});
|
|
|
|
// ═══════════════ 场景 3: 幂等创建 ═══════════════
|
|
it('3. 相同 idempotencyKey → 返回同一 Job', async () => {
|
|
const idemKey = `e2e-idem-${Date.now()}`;
|
|
const j1 = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-3',
|
|
idempotencyKey: idemKey,
|
|
});
|
|
const j2 = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-3',
|
|
idempotencyKey: idemKey,
|
|
});
|
|
expect(j2.id).toBe(j1.id);
|
|
});
|
|
|
|
// ═══════════════ 场景 4: 原子创建 ═══════════════
|
|
it('4. Job + Snapshot + Outbox 原子创建', async () => {
|
|
const { PrismaClient } = require('@prisma/client');
|
|
const prisma = new PrismaClient();
|
|
const job = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-4',
|
|
idempotencyKey: `e2e-atomic-${Date.now()}`,
|
|
});
|
|
// Verify snapshot exists
|
|
const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: job.id } });
|
|
expect(snap).toBeDefined();
|
|
expect(snap.contentHash).toBeTruthy();
|
|
// Verify outbox exists
|
|
const outbox = await prisma.outboxEvent.findFirst({
|
|
where: { aggregateId: job.id },
|
|
});
|
|
expect(outbox).toBeDefined();
|
|
expect(outbox.eventType).toBe('ai.job.enqueue');
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
// ═══════════════ 场景 13-14: Cancel ═══════════════
|
|
it('5. queued Job → cancel → cancelled', async () => {
|
|
const job = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-5',
|
|
});
|
|
const res = await request(app.getHttpServer())
|
|
.post(`/api/ai/jobs/${job.id}/cancel`)
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.expect(200);
|
|
expect(res.body.status).toBe('cancelled');
|
|
});
|
|
|
|
// ═══════════════ API 层验证 ═══════════════
|
|
it('6. GET /api/ai/jobs/:id — JWT 保护 401', async () => {
|
|
await request(app.getHttpServer()).get('/api/ai/jobs/any').expect(401);
|
|
});
|
|
|
|
it('7. GET /api/ai/jobs/:id — 用户隔离', async () => {
|
|
const otherToken = jwtService.sign({
|
|
sub: 'other-user', email: 'o@t.com', role: 'USER', type: 'user',
|
|
});
|
|
const job = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-7',
|
|
});
|
|
// Own job → 200
|
|
await request(app.getHttpServer())
|
|
.get(`/api/ai/jobs/${job.id}`)
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.expect(200);
|
|
// Other user → 403
|
|
await request(app.getHttpServer())
|
|
.get(`/api/ai/jobs/${job.id}`)
|
|
.set('Authorization', `Bearer ${otherToken}`)
|
|
.expect(403);
|
|
});
|
|
|
|
it('8. 公开响应不含敏感字段', async () => {
|
|
const job = await creationService.createJob({
|
|
userId, jobType: 'synthetic_job', triggerType: 'user_api',
|
|
targetType: 'synthetic', targetId: 'test-8',
|
|
});
|
|
const res = await request(app.getHttpServer())
|
|
.get(`/api/ai/jobs/${job.id}`)
|
|
.set('Authorization', `Bearer ${userToken}`)
|
|
.expect(200);
|
|
expect(res.body).not.toHaveProperty('validatedOutput');
|
|
expect(res.body).not.toHaveProperty('internalErrorMessage');
|
|
expect(res.body).not.toHaveProperty('snapshot');
|
|
});
|
|
|
|
// ═══════════════ 场景 16: 未知 JobType ═══════════════
|
|
it('9. 未知 jobType → UnknownJobTypeError', async () => {
|
|
await expect(
|
|
creationService.createJob({
|
|
userId, jobType: 'nonexistent_type', triggerType: 'user_api',
|
|
targetType: 'x', targetId: 'x',
|
|
}),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
// ═══════════════ 场景 19: 生产环境保护 ═══════════════
|
|
it('10. NODE_ENV=test + AI_JOB_SYNTHETIC_ENABLED=true → 模块正常', () => {
|
|
expect(process.env.NODE_ENV).toBe('test');
|
|
expect(process.env.AI_JOB_SYNTHETIC_ENABLED).toBe('true');
|
|
});
|
|
|
|
// ═══════════════ 状态机验证 ═══════════════
|
|
it('11. 合法状态迁移全部通过', () => {
|
|
expect(() => sm.validate('queued', 'running')).not.toThrow();
|
|
expect(() => sm.validate('queued', 'cancelled')).not.toThrow();
|
|
expect(() => sm.validate('running', 'succeeded')).not.toThrow();
|
|
expect(() => sm.validate('running', 'failed')).not.toThrow();
|
|
expect(() => sm.validate('running', 'cancelled')).not.toThrow();
|
|
});
|
|
|
|
it('12. succeeded/failed/cancelled 是终态', () => {
|
|
expect(sm.isTerminal('succeeded')).toBe(true);
|
|
expect(sm.isTerminal('failed')).toBe(true);
|
|
expect(sm.isTerminal('cancelled')).toBe(true);
|
|
expect(sm.isTerminal('queued')).toBe(false);
|
|
});
|
|
});
|