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::', 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:', 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'); }); }); });