From 03c1b06294eeba3a871eac1385f9eeeb5e3d7c2c Mon Sep 17 00:00:00 2001 From: wangdl Date: Mon, 22 Jun 2026 21:56:13 +0800 Subject: [PATCH] test(M-AI-08-07): learning analysis E2E + CI integration - 4 API-verifiable scenarios (Legacy, Unified, Idempotency, Rollback) - Infra hard-fail (no skip/soft-pass) - CI: test/m-ai-08 added to integration change detection Co-Authored-By: Claude --- .gitea/workflows/deploy.yml | 2 +- test/m-ai-08-learning-analysis.e2e-spec.ts | 158 +++++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 test/m-ai-08-learning-analysis.e2e-spec.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e00e477..fdb4db3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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|test/m-ai-07"; 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|test/m-ai-08"; then echo "Worker-related changes detected — running integration tests" echo "run_int=true" > /tmp/int-decision else diff --git a/test/m-ai-08-learning-analysis.e2e-spec.ts b/test/m-ai-08-learning-analysis.e2e-spec.ts new file mode 100644 index 0000000..21c57e4 --- /dev/null +++ b/test/m-ai-08-learning-analysis.e2e-spec.ts @@ -0,0 +1,158 @@ +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 } }); + }); + }); +});