完成 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>
611 lines
21 KiB
TypeScript
611 lines
21 KiB
TypeScript
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 返回已有 Job(check-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' }),
|
||
);
|
||
});
|
||
});
|
||
});
|