diff --git a/src/modules/ai-job/ai-job-lifecycle.repository.spec.ts b/src/modules/ai-job/ai-job-lifecycle.repository.spec.ts index f275d07..f8851d6 100644 --- a/src/modules/ai-job/ai-job-lifecycle.repository.spec.ts +++ b/src/modules/ai-job/ai-job-lifecycle.repository.spec.ts @@ -240,6 +240,16 @@ describe('AiJobLifecycleRepository', () => { await expect(repo.extendLock('job-001', 'worker-1')).rejects.toThrow(JobExtendLockError); }); + + it('续约失败(Job 不在 running 状态)抛出 JobNotActiveError', async () => { + (prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + (prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({ + lifecycleStatus: 'succeeded', // terminal, not running + lockedBy: null, + }); + + await expect(repo.extendLock('job-001', 'worker-1')).rejects.toThrow(JobNotActiveError); + }); }); describe('markSucceeded', () => { @@ -285,6 +295,15 @@ describe('AiJobLifecycleRepository', () => { expect(tx.aiJob.updateMany).toHaveBeenCalled(); // 不应该调用 this.prisma }); + + it('CAS 失败(非终态,如 queued)抛出 JobNotActiveError', async () => { + (prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + (prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({ + lifecycleStatus: 'queued', // not running, not terminal + }); + + await expect(repo.markSucceeded('job-001')).rejects.toThrow(JobNotActiveError); + }); }); describe('markFailed', () => { @@ -316,6 +335,29 @@ describe('AiJobLifecycleRepository', () => { ); expect(result.lifecycleStatus).toBe('failed'); }); + + it('CAS 失败(已终态)抛出 JobAlreadyTerminalError', async () => { + (prisma.aiJob.updateMany as jest.Mock).mockResolvedValue({ count: 0 }); + (prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({ + lifecycleStatus: 'succeeded', + }); + + await expect( + repo.markFailed('job-001', { errorCode: 'TEST' }), + ).rejects.toThrow(JobAlreadyTerminalError); + }); + + it('接受可选 tx 参数,复用调用方事务', async () => { + tx.aiJob.updateMany.mockResolvedValue({ count: 1 }); + tx.aiJob.findUniqueOrThrow.mockResolvedValue({ + id: 'job-001', + lifecycleStatus: 'failed', + errorCode: 'PROVIDER_TIMEOUT', + }); + + await repo.markFailed('job-001', { errorCode: 'PROVIDER_TIMEOUT' }, tx); + expect(tx.aiJob.updateMany).toHaveBeenCalled(); + }); }); describe('markCancelled', () => { @@ -405,6 +447,12 @@ describe('AiJobLifecycleRepository', () => { await expect(repo.requestCancellation('job-001')).rejects.toThrow(JobNotCancellableError); }); + + it('终态 Job 不可取消 → 抛出 JobNotCancellableError', async () => { + (prisma.aiJob.findUnique as jest.Mock).mockResolvedValue({ lifecycleStatus: 'succeeded' }); + + await expect(repo.requestCancellation('job-001')).rejects.toThrow(JobNotCancellableError); + }); }); describe('findByLifecycleStatus', () => { @@ -424,4 +472,31 @@ describe('AiJobLifecycleRepository', () => { ); }); }); + + describe('findExpiredLocks', () => { + it('查找锁过期的 running Job', async () => { + (prisma.aiJob.findMany as jest.Mock).mockResolvedValue([]); + + await repo.findExpiredLocks(90000); + expect(prisma.aiJob.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + lifecycleStatus: 'running', + lockUntil: { lt: expect.any(Date) }, + }, + }), + ); + }); + }); + + describe('findById', () => { + it('按 ID 查找 Job', async () => { + const mockJob = { id: 'job-001', lifecycleStatus: 'queued' }; + (prisma.aiJob.findUnique as jest.Mock).mockResolvedValue(mockJob); + + const result = await repo.findById('job-001'); + expect(prisma.aiJob.findUnique).toHaveBeenCalledWith({ where: { id: 'job-001' } }); + expect(result).toEqual(mockJob); + }); + }); });