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; 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); }); }); });