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'; /** * M-AI-07-07: Quiz Generation 真实业务 E2E * * 核心场景(15 场景): * 1. Legacy 模式生成成功 * 2. Unified 模式完整成功 * 3. 相同 submission 返回同一个 Job * 4. 重复消费不重复 Quiz * 5. 重复消费不重复 Questions * 6. 并发 Projector 不重复 Quiz * 7. 跨用户 target 请求被拒绝 * 8. 跨用户请求零数据库副作用 * 9. Unified 创建失败不调用 Legacy * 10. Provider 永久失败后 Job failed * 11. Projector 中途失败无部分 Quiz * 12. 旧查询可以读取 Unified Quiz * 13. 未作答查询不泄漏正确答案 * 14. Feature Flag 切回 Legacy 有效 * 15. 公开错误不泄漏内部输出 */ const userId = 'm-ai-07-e2e-user'; const userId2 = 'm-ai-07-e2e-user-2'; const OLD_ENV = { ...process.env }; async function checkInfra(): Promise { const dbUrl = process.env.DATABASE_URL || ''; const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379'; const dbMatch = dbUrl.match(/@([^:]+):(\d+)/); const dbHost = dbMatch?.[1] || '127.0.0.1'; const dbPort = parseInt(dbMatch?.[2] || '3306', 10); const redisMatch = redisUrl.match(/@?([^:]+):(\d+)/); const redisHost = redisMatch?.[1] || '127.0.0.1'; const redisPort = parseInt(redisMatch?.[2] || '6379', 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); }); const [mysqlOk, redisOk] = await Promise.all([ checkPort(dbHost, dbPort), checkPort(redisHost, redisPort), ]); return mysqlOk && redisOk; } describe('M-AI-07 Quiz Generation E2E (real infra)', () => { let app: INestApplication; let prisma: PrismaService; let jwtService: JwtService; let userToken: string; let userToken2: string; let infraAvailable = false; let testKbId: string; beforeAll(async () => { infraAvailable = await checkInfra(); if (!infraAvailable) { throw new Error( '[M-AI-07 E2E] MySQL/Redis not available — HARD FAIL. ' + 'Run: docker start mysql redis', ); } process.env.NODE_ENV = 'test'; process.env.JWT_SECRET = 'm-ai-07-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-mai07@test.com', role: 'USER', type: 'user' }); userToken2 = jwtService.sign({ sub: userId2, id: userId2, email: 'e2e-mai07-2@test.com', role: 'USER', type: 'user' }); // Create test KB with items const kb = await prisma.knowledgeBase.upsert({ where: { id: 'm-ai-07-e2e-kb-001' }, create: { id: 'm-ai-07-e2e-kb-001', userId, title: 'E2E Quiz KB', status: 'active' }, update: { userId }, }); testKbId = kb.id; await prisma.knowledgeItem.upsert({ where: { id: 'm-ai-07-e2e-ki-001' }, create: { id: 'm-ai-07-e2e-ki-001', userId, knowledgeBaseId: testKbId, itemType: 'concept', title: '光合作用', content: '光合作用是植物利用光能的过程。', summary: '光合作用原理', learnable: true, status: 'active', orderIndex: 0 }, update: { userId, title: '光合作用' }, }); // Enable FeatureFlags await prisma.featureFlag.upsert({ where: { name: 'QUIZ_GENERATION_ENGINE_MODE' }, create: { name: 'QUIZ_GENERATION_ENGINE_MODE', enabled: true, whitelist: userId }, update: { enabled: true, whitelist: userId }, }); }, 30000); afterAll(async () => { process.env = OLD_ENV; if (app) { if (infraAvailable) { try { await prisma.featureFlag.deleteMany({ where: { name: 'QUIZ_GENERATION_ENGINE_MODE' } }); } catch {} try { await prisma.quizQuestion.deleteMany({ where: { quiz: { userId } } }); } catch {} try { await prisma.quizAttempt.deleteMany({ where: { userId } }); } catch {} try { await prisma.quiz.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.knowledgeItem.deleteMany({ where: { knowledgeBaseId: testKbId } }); } catch {} try { await prisma.knowledgeBase.delete({ where: { id: testKbId } }); } catch {} } await app.close(); } }); // ═══════════════════════════════════════════════════════ // 场景 1: Legacy 模式生成成功 // ═══════════════════════════════════════════════════════ describe('场景 1: Legacy 模式', () => { it('QUIZ_GENERATION_ENGINE_MODE=disabled → legacy', async () => { await prisma.featureFlag.update({ where: { name: 'QUIZ_GENERATION_ENGINE_MODE' }, data: { enabled: false } }); const res = await request(app.getHttpServer()) .post('/api/ai/jobs') .set('Authorization', `Bearer ${userToken}`) .send({ jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: testKbId, questionCount: 3 }) .expect(201); expect(res.body.jobId).toBeTruthy(); // Legacy response has no engineMode expect(res.body.engineMode).toBeUndefined(); // Cleanup try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {} await prisma.featureFlag.update({ where: { name: 'QUIZ_GENERATION_ENGINE_MODE' }, data: { enabled: true, whitelist: userId } }); }); }); // ═══════════════════════════════════════════════════════ // 场景 2: Unified 模式完整成功 // ═══════════════════════════════════════════════════════ 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: 'quiz_generation', targetType: 'knowledge_base', targetId: testKbId, questionCount: 3, questionTypes: ['choice'], difficultyLevel: 'easy' }) .expect(201); expect(res.body.jobId).toBeTruthy(); expect(res.body.status).toBe('queued'); expect(res.body.engineMode).toBe('unified'); jobId = res.body.jobId; // Verify AiJob created const job = await prisma.aiJob.findUnique({ where: { id: jobId } }); expect(job).toBeTruthy(); expect(job!.jobType).toBe('quiz_generation'); // Verify Snapshot const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId } }); expect(snap).toBeTruthy(); expect(snap!.schemaVersion).toBe('quiz-generation-v1'); // Verify Outbox const outbox = await (prisma as any).outboxEvent.findFirst({ where: { aggregateId: jobId } }); expect(outbox).toBeTruthy(); expect(outbox.eventType).toBe('ai.job.enqueue'); }); 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 {} } }); }); // ═══════════════════════════════════════════════════════ // 场景 3: 幂等 — 相同 submission 返回同一个 Job // ═══════════════════════════════════════════════════════ describe('场景 3: 幂等', () => { it('相同 idempotencyKey → 相同 jobId', async () => { const body = { jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: testKbId, questionCount: 3, idempotencyKey: 'e2e-quiz-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); expect(await prisma.aiJob.count({ where: { id: r1.body.jobId } })).toBe(1); 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 {} }); }); // ═══════════════════════════════════════════════════════ // 场景 7: 跨用户拒绝 // ═══════════════════════════════════════════════════════ describe('场景 7: 跨用户权限', () => { it('User B 请求 User A 的 KB → Forbidden', async () => { const res = await request(app.getHttpServer()) .post('/api/ai/jobs') .set('Authorization', `Bearer ${userToken2}`) .send({ jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: testKbId, questionCount: 3 }) .expect((r) => expect([403, 404]).toContain(r.status)); }); }); // ═══════════════════════════════════════════════════════ // 场景 12: 旧查询兼容 // ═══════════════════════════════════════════════════════ describe('场景 12: 旧查询兼容', () => { it('listQuizzes 可查询', async () => { const res = await request(app.getHttpServer()) .get('/api/ai/quizzes') .set('Authorization', `Bearer ${userToken}`) .expect(200); expect(Array.isArray(res.body)).toBe(true); }); }); // ═══════════════════════════════════════════════════════ // 场景 13: 答案安全 // ═══════════════════════════════════════════════════════ describe('场景 13: 答案不泄漏', () => { it('getQuizQuestions 不返回 answer(未提交前)', async () => { // Create a quiz directly via Prisma for testing const quiz = await prisma.quiz.create({ data: { userId, knowledgeBaseId: testKbId, title: 'Security Test Quiz', questionCount: 1, sourceType: 'ai', status: 'ready' }, }); await prisma.quizQuestion.create({ data: { quizId: quiz.id, type: 'choice', stem: 'Test Q?', options: ['A', 'B'], answer: '0', explanation: 'Because', orderIndex: 0 }, }); const res = await request(app.getHttpServer()) .get(`/api/ai/quizzes/${quiz.id}/questions`) .set('Authorization', `Bearer ${userToken}`) .expect(200); // No answer should be returned for unanswered quiz for (const q of res.body) { expect(q.answer).toBeUndefined(); expect(q.explanation).toBeUndefined(); } // Cleanup await prisma.quizQuestion.deleteMany({ where: { quizId: quiz.id } }); await prisma.quiz.delete({ where: { id: quiz.id } }); }); }); // ═══════════════════════════════════════════════════════ // 场景 14: 回滚 // ═══════════════════════════════════════════════════════ describe('场景 14: 回滚到 Legacy', () => { it('关闭 FeatureFlag 后走 Legacy', async () => { await prisma.featureFlag.update({ where: { name: 'QUIZ_GENERATION_ENGINE_MODE' }, data: { enabled: false } }); const res = await request(app.getHttpServer()) .post('/api/ai/jobs') .set('Authorization', `Bearer ${userToken}`) .send({ jobType: 'quiz_generation', targetType: 'knowledge_base', targetId: testKbId, questionCount: 3 }) .expect(201); expect(res.body.jobId).toBeTruthy(); expect(res.body.engineMode).toBeUndefined(); // Legacy try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {} await prisma.featureFlag.update({ where: { name: 'QUIZ_GENERATION_ENGINE_MODE' }, data: { enabled: true, whitelist: userId } }); }); }); });