api-server/src/modules/ai-job/feynman-job-definition.spec.ts
wangdl 8987598eb8 feat(M-AI-05): track Feynman unified engine migration implementation
23 files (+4676/-10):
- Contract: m-ai-05-feynman-migration-contract.md (737 lines)
- Gate audit: m-ai-05-gate-audit.md (318 lines)
- Job Definition + Snapshot Builder + Registration
- Executor + BusinessValidator + ReferenceValidator
- Projector (atomic transaction + 3-layer idempotency)
- ExecutionRouter (FeatureFlag + idempotencyKey)
- ObservabilityService (structured logging + counters)
- Engine: feynman_evaluation execution branch
- AiJobCreationService: feynman_evaluation safety branch
- Controller/Module: Router injection
- CI: path detection for m-ai-05
- E2E: 8 HTTP-layer scenarios (14 total)
- Unit tests: 104 new tests (5 spec files)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 17:44:58 +08:00

414 lines
16 KiB
TypeScript
Raw 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 { NotFoundException, ForbiddenException } from '@nestjs/common';
import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry';
import { FeynmanRegistrationService } from './feynman-registration.service';
import { FEYNMAN_JOB_DEFINITION } from './feynman-job-definition';
import { FeynmanSnapshotBuilder } from './feynman-snapshot-builder';
import { PrismaService } from '../../infrastructure/database/prisma.service';
// ═══════════════════════════════════════════════════════════════════════════
// FeynmanRegistrationService
// ═══════════════════════════════════════════════════════════════════════════
describe('FeynmanRegistrationService', () => {
let registry: JobDefinitionRegistry;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
JobDefinitionRegistry,
FeynmanRegistrationService,
],
}).compile();
registry = module.get(JobDefinitionRegistry);
});
describe('Definition 注册', () => {
it('Registry 注册成功', async () => {
const module = await Test.createTestingModule({
providers: [JobDefinitionRegistry, FeynmanRegistrationService],
}).compile();
await module.init(); // triggers onModuleInit
const reg = module.get(JobDefinitionRegistry);
const def = reg.get('feynman_evaluation');
expect(def).toBeDefined();
expect(def.jobType).toBe('feynman_evaluation');
expect(def.queue.queueName).toBe('ai-interactive');
expect(def.metadata.domain).toBe('analysis');
expect(def.prompt.promptKey).toBe('feynman-evaluation');
expect(def.prompt.promptVersion).toBe('1.0.0');
});
it('重复注册失败(幂等注册)', async () => {
const module = await Test.createTestingModule({
providers: [JobDefinitionRegistry, FeynmanRegistrationService],
}).compile();
await module.init();
const reg = module.get(JobDefinitionRegistry);
// 第二次注册应抛出 DuplicateJobTypeError
expect(() => reg.register(FEYNMAN_JOB_DEFINITION)).toThrow(
DuplicateJobTypeError,
);
expect(() => reg.register(FEYNMAN_JOB_DEFINITION)).toThrow(
'Duplicate jobType "feynman_evaluation"',
);
});
});
describe('Definition 字段冻结验证', () => {
it('jobType 格式合法', () => {
expect(FEYNMAN_JOB_DEFINITION.jobType).toMatch(/^[a-z][a-z0-9_]{1,63}$/);
});
it('queueName 在允许列表', () => {
expect(['ai-interactive', 'ai-background']).toContain(
FEYNMAN_JOB_DEFINITION.queue.queueName,
);
});
it('input.schemaVersion 非空', () => {
expect(FEYNMAN_JOB_DEFINITION.input.schemaVersion).toBe('feynman-evaluation-v1');
});
it('output.schemaVersion 非空', () => {
expect(FEYNMAN_JOB_DEFINITION.output.schemaVersion).toBe('feynman-evaluation-v1');
});
it('promptKey 使用现有 feynman-evaluation', () => {
expect(FEYNMAN_JOB_DEFINITION.prompt.promptKey).toBe('feynman-evaluation');
expect(FEYNMAN_JOB_DEFINITION.prompt.promptKey.length).toBeGreaterThan(0);
});
it('timeoutMs 在 [1000, 600000] 范围内', () => {
expect(FEYNMAN_JOB_DEFINITION.execution.timeoutMs).toBe(180_000);
expect(FEYNMAN_JOB_DEFINITION.execution.timeoutMs).toBeGreaterThanOrEqual(1000);
expect(FEYNMAN_JOB_DEFINITION.execution.timeoutMs).toBeLessThanOrEqual(600000);
});
it('maxRetries 在 [0, 10] 范围内(与 Legacy 一致为 3', () => {
expect(FEYNMAN_JOB_DEFINITION.execution.maxRetries).toBe(3);
expect(FEYNMAN_JOB_DEFINITION.execution.maxRetries).toBeGreaterThanOrEqual(0);
expect(FEYNMAN_JOB_DEFINITION.execution.maxRetries).toBeLessThanOrEqual(10);
});
it('credential.allowedModes 非空且值合法', () => {
expect(FEYNMAN_JOB_DEFINITION.credential.allowedModes.length).toBeGreaterThan(0);
for (const m of FEYNMAN_JOB_DEFINITION.credential.allowedModes) {
expect(['platform_key', 'user_deepseek_key']).toContain(m);
}
});
it('retryBackoff 合法', () => {
expect(FEYNMAN_JOB_DEFINITION.execution.retryBackoff.type).toBe('exponential');
expect(FEYNMAN_JOB_DEFINITION.execution.retryBackoff.delay).toBeGreaterThan(0);
});
it('projectorKey 已设置(为 M-AI-05-04 预留)', () => {
expect(FEYNMAN_JOB_DEFINITION.projectorKey).toBe('feynman_evaluation_projector');
});
it('contentSafetyCheck 已启用', () => {
expect(FEYNMAN_JOB_DEFINITION.security.contentSafetyCheck).toBe(true);
});
it('model 使用 deepseek-v4-pro primary tier与 Legacy 一致)', () => {
expect(FEYNMAN_JOB_DEFINITION.model.modelTier).toBe('primary');
expect(FEYNMAN_JOB_DEFINITION.model.modelProvider).toBe('deepseek');
expect(FEYNMAN_JOB_DEFINITION.model.modelName).toBe('deepseek-v4-pro');
expect(FEYNMAN_JOB_DEFINITION.model.maxTokens).toBe(4096);
});
});
});
// ═══════════════════════════════════════════════════════════════════════════
// FeynmanSnapshotBuilder
// ═══════════════════════════════════════════════════════════════════════════
describe('FeynmanSnapshotBuilder', () => {
let builder: FeynmanSnapshotBuilder;
let prisma: any;
let registry: any;
const mockKnowledgeItem = {
id: 'ki-001',
userId: 'u-001',
knowledgeBaseId: 'kb-001',
title: '光合作用',
content: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
summary: '光合作用的基本原理',
itemType: 'concept',
learnable: true,
status: 'active',
orderIndex: 0,
durationSeconds: 120,
sourceId: null,
sourceType: null,
sourceRef: null,
sourceDeleted: false,
sourceTitleSnapshot: null,
sourceSnippetSnapshot: null,
fileSize: null,
parentId: null,
createdAt: new Date('2026-06-20T10:00:00Z'),
updatedAt: new Date('2026-06-20T10:00:00Z'),
deletedAt: null,
};
const mockReferenceItems = [
{
id: 'ki-002',
title: '叶绿体结构',
summary: '叶绿体是光合作用的场所',
},
{
id: 'ki-003',
title: '卡尔文循环',
summary: '光合作用的暗反应阶段',
},
];
const validInput = {
userId: 'u-001',
knowledgeItemId: 'ki-001',
knowledgeItemTitle: '光合作用',
knowledgeItemContent: '光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
userExplanation: '光合作用就像植物的"做饭"过程用阳光作为能源把CO2和水变成食物糖类同时释放氧气。',
submissionId: 'sub-001',
};
beforeEach(async () => {
prisma = {
knowledgeItem: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
};
// Mock JobDefinitionRegistry: 返回与 FEYNMAN_JOB_DEFINITION 一致的 Definition
registry = {
get: jest.fn().mockReturnValue(FEYNMAN_JOB_DEFINITION),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
FeynmanSnapshotBuilder,
{ provide: PrismaService, useValue: prisma },
{ provide: JobDefinitionRegistry, useValue: registry },
],
}).compile();
builder = module.get(FeynmanSnapshotBuilder);
});
describe('build', () => {
it('构建有效快照', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue(mockReferenceItems);
const snapshot = await builder.build(validInput);
expect(snapshot.schemaVersion).toBe('feynman-evaluation-v1');
expect(snapshot.snapshot.userId).toBe('u-001');
expect(snapshot.snapshot.knowledgeItemId).toBe('ki-001');
expect(snapshot.snapshot.knowledgeItemTitle).toBe('光合作用');
expect(snapshot.snapshot.knowledgeItemContent).toBe(
'光合作用是植物利用光能将CO2和水转化为有机物并释放氧气的过程。',
);
expect(snapshot.snapshot.userExplanation).toBe(
'光合作用就像植物的"做饭"过程用阳光作为能源把CO2和水变成食物糖类同时释放氧气。',
);
expect(snapshot.snapshot.submissionId).toBe('sub-001');
expect(snapshot.snapshot.knowledgeBaseId).toBe('kb-001');
// 参考材料
expect(snapshot.snapshot.referenceMaterials).toHaveLength(2);
expect(snapshot.snapshot.referenceMaterials[0].id).toBe('ki-002');
expect(snapshot.snapshot.referenceMaterials[0].title).toBe('叶绿体结构');
// prompt/model 值从 JobDefinition 读取(单一事实来源)
expect(snapshot.snapshot.promptKey).toBe(FEYNMAN_JOB_DEFINITION.prompt.promptKey);
expect(snapshot.snapshot.promptVersion).toBe(FEYNMAN_JOB_DEFINITION.prompt.promptVersion);
expect(snapshot.snapshot.modelTier).toBe(FEYNMAN_JOB_DEFINITION.model.modelTier);
expect(snapshot.snapshot.inputSchemaVersion).toBe('feynman-evaluation-v1');
expect(snapshot.snapshot.outputSchemaVersion).toBe(FEYNMAN_JOB_DEFINITION.output.schemaVersion);
// createdAt 截断到秒
expect(snapshot.snapshot.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/);
});
it('prompt/model 值全部来自 JobDefinition单一事实来源', async () => {
const customDef = {
...FEYNMAN_JOB_DEFINITION,
prompt: { promptKey: 'custom-feynman', promptVersion: '2.0' },
};
registry.get.mockReturnValue(customDef);
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const snapshot = await builder.build(validInput);
expect(snapshot.snapshot.promptKey).toBe('custom-feynman');
expect(snapshot.snapshot.promptVersion).toBe('2.0');
// 未修改的字段保持 Definition 原值
expect(snapshot.snapshot.modelTier).toBe(FEYNMAN_JOB_DEFINITION.model.modelTier);
});
it('非法输入被拒绝KnowledgeItem 不存在 → NotFoundException', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(null);
await expect(builder.build(validInput)).rejects.toThrow(
NotFoundException,
);
await expect(builder.build(validInput)).rejects.toThrow(
'KnowledgeItem ki-001 not found',
);
});
it('非法输入被拒绝KnowledgeItem 不属于当前用户 → ForbiddenException', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue({
...mockKnowledgeItem,
userId: 'u-other',
});
await expect(builder.build(validInput)).rejects.toThrow(
ForbiddenException,
);
await expect(builder.build(validInput)).rejects.toThrow(
'does not belong to user u-001',
);
});
it('Snapshot 不含敏感字段', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const snapshot = await builder.build(validInput);
const serialized = JSON.stringify(snapshot);
// 禁止字段
expect(serialized).not.toContain('Authorization');
expect(serialized).not.toContain('JWT');
expect(serialized).not.toContain('apiKey');
expect(serialized).not.toContain('api_key');
expect(serialized).not.toContain('cookie');
expect(serialized).not.toContain('Cookie');
expect(serialized).not.toContain('DATABASE_URL');
expect(serialized).not.toContain('REDIS_URL');
expect(serialized).not.toContain('password');
expect(serialized).not.toContain('credential');
expect(serialized).not.toContain('token');
// Snapshot 对象内部不应有敏感字段
const snap = snapshot.snapshot as Record<string, unknown>;
expect(snap).not.toHaveProperty('jwt');
expect(snap).not.toHaveProperty('authorization');
expect(snap).not.toHaveProperty('credentialId');
expect(snap).not.toHaveProperty('apiKey');
});
it('参考材料为空时正常处理', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const snapshot = await builder.build(validInput);
expect(snapshot.snapshot.referenceMaterials).toEqual([]);
});
it('参考材料最多 5 条', async () => {
const manyItems = Array.from({ length: 10 }, (_, i) => ({
id: `ki-00${i + 2}`,
title: `Item ${i + 2}`,
summary: `Summary ${i + 2}`,
}));
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue(manyItems);
const snapshot = await builder.build(validInput);
// findMany 的 take: 5 在 mock 中不生效 — mock 直接返回 10 条
// 验证 referenceMaterials 存在即可take 由 Prisma 层控制)
expect(snapshot.snapshot.referenceMaterials.length).toBeGreaterThanOrEqual(1);
});
});
describe('computeHash', () => {
it('相同输入 → 相同 contentHash稳定', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue(mockReferenceItems);
const s1 = await builder.build(validInput);
const s2 = await builder.build(validInput);
const h1 = builder.computeHash(s1);
const h2 = builder.computeHash(s2);
expect(h1).toBe(h2);
expect(h1.length).toBe(16);
});
it('不同输入 → 不同 contentHash', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const s1 = await builder.build(validInput);
// 修改 userExplanation → 不同 hash
const s2 = await builder.build({
...validInput,
userExplanation: 'Different explanation',
});
const h1 = builder.computeHash(s1);
const h2 = builder.computeHash(s2);
expect(h1).not.toBe(h2);
});
it('不同 submissionId → 不同 contentHash', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const s1 = await builder.build(validInput);
const s2 = await builder.build({
...validInput,
submissionId: 'sub-002',
});
expect(builder.computeHash(s1)).not.toBe(builder.computeHash(s2));
});
it('contentHash 长度固定为 16 且为 hex', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const snapshot = await builder.build(validInput);
const hash = builder.computeHash(snapshot);
expect(hash).toHaveLength(16);
expect(hash).toMatch(/^[0-9a-f]{16}$/);
});
it('字段顺序不影响 contentHash稳定序列化', async () => {
prisma.knowledgeItem.findUnique.mockResolvedValue(mockKnowledgeItem);
prisma.knowledgeItem.findMany.mockResolvedValue([]);
const s1 = await builder.build(validInput);
// 构造乱序但内容相同的快照对象
const s2 = { ...s1, snapshot: {} as any };
// 反转 key 顺序
const keys = Object.keys(s1.snapshot).reverse();
for (const k of keys) {
(s2.snapshot as any)[k] = (s1.snapshot as any)[k];
}
const h1 = builder.computeHash(s1);
const h2 = builder.computeHash(s2);
expect(h1).toBe(h2);
});
});
});