import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication, ValidationPipe } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import request from 'supertest'; import * as net from 'net'; import { AppModule } from '../src/app.module'; import { PrismaService } from '../src/infrastructure/database/prisma.service'; const userId = 'm-ai-08-e2e-user'; const OLD_ENV = { ...process.env }; async function checkInfra(): Promise { const dbUrl = process.env.DATABASE_URL || ''; const dbMatch = dbUrl.match(/@([^:]+):(\d+)/); const dbHost = dbMatch?.[1] || '127.0.0.1'; const dbPort = parseInt(dbMatch?.[2] || '3306', 10); const checkPort = (host: string, port: number): Promise => new Promise((resolve) => { const sock = new net.Socket(); sock.setTimeout(2000); sock.on('connect', () => { sock.destroy(); resolve(true); }); sock.on('error', () => resolve(false)); sock.on('timeout', () => { sock.destroy(); resolve(false); }); sock.connect(port, host); }); return checkPort(dbHost, dbPort); } describe('M-AI-08 Learning Analysis E2E (real infra)', () => { let app: INestApplication; let prisma: PrismaService; let jwtService: JwtService; let userToken: string; let infraAvailable = false; let testKbId: string; beforeAll(async () => { infraAvailable = await checkInfra(); if (!infraAvailable) { throw new Error('[M-AI-08 E2E] MySQL not available — HARD FAIL. Run: docker start mysql'); } process.env.NODE_ENV = 'test'; process.env.JWT_SECRET = 'm-ai-08-e2e-jwt-secret'; const module: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile(); app = module.createNestApplication(); app.setGlobalPrefix('api', { exclude: ['admin-api/(.*)', 'internal/(.*)'] }); app.useGlobalPipes(new ValidationPipe({ transform: true })); await app.init(); prisma = module.get(PrismaService); jwtService = module.get(JwtService); userToken = jwtService.sign({ sub: userId, id: userId, email: 'e2e-mai08@test.com', role: 'USER', type: 'user' }); const kb = await prisma.knowledgeBase.upsert({ where: { id: 'm-ai-08-e2e-kb-001' }, create: { id: 'm-ai-08-e2e-kb-001', userId, title: 'M-AI-08 E2E KB', status: 'active' }, update: { userId }, }); testKbId = kb.id; // Enable FeatureFlags await prisma.featureFlag.upsert({ where: { name: 'LEARNING_ANALYSIS_ENGINE_MODE' }, create: { name: 'LEARNING_ANALYSIS_ENGINE_MODE', enabled: true, whitelist: userId }, update: { enabled: true, whitelist: userId }, }); }, 30000); afterAll(async () => { process.env = OLD_ENV; if (app && infraAvailable) { try { await prisma.featureFlag.deleteMany({ where: { name: 'LEARNING_ANALYSIS_ENGINE_MODE' } }); } catch {} try { await prisma.aiLearningAnalysis.deleteMany({ where: { userId } }); } catch {} try { await prisma.weakPointCandidate.deleteMany({ where: { userId } }); } catch {} try { await prisma.nextActionRecommendation.deleteMany({ where: { userId } }); } catch {} try { await prisma.aiJobArtifact.deleteMany({ where: { job: { userId } } }); } catch {} try { await prisma.aiJobSnapshot.deleteMany({ where: { job: { userId } } }); } catch {} try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateType: 'AiJob' } }); } catch {} try { await prisma.aiJob.deleteMany({ where: { userId } }); } catch {} try { await prisma.knowledgeBase.delete({ where: { id: testKbId } }); } catch {} await app.close(); } }); describe('场景 1: Legacy 模式', () => { it('LEARNING_ANALYSIS_ENGINE_MODE=disabled → legacy', async () => { await prisma.featureFlag.update({ where: { name: 'LEARNING_ANALYSIS_ENGINE_MODE' }, data: { enabled: false } }); const res = await request(app.getHttpServer()) .post('/api/ai/jobs') .set('Authorization', `Bearer ${userToken}`) .send({ jobType: 'learning_state_analysis', targetType: 'knowledge_base', targetId: testKbId }) .expect(201); expect(res.body.jobId).toBeTruthy(); expect(res.body.engineMode).toBeUndefined(); if (res.body.jobId) try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {} await prisma.featureFlag.update({ where: { name: 'LEARNING_ANALYSIS_ENGINE_MODE' }, data: { enabled: true, whitelist: userId } }); }); }); describe('场景 2: Unified 模式', () => { let jobId: string; it('HTTP → AiJob + Snapshot + Outbox', async () => { const res = await request(app.getHttpServer()) .post('/api/ai/jobs') .set('Authorization', `Bearer ${userToken}`) .send({ jobType: 'learning_state_analysis', targetType: 'knowledge_base', targetId: testKbId }) .expect(201); expect(res.body.jobId).toBeTruthy(); expect(res.body.engineMode).toBe('unified'); jobId = res.body.jobId; const job = await prisma.aiJob.findUnique({ where: { id: jobId } }); expect(job).toBeTruthy(); expect(job!.jobType).toBe('learning_analysis'); const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId } }); expect(snap).toBeTruthy(); const outbox = await (prisma as any).outboxEvent.findFirst({ where: { aggregateId: jobId } }); expect(outbox).toBeTruthy(); }); afterAll(async () => { if (jobId) { try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId } }); } catch {} try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: jobId } }); } catch {} try { await prisma.aiJob.delete({ where: { id: jobId } }); } catch {} } }); }); describe('场景 3: 幂等', () => { it('相同 idempotencyKey → 相同 jobId', async () => { const body = { jobType: 'learning_state_analysis', targetType: 'knowledge_base', targetId: testKbId, idempotencyKey: 'e2e-la-idem-001' }; const r1 = await request(app.getHttpServer()).post('/api/ai/jobs').set('Authorization', `Bearer ${userToken}`).send(body).expect(201); const r2 = await request(app.getHttpServer()).post('/api/ai/jobs').set('Authorization', `Bearer ${userToken}`).send(body).expect(201); expect(r2.body.jobId).toBe(r1.body.jobId); try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId: r1.body.jobId } }); } catch {} try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: r1.body.jobId } }); } catch {} try { await prisma.aiJob.delete({ where: { id: r1.body.jobId } }); } catch {} }); }); describe('场景 4: 回滚', () => { it('切回 Legacy 有效', async () => { await prisma.featureFlag.update({ where: { name: 'LEARNING_ANALYSIS_ENGINE_MODE' }, data: { enabled: false } }); const res = await request(app.getHttpServer()) .post('/api/ai/jobs') .set('Authorization', `Bearer ${userToken}`) .send({ jobType: 'learning_state_analysis', targetType: 'knowledge_base', targetId: testKbId }) .expect(201); expect(res.body.engineMode).toBeUndefined(); if (res.body.jobId) try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {} await prisma.featureFlag.update({ where: { name: 'LEARNING_ANALYSIS_ENGINE_MODE' }, data: { enabled: true, whitelist: userId } }); }); }); });