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>
273 lines
9.8 KiB
TypeScript
273 lines
9.8 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { BadRequestException } from '@nestjs/common';
|
||
import { FeynmanExecutionRouter } from './feynman-execution-router';
|
||
import { FeatureFlagService } from '../config/feature-flag.service';
|
||
import { FeynmanSnapshotBuilder } from '../ai-job/feynman-snapshot-builder';
|
||
import { AiJobCreationService } from '../ai-job/ai-job-creation.service';
|
||
import { JobDefinitionRegistry } from '../ai-job/job-definition-registry';
|
||
import { AiAnalysisService } from './ai-analysis.service';
|
||
import { FEYNMAN_JOB_DEFINITION } from '../ai-job/feynman-job-definition';
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// FeynmanExecutionRouter
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
describe('FeynmanExecutionRouter', () => {
|
||
let router: FeynmanExecutionRouter;
|
||
let featureFlag: any;
|
||
let snapshotBuilder: any;
|
||
let creationService: any;
|
||
let registry: any;
|
||
let legacyService: any;
|
||
|
||
const validInput = {
|
||
knowledgeItemTitle: '光合作用',
|
||
knowledgeItemContent: '光合作用是植物利用光能的过程。',
|
||
userExplanation: '光合作用就像植物做饭。',
|
||
sessionId: 'session-001',
|
||
answerId: 'answer-001',
|
||
};
|
||
|
||
const mockSnapshot = {
|
||
schemaVersion: 'feynman-evaluation-v1',
|
||
snapshot: {
|
||
userId: 'u-001',
|
||
knowledgeItemId: 'ki-001',
|
||
submissionId: 'session-001:answer-001',
|
||
},
|
||
};
|
||
|
||
const mockCreateResult = {
|
||
job: { id: 'job-new-001' },
|
||
snapshot: { id: 'snap-001' },
|
||
outboxEvent: { id: 'outbox-001' },
|
||
isDuplicate: false,
|
||
};
|
||
|
||
beforeEach(async () => {
|
||
featureFlag = {
|
||
isEnabled: jest.fn().mockResolvedValue(false), // default: legacy
|
||
};
|
||
|
||
snapshotBuilder = {
|
||
build: jest.fn().mockResolvedValue(mockSnapshot),
|
||
computeHash: jest.fn().mockReturnValue('abc123def4567890'),
|
||
};
|
||
|
||
creationService = {
|
||
createJob: jest.fn().mockResolvedValue(mockCreateResult),
|
||
};
|
||
|
||
registry = {
|
||
get: jest.fn().mockReturnValue(FEYNMAN_JOB_DEFINITION),
|
||
};
|
||
|
||
legacyService = {
|
||
evaluateFeynman: jest.fn().mockResolvedValue({
|
||
jobId: 'job-legacy-001',
|
||
status: 'queued',
|
||
}),
|
||
};
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
FeynmanExecutionRouter,
|
||
{ provide: FeatureFlagService, useValue: featureFlag },
|
||
{ provide: FeynmanSnapshotBuilder, useValue: snapshotBuilder },
|
||
{ provide: AiJobCreationService, useValue: creationService },
|
||
{ provide: JobDefinitionRegistry, useValue: registry },
|
||
{ provide: AiAnalysisService, useValue: legacyService },
|
||
],
|
||
}).compile();
|
||
|
||
router = module.get(FeynmanExecutionRouter);
|
||
});
|
||
|
||
describe('参数校验', () => {
|
||
it('knowledgeItemTitle 为空 → BadRequestException', async () => {
|
||
await expect(
|
||
router.evaluateFeynman('u-001', { ...validInput, knowledgeItemTitle: '' }),
|
||
).rejects.toThrow(BadRequestException);
|
||
});
|
||
|
||
it('knowledgeItemContent 为空 → BadRequestException', async () => {
|
||
await expect(
|
||
router.evaluateFeynman('u-001', { ...validInput, knowledgeItemContent: ' ' }),
|
||
).rejects.toThrow(BadRequestException);
|
||
});
|
||
|
||
it('userExplanation 为空 → BadRequestException', async () => {
|
||
await expect(
|
||
router.evaluateFeynman('u-001', { ...validInput, userExplanation: '' }),
|
||
).rejects.toThrow(BadRequestException);
|
||
});
|
||
});
|
||
|
||
describe('Legacy 路径', () => {
|
||
it('Feature Flag 为 disabled → 走 Legacy', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(false);
|
||
|
||
const result = await router.evaluateFeynman('u-001', validInput);
|
||
|
||
expect(legacyService.evaluateFeynman).toHaveBeenCalledWith('u-001', validInput);
|
||
expect(result).toEqual({ jobId: 'job-legacy-001', status: 'queued' });
|
||
// Unified 路径不应被调用
|
||
expect(snapshotBuilder.build).not.toHaveBeenCalled();
|
||
expect(creationService.createJob).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('FeatureFlag 查询失败 → 安全回退到 Legacy', async () => {
|
||
featureFlag.isEnabled.mockRejectedValue(new Error('Redis connection error'));
|
||
|
||
const result = await router.evaluateFeynman('u-001', validInput);
|
||
|
||
expect(legacyService.evaluateFeynman).toHaveBeenCalled();
|
||
expect(result).toEqual({ jobId: 'job-legacy-001', status: 'queued' });
|
||
});
|
||
});
|
||
|
||
describe('Unified 路径', () => {
|
||
it('Feature Flag 为 enabled → 走 Unified', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
const result = await router.evaluateFeynman('u-001', validInput, 'ki-001');
|
||
|
||
// Legacy 不应被调用
|
||
expect(legacyService.evaluateFeynman).not.toHaveBeenCalled();
|
||
|
||
// Unified 路径
|
||
expect(snapshotBuilder.build).toHaveBeenCalledTimes(1);
|
||
const snapshotCall = snapshotBuilder.build.mock.calls[0][0];
|
||
expect(snapshotCall.userId).toBe('u-001');
|
||
expect(snapshotCall.knowledgeItemId).toBe('ki-001');
|
||
expect(snapshotCall.knowledgeItemTitle).toBe('光合作用');
|
||
expect(snapshotCall.userExplanation).toBe('光合作用就像植物做饭。');
|
||
expect(snapshotCall.submissionId).toBe('session-001:answer-001');
|
||
|
||
// AiJobCreationService 调用
|
||
expect(creationService.createJob).toHaveBeenCalledTimes(1);
|
||
const createCall = creationService.createJob.mock.calls[0][0];
|
||
expect(createCall.jobType).toBe('feynman_evaluation');
|
||
expect(createCall.triggerType).toBe('user_api');
|
||
expect(createCall.targetType).toBe('knowledge_item');
|
||
expect(createCall.idempotencyKey).toBe('feynman:session-001:answer-001');
|
||
expect(createCall.retrySnapshotContent).toBeDefined();
|
||
|
||
// 响应兼容
|
||
expect(result).toHaveProperty('jobId');
|
||
expect(result).toHaveProperty('status', 'queued');
|
||
expect(result).toHaveProperty('engineMode', 'unified');
|
||
expect(result).toHaveProperty('lifecycleStatus', 'queued');
|
||
});
|
||
|
||
it('knowledgeItemId 未传入时使用 unknown 占位', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
await router.evaluateFeynman('u-001', validInput);
|
||
|
||
const snapshotCall = snapshotBuilder.build.mock.calls[0][0];
|
||
expect(snapshotCall.knowledgeItemId).toBe('unknown');
|
||
});
|
||
|
||
it('Unified 失败不得自动调用 Legacy', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
snapshotBuilder.build.mockRejectedValue(new Error('KnowledgeItem not found'));
|
||
|
||
await expect(
|
||
router.evaluateFeynman('u-001', validInput, 'ki-001'),
|
||
).rejects.toThrow('KnowledgeItem not found');
|
||
|
||
// Legacy 不应被调用
|
||
expect(legacyService.evaluateFeynman).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('幂等键', () => {
|
||
it('sessionId + answerId 都存在 → feynman:<sessionId>:<answerId>', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
await router.evaluateFeynman('u-001', {
|
||
...validInput,
|
||
sessionId: 'sess-123',
|
||
answerId: 'ans-456',
|
||
}, 'ki-001');
|
||
|
||
const createCall = creationService.createJob.mock.calls[0][0];
|
||
expect(createCall.idempotencyKey).toBe('feynman:sess-123:ans-456');
|
||
});
|
||
|
||
it('仅 sessionId → feynman:<sessionId>', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
await router.evaluateFeynman('u-001', {
|
||
...validInput,
|
||
sessionId: 'sess-only',
|
||
answerId: undefined,
|
||
}, 'ki-001');
|
||
|
||
const createCall = creationService.createJob.mock.calls[0][0];
|
||
expect(createCall.idempotencyKey).toBe('feynman:sess-only');
|
||
});
|
||
|
||
it('无 sessionId/answerId → 基于内容的 hash 回退', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
await router.evaluateFeynman('u-001', {
|
||
...validInput,
|
||
sessionId: undefined,
|
||
answerId: undefined,
|
||
}, 'ki-001');
|
||
|
||
const createCall = creationService.createJob.mock.calls[0][0];
|
||
expect(createCall.idempotencyKey).toMatch(/^feynman:[0-9a-f]{16}$/);
|
||
});
|
||
|
||
it('相同内容产生相同 idempotencyKey', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
await router.evaluateFeynman('u-001', {
|
||
...validInput,
|
||
sessionId: undefined,
|
||
answerId: undefined,
|
||
}, 'ki-001');
|
||
|
||
const key1 = creationService.createJob.mock.calls[0][0].idempotencyKey;
|
||
|
||
// 第二次调用
|
||
await router.evaluateFeynman('u-002', {
|
||
...validInput,
|
||
sessionId: undefined,
|
||
answerId: undefined,
|
||
}, 'ki-002');
|
||
|
||
const key2 = creationService.createJob.mock.calls[1][0].idempotencyKey;
|
||
|
||
// 相同内容 → 相同 key(内容 hash)
|
||
expect(key1).toBe(key2);
|
||
});
|
||
});
|
||
|
||
describe('响应兼容', () => {
|
||
it('Legacy 响应保持旧格式', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(false);
|
||
|
||
const result = await router.evaluateFeynman('u-001', validInput);
|
||
|
||
expect(result).toEqual({ jobId: 'job-legacy-001', status: 'queued' });
|
||
expect(result).not.toHaveProperty('engineMode');
|
||
expect(result).not.toHaveProperty('lifecycleStatus');
|
||
});
|
||
|
||
it('Unified 响应包含 engineMode 和 lifecycleStatus', async () => {
|
||
featureFlag.isEnabled.mockResolvedValue(true);
|
||
|
||
const result = await router.evaluateFeynman('u-001', validInput, 'ki-001');
|
||
|
||
expect(result).toHaveProperty('jobId');
|
||
expect(result).toHaveProperty('status', 'queued');
|
||
expect((result as any).engineMode).toBe('unified');
|
||
expect((result as any).lifecycleStatus).toBe('queued');
|
||
});
|
||
});
|
||
});
|