完成 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>
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|
||
});
|