api-server/test/m-ai-05-feynman.e2e-spec.ts
wangdl b9be87e805 test(M-AI-05): enforce cross-user Feynman authorization
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>
2026-06-21 17:46:14 +08:00

620 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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. 重复消费不产生重复 ResultProjector 幂等)
* 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 或 404SnapshotBuilder 抛 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);
});
});
});
});