test(M-AI-07-07): quiz generation E2E + CI integration
Some checks failed
Deploy API Server / build-and-unit (push) Successful in 38s
Deploy API Server / backward-compat (push) Has been cancelled
Deploy API Server / deploy (push) Has been cancelled
Deploy API Server / current-integration (push) Has been cancelled

- 15 scenarios (8 API-verifiable + 7 unit-equivalent)
- Infra hard-fail (no skip/soft-pass)
- CI: add test/m-ai-07 to integration change detection

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
wangdl 2026-06-22 20:54:02 +08:00
parent c3b8919489
commit 24f9098178
2 changed files with 309 additions and 1 deletions

View File

@ -107,7 +107,7 @@ jobs:
CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "")
fi
echo "Changed files: $CHANGED"
if echo "$CHANGED" | grep -qE "src/workers/|src/modules/ai-analysis/|src/modules/ai/|src/modules/ai-job/|src/modules/active-recall/|src/modules/review/|src/modules/focus-items/|src/infrastructure/queue/|src/infrastructure/outbox/|prisma/schema.prisma|prisma/migrations/|test/worker-integration|test/run-integration|test/m-ai-04|test/m-ai-05|test/m-ai-06"; then
if echo "$CHANGED" | grep -qE "src/workers/|src/modules/ai-analysis/|src/modules/ai/|src/modules/ai-job/|src/modules/active-recall/|src/modules/review/|src/modules/focus-items/|src/infrastructure/queue/|src/infrastructure/outbox/|prisma/schema.prisma|prisma/migrations/|test/worker-integration|test/run-integration|test/m-ai-04|test/m-ai-05|test/m-ai-06|test/m-ai-07"; then
echo "Worker-related changes detected — running integration tests"
echo "run_int=true" > /tmp/int-decision
else

View File

@ -0,0 +1,308 @@
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<boolean> {
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<boolean> =>
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 } });
});
});
});