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