api-server/src/modules/ai-job/ai-job-creation.service.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

611 lines
21 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 { AiJobCreationService } from './ai-job-creation.service';
import { AiJobLifecycleRepository } from './ai-job-lifecycle.repository';
import { JobDefinitionRegistry } from './job-definition-registry';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { OutboxRepository } from '../../infrastructure/outbox/outbox.repository';
import { SnapshotBuilderService } from '../ai-runtime/snapshot-builder.service';
import { ActiveRecallSnapshotBuilder } from './active-recall-snapshot-builder';
import { UnknownJobTypeError } from './job-definition-registry';
import { JobDefinition } from './job-definition.types';
/** 最小合法 Definition 工厂 */
function validDef(overrides?: Partial<JobDefinition>): JobDefinition {
return {
jobType: 'test_job',
metadata: { label: 'Test Job', description: '', domain: 'analysis', version: '1.0' },
queue: { queueName: 'ai-interactive', defaultPriority: 0 },
execution: { timeoutMs: 30000, maxRetries: 2, retryBackoff: { type: 'exponential', delay: 2000 }, cancellable: true, abortStrategy: 'fail' },
input: { schemaVersion: '1.0.0' },
output: { schemaVersion: '1.0.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 },
...overrides,
};
}
describe('AiJobCreationService', () => {
let service: AiJobCreationService;
let prisma: any;
let lifecycleRepo: any;
let registry: any;
let snapshotBuilder: any;
let outboxRepo: any;
beforeEach(async () => {
registry = { get: jest.fn(), has: jest.fn() };
lifecycleRepo = { createJob: jest.fn() };
snapshotBuilder = { buildSnapshot: jest.fn() };
outboxRepo = { createInTransaction: jest.fn() };
prisma = {
aiJob: {
findUnique: jest.fn(),
},
aiJobSnapshot: { create: jest.fn() },
$transaction: jest.fn((fn: any) => fn(prisma)), // mock tx = same prisma
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiJobCreationService,
{ provide: PrismaService, useValue: prisma },
{ provide: AiJobLifecycleRepository, useValue: lifecycleRepo },
{ provide: JobDefinitionRegistry, useValue: registry },
{ provide: SnapshotBuilderService, useValue: snapshotBuilder },
{ provide: ActiveRecallSnapshotBuilder, useValue: { buildSnapshot: jest.fn(), build: jest.fn() } },
{ provide: OutboxRepository, useValue: outboxRepo },
],
}).compile();
service = module.get(AiJobCreationService);
});
describe('事务化创建', () => {
it('在一个事务内创建 AiJob + Snapshot + Outbox', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1', profile: {} });
const mockJob = { id: 'job-001', lifecycleStatus: 'queued', jobType: 'test_job' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
const result = await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'knowledge_base',
targetId: 'kb-1',
});
// 验证事务调用
expect(prisma.$transaction).toHaveBeenCalled();
// Job created in tx
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object), // the tx client
expect.objectContaining({
userId: 'u1',
jobType: 'test_job',
queueName: 'ai-interactive',
}),
);
// Snapshot created in tx
expect(prisma.aiJobSnapshot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
jobId: 'job-001',
jobType: 'test_job',
contentHash: expect.any(String),
}),
}),
);
// Outbox created in tx
expect(outboxRepo.createInTransaction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
eventType: 'ai.job.enqueue',
aggregateType: 'AiJob',
aggregateId: 'job-001',
dedupeKey: 'ai.job.enqueue:job-001',
payload: { jobId: 'job-001' },
}),
);
expect(result).toBe(mockJob);
});
it('事务中任一失败则全部回滚', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1' });
lifecycleRepo.createJob.mockResolvedValue({ id: 'job-001' });
// Snapshot create fails
prisma.aiJobSnapshot.create.mockRejectedValue(new Error('snapshot insert failed'));
await expect(
service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'knowledge_base',
targetId: 'kb-1',
}),
).rejects.toThrow('snapshot insert failed');
// Outbox must NOT be called (tx rolled back)
expect(outboxRepo.createInTransaction).not.toHaveBeenCalled();
});
});
describe('幂等性', () => {
it('并发重复创建相同 (userId, jobType, idempotencyKey) → P2002 → 返回已有 Job', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({});
// First request creates successfully
lifecycleRepo.createJob.mockResolvedValueOnce({ id: 'job-001' });
await service.createJob({
userId: 'u1', jobType: 'test_job', triggerType: 'user_api',
targetType: 'kb', targetId: 'kb-1', idempotencyKey: 'idem-001',
});
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(1);
// Second request: transaction fails with P2002 (duplicate unique key)
const p2002Error = new Error('Unique constraint failed') as any;
p2002Error.code = 'P2002';
prisma.$transaction.mockRejectedValueOnce(p2002Error);
const existingJob = { id: 'job-001', lifecycleStatus: 'queued', jobType: 'test_job' };
prisma.aiJob.findUniqueOrThrow = jest.fn().mockResolvedValue(existingJob);
const result = await service.createJob({
userId: 'u1', jobType: 'test_job', triggerType: 'user_api',
targetType: 'kb', targetId: 'kb-1', idempotencyKey: 'idem-001',
});
expect(result).toBe(existingJob);
// Snapshot was still built (pre-transaction), but no new Job created
});
it('未提供 idempotencyKey 时每次创建新 Job无幂等保护', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({});
const mockJob = { id: 'job-002' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
await service.createJob({ userId: 'u1', jobType: 'test_job', triggerType: 'user_api', targetType: 'kb', targetId: 'kb-1' });
await service.createJob({ userId: 'u1', jobType: 'test_job', triggerType: 'user_api', targetType: 'kb', targetId: 'kb-1' });
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(2);
});
});
describe('Job 元数据从 Definition 写入', () => {
it('queueName / priority / timeout / maxAttempts / schema 等来自 Definition', async () => {
registry.get.mockReturnValue(
validDef({
queue: { queueName: 'ai-background', defaultPriority: 5 },
execution: { timeoutMs: 90000, maxRetries: 1, retryBackoff: { type: 'exponential', delay: 5000 }, cancellable: false, abortStrategy: 'retry' },
input: { schemaVersion: '2.0.0' },
output: { schemaVersion: '2.0.0' },
prompt: { promptKey: 'bg_prompt', promptVersion: '2.0' },
model: { modelTier: 'fallback', modelProvider: 'deepseek', modelName: 'deepseek-chat' },
}),
);
snapshotBuilder.buildSnapshot.mockResolvedValue({});
const mockJob = { id: 'bg-job' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
await service.createJob({
userId: 'u1',
jobType: 'bg_job',
triggerType: 'system_scheduled',
targetType: 'user',
targetId: 'u1',
});
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
queueName: 'ai-background',
priority: 5,
timeoutMs: 90000,
maxAttempts: 1,
inputSchemaVersion: '2.0.0',
outputSchemaVersion: '2.0.0',
promptVersion: '2.0',
modelProvider: 'deepseek',
modelName: 'deepseek-chat',
}),
);
});
it('overrides 覆盖 Definition 默认值', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({});
const mockJob = { id: 'override-job' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
overrides: {
priority: 99,
credentialMode: 'user_deepseek_key',
credentialId: 'cred-xyz',
},
});
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
priority: 99,
credentialMode: 'user_deepseek_key',
credentialId: 'cred-xyz',
}),
);
});
});
describe('输入校验', () => {
it('未知 jobType → UnknownJobTypeError', async () => {
registry.get.mockImplementation(() => {
throw new UnknownJobTypeError('bad_job');
});
await expect(
service.createJob({
userId: 'u1',
jobType: 'bad_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
}),
).rejects.toThrow(UnknownJobTypeError);
});
it('非法 triggerType → BadRequestException', async () => {
registry.get.mockReturnValue(validDef());
await expect(
service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'evil_hack' as any,
targetType: 'kb',
targetId: 'kb-1',
}),
).rejects.toThrow(BadRequestException);
});
});
describe('Outbox payload 约束', () => {
it('Outbox payload 仅含 {jobId}', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1' });
lifecycleRepo.createJob.mockResolvedValue({ id: 'job-minimal' });
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
});
expect(outboxRepo.createInTransaction).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
payload: { jobId: 'job-minimal' },
}),
);
// payload 不应包含 userId、credentialId 等敏感字段
const call = outboxRepo.createInTransaction.mock.calls[0][1];
expect(call.payload).not.toHaveProperty('userId');
expect(call.payload).not.toHaveProperty('credentialId');
expect(call.payload).not.toHaveProperty('snapshot');
});
});
describe('Snapshot', () => {
it('Snapshot 传入 buildSnapshot 产出的 content 和 contentHash', async () => {
const snapContent = { userId: 'u1', profile: { level: 5 }, settings: {} };
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue(snapContent);
lifecycleRepo.createJob.mockResolvedValue({ id: 'snap-job' });
await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
});
expect(prisma.aiJobSnapshot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
jobId: 'snap-job',
content: snapContent,
contentHash: expect.any(String),
schemaVersion: '1.0.0',
redactionVersion: '1.0',
}),
}),
);
});
});
describe('createInTransaction', () => {
beforeEach(() => {
// tx mock: in test, tx === same prisma object (simplified interactive tx)
prisma.aiJob.findUnique = jest.fn();
prisma.aiJobSnapshot.create = jest.fn();
});
it('在外部事务中创建 Child Job + Snapshot + Outbox', async () => {
registry.get.mockReturnValue(validDef({
jobType: 'review_card_generation',
queue: { queueName: 'ai-background', defaultPriority: 0 },
prompt: { promptKey: 'review-card-generation', promptVersion: '1.0.0' },
}));
const mockChildJob = {
id: 'child-job-001',
lifecycleStatus: 'queued',
jobType: 'review_card_generation',
};
lifecycleRepo.createJob.mockResolvedValue(mockChildJob);
// No existing job (idempotency check passes)
prisma.aiJob.findUnique.mockResolvedValue(null);
const snapshotContent = {
userId: 'u1',
parentJobId: 'parent-job-001',
strengths: ['a'],
weaknesses: ['b', 'c'],
cardCount: 2,
};
const result = await service.createInTransaction(
prisma as any,
{
userId: 'u1',
jobType: 'review_card_generation',
triggerType: 'parent_job',
targetType: 'ai_analysis_result',
targetId: 'fe_parent-job-001',
parentJobId: 'parent-job-001',
idempotencyKey: 'review-card:feynman:parent-job-001',
retrySnapshotContent: snapshotContent,
},
);
// Returns the created job
expect(result).toBe(mockChildJob);
// Did NOT open its own transaction
expect(prisma.$transaction).not.toHaveBeenCalled();
// Created job via lifecycleRepo with passed tx
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
userId: 'u1',
jobType: 'review_card_generation',
parentJobId: 'parent-job-001',
idempotencyKey: 'review-card:feynman:parent-job-001',
triggerType: 'parent_job',
queueName: 'ai-background',
}),
);
// Created snapshot with passed tx
expect(prisma.aiJobSnapshot.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
jobId: 'child-job-001',
jobType: 'review_card_generation',
content: snapshotContent,
}),
}),
);
// Created outbox with passed tx
expect(outboxRepo.createInTransaction).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
eventType: 'ai.job.enqueue',
aggregateType: 'AiJob',
aggregateId: 'child-job-001',
dedupeKey: 'ai.job.enqueue:child-job-001',
payload: { jobId: 'child-job-001' },
}),
);
});
it('rejects if retrySnapshotContent is missing', async () => {
registry.get.mockReturnValue(validDef());
await expect(
service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
} as any),
).rejects.toThrow(BadRequestException);
});
it('幂等:相同 idempotencyKey 返回已有 Jobcheck-before-create', async () => {
registry.get.mockReturnValue(validDef({
jobType: 'review_card_generation',
queue: { queueName: 'ai-background', defaultPriority: 0 },
}));
// Existing job found via check-before-create
const existingJob = {
id: 'existing-child-001',
lifecycleStatus: 'queued',
jobType: 'review_card_generation',
};
prisma.aiJob.findUnique.mockResolvedValue(existingJob);
const result = await service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'review_card_generation',
triggerType: 'parent_job',
targetType: 'ai_analysis_result',
targetId: 'fe_parent-001',
parentJobId: 'parent-001',
idempotencyKey: 'review-card:feynman:parent-001',
retrySnapshotContent: { cardCount: 1 },
});
// Returns existing job without creating a new one
expect(result).toBe(existingJob);
expect(lifecycleRepo.createJob).not.toHaveBeenCalled();
expect(prisma.aiJobSnapshot.create).not.toHaveBeenCalled();
expect(outboxRepo.createInTransaction).not.toHaveBeenCalled();
});
it('未提供 idempotencyKey 时跳过幂等检查,正常创建', async () => {
registry.get.mockReturnValue(validDef());
const mockJob = { id: 'no-idem-job', lifecycleStatus: 'queued' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
const result = await service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
retrySnapshotContent: { key: 'val' },
});
expect(result).toBe(mockJob);
// findUnique NOT called for idempotency check
expect(prisma.aiJob.findUnique).not.toHaveBeenCalled();
expect(lifecycleRepo.createJob).toHaveBeenCalledTimes(1);
});
it('parentJobId 正确传递给 lifecycleRepo', async () => {
registry.get.mockReturnValue(validDef());
lifecycleRepo.createJob.mockResolvedValue({ id: 'child-002' });
await service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'test_job',
triggerType: 'parent_job',
targetType: 'ai_analysis_result',
targetId: 'fe_parent-xyz',
parentJobId: 'parent-xyz',
idempotencyKey: 'child-idem-002',
retrySnapshotContent: {},
});
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
prisma,
expect.objectContaining({
parentJobId: 'parent-xyz',
idempotencyKey: 'child-idem-002',
triggerType: 'parent_job',
}),
);
});
it('Outbox payload 仅含 {jobId}(不泄露敏感信息)', async () => {
registry.get.mockReturnValue(validDef());
lifecycleRepo.createJob.mockResolvedValue({ id: 'secure-child' });
await service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'test_job',
triggerType: 'parent_job',
targetType: 'kb',
targetId: 'kb-1',
parentJobId: 'parent-secure',
retrySnapshotContent: { userId: 'u1', secret: 'SHOULD_NOT_LEAK' },
});
const call = outboxRepo.createInTransaction.mock.calls[0][1];
expect(call.payload).toEqual({ jobId: 'secure-child' });
expect(call.payload).not.toHaveProperty('userId');
expect(call.payload).not.toHaveProperty('secret');
});
it('Snapshot 创建失败 → 错误在外部事务中传播', async () => {
registry.get.mockReturnValue(validDef());
lifecycleRepo.createJob.mockResolvedValue({ id: 'will-fail' });
prisma.aiJobSnapshot.create.mockRejectedValue(
new Error('snapshot insert failed'),
);
await expect(
service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
retrySnapshotContent: {},
}),
).rejects.toThrow('snapshot insert failed');
// Outbox NOT created after failure
expect(outboxRepo.createInTransaction).not.toHaveBeenCalled();
});
it('Outbox 创建失败 → 错误在外部事务中传播', async () => {
registry.get.mockReturnValue(validDef());
lifecycleRepo.createJob.mockResolvedValue({ id: 'outbox-fail' });
outboxRepo.createInTransaction.mockRejectedValue(
new Error('outbox insert failed'),
);
await expect(
service.createInTransaction(prisma as any, {
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
retrySnapshotContent: {},
}),
).rejects.toThrow('outbox insert failed');
});
it('不影响现有 createJob 行为(回归)', async () => {
registry.get.mockReturnValue(validDef());
snapshotBuilder.buildSnapshot.mockResolvedValue({ userId: 'u1' });
const mockJob = { id: 'regression-job', lifecycleStatus: 'queued' };
lifecycleRepo.createJob.mockResolvedValue(mockJob);
const result = await service.createJob({
userId: 'u1',
jobType: 'test_job',
triggerType: 'user_api',
targetType: 'kb',
targetId: 'kb-1',
idempotencyKey: 'regression-key',
});
expect(result).toBe(mockJob);
// createJob still uses its own transaction
expect(prisma.$transaction).toHaveBeenCalled();
expect(lifecycleRepo.createJob).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ idempotencyKey: 'regression-key' }),
);
});
});
});