import { Test, TestingModule } from '@nestjs/testing'; import { JobDefinitionRegistry, DuplicateJobTypeError } from './job-definition-registry'; import { ReviewCardGenerationRegistrationService } from './review-card-generation-registration.service'; import { REVIEW_CARD_GENERATION_JOB_DEFINITION } from './review-card-generation-job-definition'; import { ReviewCardGenerationSnapshotBuilder } from './review-card-generation-snapshot-builder'; // ═══════════════════════════════════════════════════════════════════════════ // ReviewCardGenerationRegistrationService // ═══════════════════════════════════════════════════════════════════════════ describe('ReviewCardGenerationRegistrationService', () => { let registry: JobDefinitionRegistry; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [JobDefinitionRegistry, ReviewCardGenerationRegistrationService], }).compile(); registry = module.get(JobDefinitionRegistry); }); describe('Definition 注册', () => { it('Registry 注册成功', async () => { const module = await Test.createTestingModule({ providers: [JobDefinitionRegistry, ReviewCardGenerationRegistrationService], }).compile(); await module.init(); // triggers onModuleInit const reg = module.get(JobDefinitionRegistry); const def = reg.get('review_card_generation'); expect(def).toBeDefined(); expect(def.jobType).toBe('review_card_generation'); expect(def.queue.queueName).toBe('ai-background'); expect(def.metadata.domain).toBe('generation'); expect(def.prompt.promptKey).toBe('review-card-generation'); expect(def.prompt.promptVersion).toBe('1.0.0'); }); it('重复注册失败(幂等注册)', async () => { const module = await Test.createTestingModule({ providers: [JobDefinitionRegistry, ReviewCardGenerationRegistrationService], }).compile(); await module.init(); const reg = module.get(JobDefinitionRegistry); // 第二次注册应抛出 DuplicateJobTypeError expect(() => reg.register(REVIEW_CARD_GENERATION_JOB_DEFINITION)).toThrow( DuplicateJobTypeError, ); expect(() => reg.register(REVIEW_CARD_GENERATION_JOB_DEFINITION)).toThrow( 'Duplicate jobType "review_card_generation"', ); }); }); describe('Definition 字段冻结验证', () => { it('jobType 格式合法', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.jobType).toMatch(/^[a-z][a-z0-9_]{1,63}$/); }); it('queueName 为 ai-background', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.queue.queueName).toBe('ai-background'); }); it('input.schemaVersion 非空', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.input.schemaVersion).toBe('review-card-generation-v1'); }); it('output.schemaVersion 非空', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.output.schemaVersion).toBe('review-card-generation-v1'); }); it('promptKey 为 review-card-generation', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.prompt.promptKey).toBe('review-card-generation'); expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.prompt.promptVersion).toBe('1.0.0'); }); it('timeoutMs 在 [1000, 600000] 范围内', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.execution.timeoutMs).toBeGreaterThanOrEqual(1000); expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.execution.timeoutMs).toBeLessThanOrEqual(600000); }); it('maxRetries 在 [0, 10] 范围内', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.execution.maxRetries).toBeGreaterThanOrEqual(0); expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.execution.maxRetries).toBeLessThanOrEqual(10); }); it('modelTier 为 cheap', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.model.modelTier).toBe('cheap'); }); it('modelName 为 deepseek-v4-flash(与 ModelRouter cheap tier 一致)', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.model.modelName).toBe('deepseek-v4-flash'); }); it('maxTokens 为 2048', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.model.maxTokens).toBe(2048); }); it('credential.allowedModes 合法', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.credential.allowedModes.length).toBeGreaterThan(0); for (const m of REVIEW_CARD_GENERATION_JOB_DEFINITION.credential.allowedModes) { expect(['platform_key', 'user_deepseek_key']).toContain(m); } }); it('retryBackoff 合法', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.execution.retryBackoff.type).toBe('exponential'); expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.execution.retryBackoff.delay).toBeGreaterThan(0); }); it('projectorKey 已设置', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.projectorKey).toBe('review_card_generation_projector'); }); it('contentSafetyCheck 已启用', () => { expect(REVIEW_CARD_GENERATION_JOB_DEFINITION.security.contentSafetyCheck).toBe(true); }); }); }); // ═══════════════════════════════════════════════════════════════════════════ // ReviewCardGenerationSnapshotBuilder // ═══════════════════════════════════════════════════════════════════════════ describe('ReviewCardGenerationSnapshotBuilder', () => { let builder: ReviewCardGenerationSnapshotBuilder; let registry: any; const validInput = { userId: 'u-001', parentJobId: 'parent-job-001', sourceResultId: 'fe_parent-job-001', knowledgeItemId: 'ki-001', knowledgeBaseId: 'kb-001', summary: '用户对光合作用有基本理解,但在光反应和暗反应的区别上存在混淆。', strengths: ['基本概念掌握', '术语使用正确'], weaknesses: ['光反应细节不准确', '暗反应遗漏关键步骤'], }; beforeEach(async () => { registry = { get: jest.fn().mockReturnValue(REVIEW_CARD_GENERATION_JOB_DEFINITION), }; const module: TestingModule = await Test.createTestingModule({ providers: [ ReviewCardGenerationSnapshotBuilder, { provide: JobDefinitionRegistry, useValue: registry }, ], }).compile(); builder = module.get(ReviewCardGenerationSnapshotBuilder); }); describe('build', () => { it('构建有效快照', () => { const snapshot = builder.build(validInput); expect(snapshot.schemaVersion).toBe('review-card-generation-v1'); expect(snapshot.snapshot.userId).toBe('u-001'); expect(snapshot.snapshot.parentJobId).toBe('parent-job-001'); expect(snapshot.snapshot.sourceResultId).toBe('fe_parent-job-001'); expect(snapshot.snapshot.knowledgeItemId).toBe('ki-001'); expect(snapshot.snapshot.knowledgeBaseId).toBe('kb-001'); expect(snapshot.snapshot.title).toBe('用户对光合作用有基本理解,但在光反应和暗反应的区别上存在混淆。'); expect(snapshot.snapshot.summary).toContain('摘要:'); expect(snapshot.snapshot.summary).toContain('掌握点:'); expect(snapshot.snapshot.summary).toContain('薄弱点:'); expect(snapshot.snapshot.strengths).toEqual(['基本概念掌握', '术语使用正确']); expect(snapshot.snapshot.weaknesses).toEqual(['光反应细节不准确', '暗反应遗漏关键步骤']); expect(snapshot.snapshot.cardCount).toBe(2); // Math.min(3, Math.max(1, 2)) = 2 }); it('prompt/model 值全部来自 JobDefinition(单一事实来源)', () => { const customDef = { ...REVIEW_CARD_GENERATION_JOB_DEFINITION, prompt: { promptKey: 'custom-rc-key', promptVersion: '2.0' }, model: { ...REVIEW_CARD_GENERATION_JOB_DEFINITION.model, modelTier: 'primary' as const }, }; registry.get.mockReturnValue(customDef); const snapshot = builder.build(validInput); expect(snapshot.snapshot.promptKey).toBe('custom-rc-key'); expect(snapshot.snapshot.promptVersion).toBe('2.0'); expect(snapshot.snapshot.modelTier).toBe('primary'); // 未修改的字段保持 Definition 原值 expect(snapshot.snapshot.inputSchemaVersion).toBe('review-card-generation-v1'); }); it('cardCount = 1 when no weaknesses', () => { const snapshot = builder.build({ ...validInput, weaknesses: [], }); expect(snapshot.snapshot.cardCount).toBe(1); }); it('cardCount = 1 when weaknesses undefined', () => { const snapshot = builder.build({ ...validInput, weaknesses: undefined as any, }); expect(snapshot.snapshot.cardCount).toBe(1); }); it('cardCount = 1 when single weakness', () => { const snapshot = builder.build({ ...validInput, weaknesses: ['只有一个弱点'], }); expect(snapshot.snapshot.cardCount).toBe(1); }); it('cardCount = 2 when two weaknesses', () => { const snapshot = builder.build({ ...validInput, weaknesses: ['弱点1', '弱点2'], }); expect(snapshot.snapshot.cardCount).toBe(2); }); it('cardCount capped at 3 when many weaknesses', () => { const snapshot = builder.build({ ...validInput, weaknesses: ['w1', 'w2', 'w3', 'w4', 'w5'], }); expect(snapshot.snapshot.cardCount).toBe(3); }); it('title truncated to 80 characters', () => { const longSummary = 'A'.repeat(200); const snapshot = builder.build({ ...validInput, summary: longSummary, }); expect(snapshot.snapshot.title.length).toBeLessThanOrEqual(80); expect(snapshot.snapshot.title).toBe(longSummary.slice(0, 80)); }); it('title fallback when summary is empty', () => { const snapshot = builder.build({ ...validInput, summary: '', }); expect(snapshot.snapshot.title).toBe('AI 分析结果'); }); it('Snapshot 不含敏感字段', () => { const snapshot = 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'); expect(serialized).not.toContain('connectionInfo'); expect(serialized).not.toContain('password'); expect(serialized).not.toContain('credential'); 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'); expect(snap).not.toHaveProperty('providerKey'); }); it('Snapshot 不包含 Parent Job 全量实体', () => { const snapshot = builder.build(validInput); const snap = snapshot.snapshot as Record; // Only contains parentJobId reference, not the full entity expect(snap).toHaveProperty('parentJobId'); expect(snap).not.toHaveProperty('parentJob'); expect(snap).not.toHaveProperty('parent'); }); it('createdAt 归一化到秒(截断毫秒)', () => { const snapshot = builder.build(validInput); // ISO8601 format without milliseconds: 2026-06-21T12:00:00Z expect(snapshot.snapshot.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/); expect(snapshot.snapshot.createdAt).not.toContain('.'); }); }); describe('computeHash', () => { it('相同输入 → 相同 contentHash(稳定)', () => { const s1 = builder.build(validInput); const s2 = builder.build(validInput); const h1 = builder.computeHash(s1); const h2 = builder.computeHash(s2); expect(h1).toBe(h2); expect(h1.length).toBe(16); }); it('不同输入 → 不同 contentHash', () => { const s1 = builder.build(validInput); const s2 = builder.build({ ...validInput, weaknesses: ['完全不同的弱点'], }); const h1 = builder.computeHash(s1); const h2 = builder.computeHash(s2); expect(h1).not.toBe(h2); }); it('contentHash 长度固定为 16', () => { const snapshot = builder.build(validInput); const hash = builder.computeHash(snapshot); expect(hash).toHaveLength(16); expect(hash).toMatch(/^[0-9a-f]{16}$/); }); it('contentHash 不含随机值(多次调用相同输入→相同hash)', () => { const hashes = Array.from({ length: 10 }, () => { const s = builder.build(validInput); return builder.computeHash(s); }); // All hashes must be identical const first = hashes[0]; for (const h of hashes) { expect(h).toBe(first); } }); }); });