fix: Gate E2E 移除 TCP 端口探测,改用 AppModule 初始化验证 infra
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

- 移除 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>
This commit is contained in:
wangdl 2026-06-21 11:25:21 +08:00
parent d936bf5ce3
commit dfbf91382b

View File

@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import request from 'supertest'; import request from 'supertest';
import * as net from 'net';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { AiJobCreationService } from '../src/modules/ai-job/ai-job-creation.service'; import { AiJobCreationService } from '../src/modules/ai-job/ai-job-creation.service';
import { JobDefinitionRegistry } from '../src/modules/ai-job/job-definition-registry'; 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 * M-AI-03 GATE E2E Fail-Closed
* *
* REQUIRE_REAL_INFRA=true * REQUIRE_REAL_INFRA=true
* soft-pass * AppModule.init() Prisma/Redis infra OK
* beforeAll fail-closed
*/ */
const OLD_ENV = { ...process.env }; const OLD_ENV = { ...process.env };
// ═══════════════ 健康检查fail-closed ═══════════════ async function assertGateEnv(): Promise<void> {
if (process.env.REQUIRE_REAL_INFRA !== 'true') {
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'); 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'; const TEST_USER = 'gate-e2e-user';
describe('M-AI-03 GATE E2E (fail-closed)', () => { describe('M-AI-03 GATE E2E (fail-closed)', () => {
@ -63,12 +34,13 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => {
let userToken: string; let userToken: string;
beforeAll(async () => { beforeAll(async () => {
await assertInfra(); await assertGateEnv();
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
process.env.AI_JOB_SYNTHETIC_ENABLED = 'true'; process.env.AI_JOB_SYNTHETIC_ENABLED = 'true';
process.env.JWT_SECRET = 'gate-e2e-secret'; process.env.JWT_SECRET = 'gate-e2e-secret';
// AppModule.init() 连接 Prisma + Redis — 失败则测试 fail-closed
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); }).compile();
@ -91,7 +63,7 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => {
}); });
// ═══════════════ 场景 1: 完整成功链路 ═══════════════ // ═══════════════ 场景 1: 完整成功链路 ═══════════════
it('1. synthetic_job 全链路成功: creation → snapshot → outbox → job=queued', async () => { it('1. synthetic_job 创建 → snapshot 存在 → outbox 存在', async () => {
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -102,23 +74,21 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => {
jobType: 'synthetic_job', jobType: 'synthetic_job',
triggerType: 'user_api', triggerType: 'user_api',
targetType: 'synthetic', targetType: 'synthetic',
targetId: `gate-target-${Date.now()}`, targetId: `gt-${Date.now()}`,
idempotencyKey: `gate-success-${Date.now()}`, idempotencyKey: `gt-s1-${Date.now()}`,
}); });
expect(job).toBeDefined(); expect(job).toBeDefined();
expect(job.lifecycleStatus).toBe('queued'); expect(job.lifecycleStatus).toBe('queued');
expect(job.queueName).toBe('ai-interactive'); expect(job.queueName).toBe('ai-interactive');
// Snapshot exists // Snapshot
const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: job.id } }); const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: job.id } });
expect(snap).toBeDefined(); expect(snap).toBeDefined();
expect(snap.contentHash).toBeTruthy(); expect(snap.contentHash).toBeTruthy();
// Outbox exists (jobId is in dedupeKey or aggregateId) // Outbox: payload = {jobId} only
const outbox = await prisma.outboxEvent.findFirst({ const outbox = await prisma.outboxEvent.findFirst({ where: { aggregateId: job.id } });
where: { aggregateId: job.id },
});
expect(outbox).toBeDefined(); expect(outbox).toBeDefined();
expect(outbox.eventType).toBe('ai.job.enqueue'); expect(outbox.eventType).toBe('ai.job.enqueue');
expect(outbox.payload).toEqual({ jobId: job.id }); expect(outbox.payload).toEqual({ jobId: job.id });
@ -127,113 +97,85 @@ describe('M-AI-03 GATE E2E (fail-closed)', () => {
}); });
// ═══════════════ 场景 2: Admin Retry 请求级幂等 ═══════════════ // ═══════════════ 场景 2: Admin Retry 请求级幂等 ═══════════════
it('2. Admin Retry: 相同 operationId → 幂等返回同一个 Child Job', async () => { it('2. Admin Retry: 同 operationId 返回同一 Child Job; 不同 operationId 创建不同 Job', async () => {
// Create a terminal original job const idemKey = `gt-retry-src-${Date.now()}`;
const idemKey = `gate-retry-src-${Date.now()}`;
const original = await creationService.createJob({ const original = await creationService.createJob({
userId: TEST_USER, userId: TEST_USER,
jobType: 'synthetic_job', jobType: 'synthetic_job',
triggerType: 'user_api', triggerType: 'user_api',
targetType: 'synthetic', targetType: 'synthetic',
targetId: `retry-target-${Date.now()}`, targetId: `rt-${Date.now()}`,
idempotencyKey: idemKey, idempotencyKey: idemKey,
}); });
// Simulate terminal status by canceling
await lifecycleRepo.markCancelled(original.id); await lifecycleRepo.markCancelled(original.id);
const adminToken = jwtService.sign({ sub: 'admin-gate', email: 'ag@t.com', role: 'SUPER_ADMIN', type: 'admin' }); 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()}`; // 同 operationId 两次 → 同一 Child Job
// First retry
const res1 = await request(app.getHttpServer()) const res1 = await request(app.getHttpServer())
.post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${operationId}`) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${opId}`)
.set('Authorization', `Bearer ${adminToken}`) .set('Authorization', `Bearer ${adminToken}`).expect(200);
.expect(200);
// Second retry — same operationId, must return same child Job
const res2 = await request(app.getHttpServer()) const res2 = await request(app.getHttpServer())
.post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${operationId}`) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=${opId}`)
.set('Authorization', `Bearer ${adminToken}`) .set('Authorization', `Bearer ${adminToken}`).expect(200);
.expect(200);
expect(res2.body.id).toBe(res1.body.id); expect(res2.body.id).toBe(res1.body.id);
expect(res2.body.parentJobId).toBe(original.id); expect(res2.body.parentJobId).toBe(original.id);
expect(res2.body.triggerType).toBe('admin_manual'); expect(res2.body.triggerType).toBe('admin_manual');
// Different operationId must create a different child // 不同 operationId → 不同 Child Job
const res3 = await request(app.getHttpServer()) const res3 = await request(app.getHttpServer())
.post(`/admin-api/ai/jobs/${original.id}/retry?operationId=gate-op-different-${Date.now()}`) .post(`/admin-api/ai/jobs/${original.id}/retry?operationId=gt-op-other-${Date.now()}`)
.set('Authorization', `Bearer ${adminToken}`) .set('Authorization', `Bearer ${adminToken}`).expect(200);
.expect(200);
expect(res3.body.id).not.toBe(res1.body.id); expect(res3.body.id).not.toBe(res1.body.id);
}); });
// ═══════════════ 场景 3: 事务回滚 — Snapshot 失败 ═══════════════ // ═══════════════ 场景 3: 事务回滚 ═══════════════
it('3. Snapshot 失败 → 事务全部回滚 (AiJob=0, Snapshot=0, Outbox=0)', async () => { it('3. Snapshot/Outbox 创建失败 → 事务回滚 → 无残留', async () => {
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const idemKey = `gt-rollback-${Date.now()}`;
const idemKey = `gate-rollback-snap-${Date.now()}`; // 使用不存在的 userId 触发 FK 失败 → 事务回滚
// 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 { try {
await creationService.createJob({ await creationService.createJob({
userId: 'non-existent-user-that-causes-fk-failure', userId: 'nonexistent-user-fk-fail',
jobType: 'synthetic_job', jobType: 'synthetic_job',
triggerType: 'user_api', triggerType: 'user_api',
targetType: 'synthetic', targetType: 'synthetic',
targetId: 'rollback-test', targetId: 'rb-test',
idempotencyKey: idemKey, idempotencyKey: idemKey,
}); });
} catch (_) { } catch (_) { /* expected */ }
// Expected — FK or snapshot failure
}
// 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); expect(jobs.length).toBe(0);
await prisma.$disconnect(); await prisma.$disconnect();
}); });
// ═══════════════ 场景 4: 事务回滚 — Outbox 失败 ═══════════════ // ═══════════════ 场景 4: 幂等不重复创建 ═══════════════
it('4. Outbox 失败 → 事务全部回滚', async () => { it('4. 同 idempotencyKey 两次创建 → 只有 1 Job + 1 Snapshot + 1 Outbox', async () => {
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const idemKey = `gt-idem-${Date.now()}`;
const idemKey = `gate-rollback-outbox-${Date.now()}`; const j1 = await creationService.createJob({
userId: TEST_USER, jobType: 'synthetic_job', triggerType: 'user_api',
// Attempt creation — outbox dedupeKey UNIQUE prevents duplicates targetType: 'synthetic', targetId: 'idem-test', idempotencyKey: idemKey,
// 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(); expect(j1).toBeDefined();
// Verify exactly 1 job, 1 snapshot, 1 outbox
const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey } }); const jobs = await prisma.aiJob.findMany({ where: { idempotencyKey: idemKey } });
expect(jobs.length).toBe(1); 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); 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); expect(outboxes.length).toBe(1);
await prisma.$disconnect(); await prisma.$disconnect();
}); });
// ═══════════════ 场景 5: 生产环境不注册 ═══════════════ // ═══════════════ 场景 5: 环境门控 ═══════════════
it('5. NODE_ENV=test + AI_JOB_SYNTHETIC_ENABLED=true → Synthetic 已注册', () => { it('5. NODE_ENV=test + AI_JOB_SYNTHETIC_ENABLED=true → Synthetic 已注册', () => {
expect(process.env.NODE_ENV).toBe('test'); expect(process.env.NODE_ENV).toBe('test');
expect(process.env.AI_JOB_SYNTHETIC_ENABLED).toBe('true'); expect(process.env.AI_JOB_SYNTHETIC_ENABLED).toBe('true');