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 <noreply@anthropic.com>
This commit is contained in:
parent
b779d1f3ce
commit
03c1b06294
@ -107,7 +107,7 @@ jobs:
|
|||||||
CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "")
|
CHANGED=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || echo "")
|
||||||
fi
|
fi
|
||||||
echo "Changed files: $CHANGED"
|
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 "Worker-related changes detected — running integration tests"
|
||||||
echo "run_int=true" > /tmp/int-decision
|
echo "run_int=true" > /tmp/int-decision
|
||||||
else
|
else
|
||||||
|
|||||||
158
test/m-ai-08-learning-analysis.e2e-spec.ts
Normal file
158
test/m-ai-08-learning-analysis.e2e-spec.ts
Normal file
@ -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<boolean> {
|
||||||
|
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<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);
|
||||||
|
});
|
||||||
|
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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user