api-server/src/modules/ai-analysis/feynman-execution-router.spec.ts
wangdl 8987598eb8 feat(M-AI-05): track Feynman unified engine migration implementation
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>
2026-06-21 17:44:58 +08:00

273 lines
9.8 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 { 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');
});
});
});