api-server/src/modules/ai-job/ai-job-execution-engine.spec.ts
wangdl f1e529b99e
Some checks failed
Deploy API Server / build-and-unit (push) Failing after 26s
Deploy API Server / current-integration (push) Has been skipped
Deploy API Server / backward-compat (push) Has been skipped
Deploy API Server / deploy (push) Has been skipped
feat: M-AI-06 Feynman ReviewCard Child Job 与可靠生成
完成 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>
2026-06-22 19:27:14 +08:00

296 lines
12 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 { 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,
}),
}),
);
});
});
});