完成 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>
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
import { Test, TestingModule } from '@nestjs/testing';
|
||
import { AiJobExecutionEngineImpl } from './ai-job-execution-engine';
|
||
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
|
||
import { JobDefinitionRegistry } from './job-definition-registry';
|
||
import { AiJobStateMachine } from './ai-job-state-machine';
|
||
import { AiGatewayService } from '../ai/gateway/ai-gateway.service';
|
||
import { ProjectionExecutor } from './projection-executor.service';
|
||
import { ActiveRecallExecutor } from './active-recall-executor';
|
||
import { ActiveRecallObservabilityService } from './active-recall-observability.service';
|
||
import { FeynmanExecutor } from './feynman-executor';
|
||
import { FeynmanBusinessValidator, FeynmanReferenceValidator } from './feynman-validator';
|
||
import { FeynmanObservabilityService } from './feynman-observability.service';
|
||
import { ReviewCardGenerationExecutor } from './review-card-generation-executor';
|
||
import { ReviewCardGenerationValidator } from './review-card-generation-validator';
|
||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||
import { JobLockConflictError, JobAlreadyTerminalError } from './ai-job.errors';
|
||
|
||
function validDef() {
|
||
return {
|
||
jobType: 'test_job',
|
||
queue: { queueName: 'ai-interactive', defaultPriority: 0 },
|
||
execution: { timeoutMs: 5000, maxRetries: 2, retryBackoff: { type: 'exponential', delay: 2000 }, cancellable: true, abortStrategy: 'fail' },
|
||
input: { schemaVersion: '1.0' },
|
||
output: { schemaVersion: '1.0' },
|
||
prompt: { promptKey: 'test', promptVersion: '1.0' },
|
||
model: { modelTier: 'primary', modelProvider: 'deepseek', modelName: 'deepseek-chat' },
|
||
credential: { allowedModes: ['platform_key'], defaultMode: 'platform_key' },
|
||
security: { contentSafetyCheck: false, outputRedaction: false },
|
||
metadata: { label: 'Test', description: '', domain: 'analysis', version: '1.0' },
|
||
};
|
||
}
|
||
|
||
describe('AiJobExecutionEngineImpl', () => {
|
||
let engine: AiJobExecutionEngineImpl;
|
||
let prisma: any;
|
||
let lifecycleRepo: any;
|
||
let registry: any;
|
||
let stateMachine: any;
|
||
let aiGateway: any;
|
||
let projectionExecutor: any;
|
||
|
||
function makeJob(overrides?: any) {
|
||
return {
|
||
id: 'job-001',
|
||
userId: 'user-1',
|
||
jobType: 'test_job',
|
||
lifecycleStatus: 'queued',
|
||
queueName: 'ai-interactive',
|
||
attemptCount: 0,
|
||
cancelRequestedAt: null,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
lifecycleRepo = {
|
||
lockJob: jest.fn(),
|
||
markSucceeded: jest.fn(),
|
||
markFailed: jest.fn(),
|
||
markCancelled: jest.fn(),
|
||
};
|
||
registry = { get: jest.fn().mockReturnValue(validDef()) };
|
||
stateMachine = new AiJobStateMachine();
|
||
aiGateway = { generate: jest.fn() };
|
||
projectionExecutor = { execute: jest.fn().mockResolvedValue([]) };
|
||
|
||
prisma = {
|
||
aiJob: {
|
||
findUnique: jest.fn(),
|
||
update: jest.fn(),
|
||
},
|
||
aiJobSnapshot: { findUnique: jest.fn() },
|
||
aiUsageLog: { create: jest.fn() },
|
||
};
|
||
|
||
jest.spyOn(require('@nestjs/common').Logger.prototype, 'log').mockImplementation(() => {});
|
||
jest.spyOn(require('@nestjs/common').Logger.prototype, 'warn').mockImplementation(() => {});
|
||
jest.spyOn(require('@nestjs/common').Logger.prototype, 'error').mockImplementation(() => {});
|
||
|
||
const module: TestingModule = await Test.createTestingModule({
|
||
providers: [
|
||
AiJobExecutionEngineImpl,
|
||
{ provide: PrismaService, useValue: prisma },
|
||
{ provide: AiJobLifecycleRepository, useValue: lifecycleRepo },
|
||
{ provide: JobDefinitionRegistry, useValue: registry },
|
||
{ provide: AiJobStateMachine, useValue: stateMachine },
|
||
{ provide: AiGatewayService, useValue: aiGateway },
|
||
{ provide: ProjectionExecutor, useValue: projectionExecutor },
|
||
{ provide: ActiveRecallExecutor, useValue: { execute: jest.fn() } },
|
||
{ provide: FeynmanExecutor, useValue: { execute: jest.fn() } },
|
||
{ provide: FeynmanBusinessValidator, useValue: { validate: jest.fn() } },
|
||
{ provide: FeynmanReferenceValidator, useValue: { validate: jest.fn() } },
|
||
{ provide: ReviewCardGenerationExecutor, useValue: { execute: jest.fn() } },
|
||
{ provide: ReviewCardGenerationValidator, useValue: { validate: jest.fn() } },
|
||
{ provide: ActiveRecallObservabilityService, useValue: {
|
||
incrementUnifiedExecuteSuccess: jest.fn(),
|
||
incrementUnifiedExecuteFailed: jest.fn(),
|
||
incrementUnifiedRetry: jest.fn(),
|
||
incrementProjectorFailed: jest.fn(),
|
||
logExecutionCompleted: jest.fn(),
|
||
logExecutionFailed: jest.fn(),
|
||
logRollback: jest.fn(),
|
||
} },
|
||
{ provide: FeynmanObservabilityService, useValue: {
|
||
incrementUnifiedExecuteSuccess: jest.fn(),
|
||
incrementUnifiedExecuteFailed: jest.fn(),
|
||
incrementUnifiedRetry: jest.fn(),
|
||
incrementProjectorFailed: jest.fn(),
|
||
addFocusItemCreated: jest.fn(),
|
||
addReviewCardCreated: jest.fn(),
|
||
logExecutionCompleted: jest.fn(),
|
||
logExecutionFailed: jest.fn(),
|
||
logRollback: jest.fn(),
|
||
} },
|
||
],
|
||
}).compile();
|
||
|
||
engine = module.get(AiJobExecutionEngineImpl);
|
||
});
|
||
|
||
const ctx = {
|
||
jobId: 'bull-001',
|
||
attemptMade: 1,
|
||
updateProgress: jest.fn(),
|
||
};
|
||
|
||
describe('正常执行全链路', () => {
|
||
it('PREPARE→RESOLVE→EXECUTE→PROJECT→COMPLETE 全部成功', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running' }));
|
||
prisma.aiJobSnapshot.findUnique.mockResolvedValue({
|
||
schemaVersion: '1.0',
|
||
content: { snapshotData: true },
|
||
});
|
||
aiGateway.generate.mockResolvedValue({
|
||
parsed: { result: 'ok' },
|
||
usage: { provider: 'deepseek', model: 'deepseek-chat', inputTokens: 100, outputTokens: 50, estimatedCost: 0.001, latencyMs: 800 },
|
||
});
|
||
|
||
await engine.execute('job-001', ctx);
|
||
|
||
// PREPARE: lockJob called
|
||
expect(lifecycleRepo.lockJob).toHaveBeenCalledWith('job-001', 'bull-001');
|
||
// EXECUTE: AiGateway called
|
||
expect(aiGateway.generate).toHaveBeenCalled();
|
||
// PROJECT: validatedOutput + outputHash written
|
||
expect(prisma.aiJob.update).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
where: { id: 'job-001' },
|
||
data: expect.objectContaining({
|
||
validatedOutput: { result: 'ok' },
|
||
outputHash: expect.any(String),
|
||
}),
|
||
}),
|
||
);
|
||
// PROJECT: ProjectionExecutor called
|
||
expect(projectionExecutor.execute).toHaveBeenCalledWith(
|
||
undefined, // no projectorKey in validDef
|
||
expect.objectContaining({
|
||
job: expect.objectContaining({ id: 'job-001' }),
|
||
validatedOutput: { result: 'ok' },
|
||
}),
|
||
);
|
||
// COMPLETE: usage log
|
||
expect(prisma.aiUsageLog.create).toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('终态跳过', () => {
|
||
it('Job 已 succeeded → 跳过执行', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob({ lifecycleStatus: 'succeeded' }));
|
||
|
||
await engine.execute('job-001', ctx);
|
||
expect(lifecycleRepo.lockJob).not.toHaveBeenCalled();
|
||
expect(aiGateway.generate).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('取消检查', () => {
|
||
it('queued + cancelRequestedAt → 直接标记 cancelled', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob({ cancelRequestedAt: new Date() }));
|
||
|
||
await engine.execute('job-001', ctx);
|
||
expect(lifecycleRepo.markCancelled).toHaveBeenCalledWith('job-001');
|
||
});
|
||
|
||
it('执行中被取消 → cancelled,不创建 Artifact', async () => {
|
||
prisma.aiJob.findUnique
|
||
.mockResolvedValueOnce(makeJob()) // initial read
|
||
.mockResolvedValueOnce(makeJob({ cancelRequestedAt: new Date() })); // after lockJob, check
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running' }));
|
||
|
||
await engine.execute('job-001', ctx);
|
||
expect(lifecycleRepo.markCancelled).toHaveBeenCalled();
|
||
// Provider 不应被调用
|
||
expect(aiGateway.generate).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('错误分类', () => {
|
||
it('429 → retryable → 抛给 BullMQ', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running' }));
|
||
prisma.aiJobSnapshot.findUnique.mockResolvedValue({ schemaVersion: '1.0', content: {} });
|
||
|
||
aiGateway.generate.mockRejectedValue(new Error('429 rate limited'));
|
||
|
||
await expect(engine.execute('job-001', ctx)).rejects.toThrow('429');
|
||
// 不调用 markFailed(让 BullMQ 重试)
|
||
expect(lifecycleRepo.markFailed).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('schema validation → permanent → markFailed(不抛错,BullMQ 不再重试)', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running', attemptCount: 1 }));
|
||
prisma.aiJobSnapshot.findUnique.mockResolvedValue({ schemaVersion: '1.0', content: {} });
|
||
|
||
aiGateway.generate.mockRejectedValue(new Error('schema validation failed'));
|
||
|
||
// 永久错误:不应抛出(避免 BullMQ retry)
|
||
await engine.execute('job-001', ctx);
|
||
expect(lifecycleRepo.markFailed).toHaveBeenCalledWith(
|
||
'job-001',
|
||
expect.objectContaining({ errorCode: 'schema_validation_failed' }),
|
||
);
|
||
});
|
||
|
||
it('timeout → retryable → 抛给 BullMQ', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running' }));
|
||
prisma.aiJobSnapshot.findUnique.mockResolvedValue({ schemaVersion: '1.0', content: {} });
|
||
|
||
aiGateway.generate.mockRejectedValue(new Error('timeout ETIMEDOUT'));
|
||
|
||
await expect(engine.execute('job-001', ctx)).rejects.toThrow();
|
||
expect(lifecycleRepo.markFailed).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('抢锁冲突', () => {
|
||
it('已被其他 Worker 抢走 → 静默退出', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockRejectedValue(new JobLockConflictError('job-001'));
|
||
|
||
await engine.execute('job-001', ctx);
|
||
// 不应抛错,静默退出
|
||
expect(aiGateway.generate).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('Snapshot schema 不兼容', () => {
|
||
it('schemaVersion 不匹配 → markFailed', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running' }));
|
||
prisma.aiJobSnapshot.findUnique.mockResolvedValue({
|
||
schemaVersion: '99.0', // incompatible
|
||
content: {},
|
||
});
|
||
|
||
await engine.execute('job-001', ctx);
|
||
expect(lifecycleRepo.markFailed).toHaveBeenCalledWith(
|
||
'job-001',
|
||
expect.objectContaining({ errorCode: 'schema_validation_failed' }),
|
||
);
|
||
expect(aiGateway.generate).not.toHaveBeenCalled();
|
||
});
|
||
});
|
||
|
||
describe('Usage Logging', () => {
|
||
it('每次 Provider 调用写 AiUsageLog', async () => {
|
||
prisma.aiJob.findUnique.mockResolvedValue(makeJob());
|
||
lifecycleRepo.lockJob.mockResolvedValue(makeJob({ lifecycleStatus: 'running', attemptCount: 0 }));
|
||
prisma.aiJobSnapshot.findUnique.mockResolvedValue({ schemaVersion: '1.0', content: {} });
|
||
aiGateway.generate.mockResolvedValue({
|
||
parsed: { ok: true },
|
||
usage: { provider: 'deepseek', model: 'deepseek-chat', inputTokens: 50, outputTokens: 20, estimatedCost: 0.0005, latencyMs: 300 },
|
||
});
|
||
|
||
await engine.execute('job-001', ctx);
|
||
|
||
expect(prisma.aiUsageLog.create).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
userId: 'user-1',
|
||
jobId: 'job-001',
|
||
provider: 'deepseek',
|
||
model: 'deepseek-chat',
|
||
inputTokens: 50,
|
||
outputTokens: 20,
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
});
|