api-server/src/modules/ai-job/review-card-generation-registration.service.spec.ts
wangdl f1e529b99e
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 26s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
feat: M-AI-06 Feynman ReviewCard Child Job 与可靠生成
完成 M-AI-06 全部 7 个 Issue:
- 01: 审计冻结契约 (docs/architecture/m-ai-06-review-card-child-job-contract.md)
- 02: AiJobCreationService.createInTransaction(tx, input)
- 03: ReviewCard Generation Job Definition + Snapshot Builder
- 04: ReviewCard Generation Executor + 输出验证 Validator
- 05: ReviewCard Generation Projector + 卡片幂等 + Engine 接入
- 06: FeynmanProjector 接入 FEYNMAN_REVIEW_CARD_MODE Feature Flag
- 07: E2E 测试 (test/m-ai-06-review-card-child.e2e-spec.ts)

核心变更:
- 新增 10 个文件, 修改 7 个文件
- 新增 createInTransaction() 外部事务支持
- 新增 review_card_generation Job Type (ai-background / cheap tier)
- FeynmanProjector 根据 Feature Flag 路由 child_job / legacy_event
- Projector 入口幂等 + P2002 回退双重保障
- 测试: 单元 482 passed, E2E 待 CI 执行

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-22 19:27:14 +08:00

339 lines
13 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 { 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<string, unknown>;
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<string, unknown>;
// 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);
}
});
});
});