api-server/test/m-ai-03-gate.e2e-spec.ts
wangdl d936bf5ce3
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 34s
Deploy API Server / current-integration (push) Successful in 29s
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 URL 解析 — 区分 user:pass@host:port 和 host:port
正则修正:
- @([^:@]+):(\d+) — 匹配 @host:port(跳过 user:pass)
- :\/\/([^:@]+):(\d+) — 兜底无认证的 host:port

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

248 lines
9.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 } 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';
import { AiJobLifecycleRepository } from '../src/modules/ai-job/ai-job-lifecycle.repository';
/**
* M-AI-03 GATE E2E — Fail-Closed 核心门禁
*
* 必须设置 REQUIRE_REAL_INFRA=true 执行。
* 基础设施不可用 → 测试失败(不 soft-pass
*/
const OLD_ENV = { ...process.env };
// ═══════════════ 健康检查fail-closed ═══════════════
async function checkPort(host: string, port: number, label: string): Promise<void> {
await new Promise<void>((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<void> {
const requireInfra = process.env.REQUIRE_REAL_INFRA === 'true';
if (!requireInfra) {
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)', () => {
let app: INestApplication;
let creationService: AiJobCreationService;
let registry: JobDefinitionRegistry;
let lifecycleRepo: AiJobLifecycleRepository;
let jwtService: JwtService;
let userToken: string;
beforeAll(async () => {
await assertInfra();
process.env.NODE_ENV = 'test';
process.env.AI_JOB_SYNTHETIC_ENABLED = 'true';
process.env.JWT_SECRET = 'gate-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);
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 全链路成功: creation → snapshot → outbox → job=queued', 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: `gate-target-${Date.now()}`,
idempotencyKey: `gate-success-${Date.now()}`,
});
expect(job).toBeDefined();
expect(job.lifecycleStatus).toBe('queued');
expect(job.queueName).toBe('ai-interactive');
// Snapshot exists
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 },
});
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', async () => {
// Create a terminal original job
const idemKey = `gate-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()}`,
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 operationId = `gate-op-${Date.now()}`;
// First retry
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
const res2 = await request(app.getHttpServer())
.post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${operationId}`)
.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
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);
expect(res3.body.id).not.toBe(res1.body.id);
});
// ═══════════════ 场景 3: 事务回滚 — Snapshot 失败 ═══════════════
it('3. Snapshot 失败 → 事务全部回滚 (AiJob=0, Snapshot=0, Outbox=0)', async () => {
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
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
try {
await creationService.createJob({
userId: 'non-existent-user-that-causes-fk-failure',
jobType: 'synthetic_job',
triggerType: 'user_api',
targetType: 'synthetic',
targetId: 'rollback-test',
idempotencyKey: idemKey,
});
} catch (_) {
// Expected — FK or snapshot failure
}
// Verify nothing persisted for this idempotencyKey
const jobs = await prisma.aiJob.findMany({
where: { idempotencyKey: idemKey },
});
expect(jobs.length).toBe(0);
await prisma.$disconnect();
});
// ═══════════════ 场景 4: 事务回滚 — Outbox 失败 ═══════════════
it('4. Outbox 失败 → 事务全部回滚', async () => {
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
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,
});
expect(job1).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 } });
expect(snaps.length).toBe(1);
const outboxes = await prisma.outboxEvent.findMany({ where: { aggregateId: job1.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);
});
});