Scenario 7 E2E fix: - Asset correct HTTP 403 (was 201 in earlier draft) - Add before/after zero-side-effect verification (7 tables) - Add model-call-count = 0 verification (via UsageLog) - Add stable error structure assertion (no stack/Prisma/DATABASE_URL) - Add Legacy path behavior documentation (known limitation: Legacy does not accept knowledgeItemId, no ownership check) Real call chain: FeynmanExecutionRouter.evaluateFeynman():121 → FeynmanSnapshotBuilder.build():102-110 → knowledgeItem.userId !== input.userId → ForbiddenException → before AiJobCreationService.createJob():124 → NestJS → HTTP 403 Co-Authored-By: Claude <noreply@anthropic.com>
620 lines
26 KiB
TypeScript
620 lines
26 KiB
TypeScript
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-05-07: Feynman 真实业务 E2E
|
||
*
|
||
* 核心阻断场景(14 场景全部覆盖):
|
||
* 1. Legacy 模式原链路成功
|
||
* 2. Unified 模式完整成功(HTTP → Job + Snapshot + Outbox)
|
||
* 3. 相同 submission 重复请求返回同一 Job(幂等)
|
||
* 4. 重复消费不产生重复 Result(Projector 幂等)
|
||
* 5. 重复消费不产生重复 FocusItem
|
||
* 6. 重复消费不产生重复 ReviewCard
|
||
* 7. 其他用户知识点请求被拒绝(权限)
|
||
* 8. Unified 创建失败不调用 Legacy
|
||
* 9. Provider 永久失败后 Job failed
|
||
* 10. Projector 失败无部分业务产物
|
||
* 11. 旧查询接口可读取 Unified Result
|
||
* 12. 原复习页面可读取 FocusItem 和 ReviewCard
|
||
* 13. Feature Flag 切回 Legacy 后新请求走旧链路
|
||
* 14. 公开错误无内部信息
|
||
*/
|
||
|
||
const userId = 'm-ai-05-e2e-user';
|
||
const userId2 = 'm-ai-05-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-05 Feynman E2E (real infra)', () => {
|
||
let app: INestApplication;
|
||
let prisma: PrismaService;
|
||
let jwtService: JwtService;
|
||
let userToken: string;
|
||
let userToken2: string;
|
||
let infraAvailable = false;
|
||
let testKnowledgeItemId: string;
|
||
|
||
beforeAll(async () => {
|
||
infraAvailable = await checkInfra();
|
||
if (!infraAvailable) {
|
||
throw new Error(
|
||
'[M-AI-05 E2E] MySQL/Redis not available — E2E tests require real infrastructure.\n' +
|
||
'Run: docker start mysql redis\n' +
|
||
'This is a HARD FAIL: core scenarios must not silently skip.',
|
||
);
|
||
}
|
||
|
||
process.env.NODE_ENV = 'test';
|
||
process.env.JWT_SECRET = 'm-ai-05-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@test.com', role: 'USER', type: 'user',
|
||
});
|
||
userToken2 = jwtService.sign({
|
||
sub: userId2, id: userId2, email: 'e2e2@test.com', role: 'USER', type: 'user',
|
||
});
|
||
|
||
// 创建测试 KnowledgeItem
|
||
const ki = await prisma.knowledgeItem.upsert({
|
||
where: { id: 'm-ai-05-e2e-ki-001' },
|
||
create: {
|
||
id: 'm-ai-05-e2e-ki-001',
|
||
userId,
|
||
knowledgeBaseId: 'm-ai-05-e2e-kb-001',
|
||
itemType: 'concept',
|
||
title: '光合作用',
|
||
content: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||
summary: '光合作用的基本原理',
|
||
learnable: true,
|
||
status: 'active',
|
||
orderIndex: 0,
|
||
},
|
||
update: { userId, title: '光合作用' },
|
||
});
|
||
testKnowledgeItemId = ki.id;
|
||
|
||
// 确保 knowledgeBase 存在
|
||
await prisma.knowledgeBase.upsert({
|
||
where: { id: 'm-ai-05-e2e-kb-001' },
|
||
create: {
|
||
id: 'm-ai-05-e2e-kb-001',
|
||
userId,
|
||
title: 'E2E Test KB',
|
||
description: 'E2E test knowledge base',
|
||
status: 'active',
|
||
},
|
||
update: {},
|
||
});
|
||
|
||
// 启用 Unified FeatureFlag(白名单:仅 e2e 用户)
|
||
await prisma.featureFlag.upsert({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
create: { name: 'FEYNMAN_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.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: false, whitelist: '' },
|
||
});
|
||
} catch {}
|
||
try { await prisma.aiJobArtifact.deleteMany({ where: { job: { userId } } }); } catch {}
|
||
try { await prisma.aiAnalysisResult.deleteMany({ where: { userId } }); } catch {}
|
||
try { await prisma.focusItem.deleteMany({ where: { 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.delete({ where: { id: testKnowledgeItemId } }); } catch {}
|
||
try { await prisma.knowledgeBase.delete({ where: { id: 'm-ai-05-e2e-kb-001' } }); } catch {}
|
||
}
|
||
await app.close();
|
||
}
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 1: Legacy 模式原链路成功
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 1: Legacy 模式原链路成功', () => {
|
||
it('FEYNMAN_ENGINE_MODE=disabled → 走 Legacy 路径', async () => {
|
||
// 临时关闭 FeatureFlag
|
||
await prisma.featureFlag.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: false },
|
||
});
|
||
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '植物利用光能的过程',
|
||
userExplanation: '光合作用就像植物做饭',
|
||
})
|
||
.expect(201);
|
||
|
||
// Legacy 响应
|
||
expect(res.body.jobId).toBeTruthy();
|
||
expect(res.body.status).toBe('queued');
|
||
// Legacy 不含 engineMode
|
||
expect(res.body.engineMode).toBeUndefined();
|
||
|
||
// 恢复 FeatureFlag
|
||
await prisma.featureFlag.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: true, whitelist: userId },
|
||
});
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 2: Unified 模式完整成功
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 2: Unified 模式完整成功', () => {
|
||
let unifiedJobId: string;
|
||
|
||
it('HTTP → Job + Snapshot + Outbox 同事务', async () => {
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
|
||
userExplanation: '光合作用就像植物的"做饭"过程,用阳光作为能源,把CO2和水变成食物。',
|
||
sessionId: 'e2e-session-001',
|
||
answerId: 'e2e-answer-001',
|
||
knowledgeItemId: testKnowledgeItemId,
|
||
})
|
||
.expect(201);
|
||
|
||
expect(res.body.jobId).toBeTruthy();
|
||
expect(res.body.status).toBe('queued');
|
||
expect(res.body.engineMode).toBe('unified');
|
||
expect(res.body.lifecycleStatus).toBe('queued');
|
||
unifiedJobId = res.body.jobId;
|
||
|
||
// 验证 AiJob 已创建
|
||
const job = await prisma.aiJob.findUnique({ where: { id: unifiedJobId } });
|
||
expect(job).toBeTruthy();
|
||
expect(job!.jobType).toBe('feynman_evaluation');
|
||
expect(job!.lifecycleStatus).toBe('queued');
|
||
expect(job!.targetType).toBe('knowledge_item');
|
||
expect(job!.targetId).toBe(testKnowledgeItemId);
|
||
|
||
// 验证 Snapshot 已创建
|
||
const snap = await prisma.aiJobSnapshot.findUnique({ where: { jobId: unifiedJobId } });
|
||
expect(snap).toBeTruthy();
|
||
expect(snap!.schemaVersion).toBe('feynman-evaluation-v1');
|
||
const content = snap!.content as any;
|
||
expect(content.snapshot.userId).toBe(userId);
|
||
expect(content.snapshot.knowledgeItemTitle).toBe('光合作用');
|
||
expect(content.snapshot.userExplanation).toContain('做饭');
|
||
|
||
// 验证 OutboxEvent 已创建
|
||
const outbox = await (prisma as any).outboxEvent.findFirst({
|
||
where: { aggregateId: unifiedJobId },
|
||
});
|
||
expect(outbox).toBeTruthy();
|
||
expect(outbox.eventType).toBe('ai.job.enqueue');
|
||
|
||
// 验证 Outbox payload 最小化(只有 jobId)
|
||
const payload = outbox.payload as any;
|
||
expect(payload.jobId).toBe(unifiedJobId);
|
||
// Redis Payload 只有 {jobId}
|
||
const payloadKeys = Object.keys(payload);
|
||
expect(payloadKeys).toHaveLength(1);
|
||
expect(payloadKeys[0]).toBe('jobId');
|
||
|
||
// 验证 Snapshot 不含敏感字段
|
||
const serialized = JSON.stringify(content);
|
||
expect(serialized).not.toContain('"Authorization"');
|
||
expect(serialized).not.toContain('"JWT"');
|
||
expect(serialized).not.toContain('"apiKey"');
|
||
expect(serialized).not.toContain('"password"');
|
||
expect(serialized).not.toContain('"DATABASE_URL"');
|
||
expect(serialized).not.toContain('"REDIS_URL"');
|
||
});
|
||
|
||
afterAll(async () => {
|
||
if (unifiedJobId && infraAvailable) {
|
||
try { await prisma.aiJobArtifact.deleteMany({ where: { jobId: unifiedJobId } }); } catch {}
|
||
try { await prisma.aiAnalysisResult.deleteMany({ where: { jobId: unifiedJobId } }); } catch {}
|
||
try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId: unifiedJobId } }); } catch {}
|
||
try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: unifiedJobId } }); } catch {}
|
||
try { await prisma.aiJob.delete({ where: { id: unifiedJobId } }); } catch {}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 3: 相同 submission 重复请求返回同一 Job(幂等)
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 3: 重复提交幂等', () => {
|
||
it('相同 sessionId+answerId → 相同 jobId,不创建多个 Job', async () => {
|
||
const body = {
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '植物利用光能的过程。',
|
||
userExplanation: '幂等测试解释',
|
||
sessionId: 'e2e-idempotent-session',
|
||
answerId: 'e2e-idempotent-answer',
|
||
knowledgeItemId: testKnowledgeItemId,
|
||
};
|
||
|
||
const res1 = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send(body)
|
||
.expect(201);
|
||
|
||
const res2 = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send(body)
|
||
.expect(201);
|
||
|
||
// 同一个 Job
|
||
expect(res2.body.jobId).toBe(res1.body.jobId);
|
||
|
||
// 数据库中只有一个 Job
|
||
const jobCount = await prisma.aiJob.count({
|
||
where: { id: res1.body.jobId },
|
||
});
|
||
expect(jobCount).toBe(1);
|
||
|
||
// 只有一个 Snapshot
|
||
const snapCount = await prisma.aiJobSnapshot.count({
|
||
where: { jobId: res1.body.jobId },
|
||
});
|
||
expect(snapCount).toBe(1);
|
||
|
||
// 清理
|
||
try { await prisma.aiJobArtifact.deleteMany({ where: { jobId: res1.body.jobId } }); } catch {}
|
||
try { await prisma.aiJobSnapshot.deleteMany({ where: { jobId: res1.body.jobId } }); } catch {}
|
||
try { await (prisma as any).outboxEvent.deleteMany({ where: { aggregateId: res1.body.jobId } }); } catch {}
|
||
try { await prisma.aiJob.delete({ where: { id: res1.body.jobId } }); } catch {}
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 7: 其他用户知识点请求被拒绝(权限)
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 7: P0 跨用户权限拒绝
|
||
//
|
||
// Fixture:
|
||
// User A (userId) → JWT userToken
|
||
// User B (userId2) → JWT userToken2
|
||
// KnowledgeItem A → testKnowledgeItemId, userId=userId (User A)
|
||
//
|
||
// 请求:
|
||
// User B (userToken2) + KnowledgeItem A (testKnowledgeItemId)
|
||
//
|
||
// 预期:
|
||
// HTTP 403 Forbidden
|
||
// SnapshotBuilder 检测 knowledgeItem.userId !== input.userId
|
||
// 异常在 AiJobCreationService.createJob() 之前传播
|
||
// 零数据库副作用 / 零模型调用
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 7: 跨用户权限拒绝', () => {
|
||
it('User B 提交 User A 的知识点 → 403 + 零副作用', async () => {
|
||
// ── 请求前计数 ──
|
||
const countsBefore = {
|
||
aiJob: await prisma.aiJob.count({ where: { userId: userId2 } }),
|
||
aiJobSnapshot: await prisma.aiJobSnapshot.count(),
|
||
outboxEvent: await (prisma as any).outboxEvent.count(),
|
||
aiUsageLog: await (prisma as any).aiUsageLog.count({ where: { userId: userId2 } }),
|
||
aiAnalysisResult: await prisma.aiAnalysisResult.count({ where: { userId: userId2 } }),
|
||
focusItem: await prisma.focusItem.count({ where: { userId: userId2 } }),
|
||
aiJobArtifact: await prisma.aiJobArtifact.count(),
|
||
};
|
||
|
||
// ── 跨用户请求 ──
|
||
// User B (userToken2) 使用 User A 的 KnowledgeItem ID
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken2}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '植物利用光能的过程',
|
||
userExplanation: 'User B 尝试评估 User A 的知识点',
|
||
knowledgeItemId: testKnowledgeItemId, // 属于 User A
|
||
sessionId: 'e2e-cross-user-session',
|
||
answerId: 'e2e-cross-user-answer',
|
||
})
|
||
.expect(403);
|
||
|
||
// ── 错误响应结构 ──
|
||
// NestJS ForbiddenException → { statusCode: 403, message: 'Forbidden' }
|
||
expect(res.body).toBeDefined();
|
||
expect(res.body.statusCode || res.body.status).toBe(403);
|
||
// 不含内部堆栈
|
||
if (res.body.message) {
|
||
expect(typeof res.body.message).toBe('string');
|
||
expect(res.body.message).not.toContain('at ');
|
||
expect(res.body.message).not.toContain('node_modules');
|
||
expect(res.body.message).not.toContain('Prisma');
|
||
expect(res.body.message).not.toContain('DATABASE_URL');
|
||
}
|
||
|
||
// ── 请求后计数 ──
|
||
const countsAfter = {
|
||
aiJob: await prisma.aiJob.count({ where: { userId: userId2 } }),
|
||
aiJobSnapshot: await prisma.aiJobSnapshot.count(),
|
||
outboxEvent: await (prisma as any).outboxEvent.count(),
|
||
aiUsageLog: await (prisma as any).aiUsageLog.count({ where: { userId: userId2 } }),
|
||
aiAnalysisResult: await prisma.aiAnalysisResult.count({ where: { userId: userId2 } }),
|
||
focusItem: await prisma.focusItem.count({ where: { userId: userId2 } }),
|
||
aiJobArtifact: await prisma.aiJobArtifact.count(),
|
||
};
|
||
|
||
// ── 零副作用验证 ──
|
||
expect(countsAfter.aiJob).toBe(countsBefore.aiJob); // 0 new AiJob
|
||
expect(countsAfter.aiJobSnapshot).toBe(countsBefore.aiJobSnapshot); // 0 new Snapshot
|
||
expect(countsAfter.outboxEvent).toBe(countsBefore.outboxEvent); // 0 new Outbox
|
||
expect(countsAfter.aiUsageLog).toBe(countsBefore.aiUsageLog); // 0 new UsageLog (≡ 0 model calls)
|
||
expect(countsAfter.aiAnalysisResult).toBe(countsBefore.aiAnalysisResult); // 0 new Result
|
||
expect(countsAfter.focusItem).toBe(countsBefore.focusItem); // 0 new FocusItem
|
||
expect(countsAfter.aiJobArtifact).toBe(countsBefore.aiJobArtifact); // 0 new Artifact
|
||
|
||
// ── 验证 User B 下绝对没有使用 User A 知识点的 Job ──
|
||
const crossJobs = await prisma.aiJob.findMany({
|
||
where: { userId: userId2, targetId: testKnowledgeItemId },
|
||
});
|
||
expect(crossJobs).toHaveLength(0);
|
||
});
|
||
|
||
it('Legacy 路径:User B 提交时 Router 回退到 Legacy(无 knowledgeItemId)', async () => {
|
||
// Legacy 路径不接受 knowledgeItemId,不执行所有权校验。
|
||
// 这是已知的 Legacy 设计限制 — 记录现状,不在本批修复。
|
||
// 生产默认保持 Legacy 时,依赖客户端不会伪造 knowledgeItemTitle/content。
|
||
|
||
// 临时关闭 Unified FeatureFlag
|
||
await prisma.featureFlag.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: false },
|
||
});
|
||
|
||
const countsBefore = await prisma.aiJob.count({ where: { userId: userId2 } });
|
||
|
||
// Legacy 请求(不含 knowledgeItemId — Legacy 不支持此字段)
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken2}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '植物利用光能的过程',
|
||
userExplanation: 'Legacy 跨用户测试',
|
||
})
|
||
.expect(201);
|
||
|
||
// Legacy 接受请求(无所有权校验)
|
||
expect(res.body.jobId).toBeTruthy();
|
||
expect(res.body.engineMode).toBeUndefined(); // Legacy 不含此字段
|
||
|
||
const countsAfter = await prisma.aiJob.count({ where: { userId: userId2 } });
|
||
// Legacy 创建了 Job — 已知限制,不在本批修复
|
||
expect(countsAfter).toBeGreaterThan(countsBefore);
|
||
|
||
// 清理 Legacy 创建的 Job
|
||
if (res.body.jobId) {
|
||
try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {}
|
||
}
|
||
|
||
// 恢复 FeatureFlag
|
||
await prisma.featureFlag.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: true, whitelist: userId },
|
||
});
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 8: Unified 创建失败不调用 Legacy
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 8: Unified 失败不 fallback Legacy', () => {
|
||
it('Unified 路径失败不自动调用 Legacy', async () => {
|
||
// 使用无效的 knowledgeItemId 触发 SnapshotBuilder 失败
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '测试内容',
|
||
userExplanation: '测试解释',
|
||
knowledgeItemId: 'non-existent-ki-99999',
|
||
})
|
||
// 期望 500 或 404(SnapshotBuilder 抛 NotFoundException)
|
||
.expect((res) => {
|
||
expect([201, 404, 500]).toContain(res.status);
|
||
});
|
||
|
||
// 如果返回 201,不应该是 Legacy Job(不应有 engineMode 缺失)
|
||
if (res.status === 201 && res.body.jobId) {
|
||
// 验证这个 Job 是 Unified(不是 Legacy)
|
||
const job = await prisma.aiJob.findUnique({ where: { id: res.body.jobId } });
|
||
if (job) {
|
||
// 即使是 Unified,也应标记 jobType
|
||
expect(job.jobType).toBe('feynman_evaluation');
|
||
// 清理
|
||
try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {}
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 11: 旧查询接口可读取 Unified Result
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 11: 旧查询接口兼容', () => {
|
||
it('GET /api/ai-analysis/jobs/:id 可查询 Unified Job', async () => {
|
||
// 先创建一个 Unified Job
|
||
const createRes = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '测试内容',
|
||
userExplanation: '查询兼容性测试',
|
||
sessionId: 'e2e-query-session',
|
||
answerId: 'e2e-query-answer',
|
||
knowledgeItemId: testKnowledgeItemId,
|
||
})
|
||
.expect(201);
|
||
|
||
const jobId = createRes.body.jobId;
|
||
|
||
// 通过旧接口查询
|
||
const queryRes = await request(app.getHttpServer())
|
||
.get(`/api/ai-analysis/jobs/${jobId}`)
|
||
.expect(200);
|
||
|
||
expect(queryRes.body.id).toBe(jobId);
|
||
expect(queryRes.body.type).toBe('feynman_evaluation');
|
||
// 旧状态字段兼容
|
||
expect(['pending', 'queued']).toContain(queryRes.body.status);
|
||
|
||
// 清理
|
||
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 {}
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 13: Feature Flag 切回 Legacy 后新请求走旧链路
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 13: 回滚 — Unified → Legacy', () => {
|
||
it('关闭 FeatureFlag 后新请求走 Legacy', async () => {
|
||
// 关闭 FeatureFlag
|
||
await prisma.featureFlag.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: false },
|
||
});
|
||
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '测试内容',
|
||
userExplanation: '回滚测试',
|
||
})
|
||
.expect(201);
|
||
|
||
// Legacy 响应:没有 engineMode
|
||
expect(res.body.jobId).toBeTruthy();
|
||
expect(res.body.engineMode).toBeUndefined();
|
||
|
||
// 验证走的是 Legacy 路径:jobType 应为 'feynman-evaluation'(旧格式)
|
||
const job = await prisma.aiJob.findUnique({ where: { id: res.body.jobId } });
|
||
if (job) {
|
||
expect(job.jobType).toBe('feynman-evaluation'); // Legacy jobType 使用连字符
|
||
}
|
||
|
||
// 恢复 FeatureFlag
|
||
await prisma.featureFlag.update({
|
||
where: { name: 'FEYNMAN_ENGINE_MODE' },
|
||
data: { enabled: true, whitelist: userId },
|
||
});
|
||
|
||
// 清理
|
||
if (res.body.jobId) {
|
||
try { await prisma.aiJob.delete({ where: { id: res.body.jobId } }); } catch {}
|
||
}
|
||
});
|
||
});
|
||
|
||
// ═══════════════════════════════════════════════════════════
|
||
// 场景 14: 公开错误无内部信息
|
||
// ═══════════════════════════════════════════════════════════
|
||
|
||
describe('场景 14: 公开错误脱敏', () => {
|
||
it('Unified 创建失败的错误响应不含内部信息', async () => {
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({
|
||
knowledgeItemTitle: '', // 空标题 — 应触发参数校验错误
|
||
knowledgeItemContent: '',
|
||
userExplanation: '',
|
||
});
|
||
|
||
// 不应返回内部堆栈或敏感信息
|
||
if (res.body.message) {
|
||
expect(typeof res.body.message).toBe('string');
|
||
expect(res.body.message).not.toContain('Prisma');
|
||
expect(res.body.message).not.toContain('DATABASE_URL');
|
||
expect(res.body.message).not.toContain('at ');
|
||
expect(res.body.message).not.toContain('node_modules');
|
||
}
|
||
});
|
||
|
||
it('缺少必填参数返回明确错误', async () => {
|
||
const res = await request(app.getHttpServer())
|
||
.post('/api/ai-analysis/feynman')
|
||
.set('Authorization', `Bearer ${userToken}`)
|
||
.send({})
|
||
.expect((res) => {
|
||
expect([400, 500]).toContain(res.status);
|
||
});
|
||
});
|
||
});
|
||
});
|