scrum4me-mcp/__tests__/wait-for-job-snapshot.test.ts
janpeter visser 095ebc40f8 feat(M13): retry-tracking — stale CLAIMED jobs → QUEUED (retry_count++) or FAILED (≥2 retries)
resetStaleClaimedJobs now uses $queryRaw with RETURNING so it can send pg_notify
claude_job_status events per transitioned job. Jobs under the retry limit are
re-queued with retry_count incremented; jobs at ≥2 retries are marked FAILED.
2026-05-01 13:18:59 +02:00

148 lines
5.3 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../src/prisma.js', () => ({
prisma: {
$queryRaw: vi.fn(),
$transaction: vi.fn(),
},
}))
import { prisma } from '../src/prisma.js'
import { resetStaleClaimedJobs, tryClaimJob } from '../src/tools/wait-for-job.js'
const mockPrisma = prisma as unknown as {
$queryRaw: ReturnType<typeof vi.fn>
$transaction: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.clearAllMocks()
// Default: no stale jobs returned from either query
mockPrisma.$queryRaw.mockResolvedValue([])
})
describe('resetStaleClaimedJobs', () => {
it('runs two $queryRaw calls: one for FAILED, one for QUEUED re-enqueue', async () => {
await resetStaleClaimedJobs('user-1')
// Two queries: failed jobs + requeued jobs
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(2)
})
it('FAILED query includes plan_snapshot = NULL reset and retry_count >= 2', async () => {
await resetStaleClaimedJobs('user-1')
const calls = mockPrisma.$queryRaw.mock.calls
// First call: FAILED transition
const failedSql = (calls[0][0] as string[]).join('')
expect(failedSql).toContain("status = 'FAILED'")
expect(failedSql).toContain('retry_count >= 2')
})
it('QUEUED re-enqueue query includes plan_snapshot = NULL and retry_count increment', async () => {
await resetStaleClaimedJobs('user-1')
const calls = mockPrisma.$queryRaw.mock.calls
// Second call: re-enqueue transition
const requeueSql = (calls[1][0] as string[]).join('')
expect(requeueSql).toContain("status = 'QUEUED'")
expect(requeueSql).toContain('plan_snapshot = NULL')
expect(requeueSql).toContain('retry_count = retry_count + 1')
expect(requeueSql).toContain('retry_count < 2')
})
})
describe('tryClaimJob', () => {
it('writes plan_snapshot from task.implementation_plan when claiming a job', async () => {
const jobId = 'job-123'
const implementationPlan = 'Step 1: Do the thing\nStep 2: Done'
mockPrisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => {
const mockTx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: jobId, implementation_plan: implementationPlan }]),
$executeRaw: vi.fn().mockResolvedValue(1),
}
return fn(mockTx as unknown as typeof prisma)
})
const result = await tryClaimJob('user-1', 'token-1')
expect(result).toBe(jobId)
// Verify the transaction was called and the UPDATE included plan_snapshot
expect(mockPrisma.$transaction).toHaveBeenCalledOnce()
const txFn = mockPrisma.$transaction.mock.calls[0][0]
const capturedTx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: jobId, implementation_plan: implementationPlan }]),
$executeRaw: vi.fn().mockResolvedValue(1),
}
await txFn(capturedTx as unknown as typeof prisma)
const updateCall = capturedTx.$executeRaw.mock.calls[0]
const sqlParts: string[] = updateCall[0]
const fullSql = sqlParts.join('')
expect(fullSql).toContain('plan_snapshot')
expect(fullSql).toContain("status = 'CLAIMED'")
})
it('uses empty string as snapshot when task has no implementation_plan', async () => {
const jobId = 'job-456'
mockPrisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => {
const mockTx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: jobId, implementation_plan: null }]),
$executeRaw: vi.fn().mockResolvedValue(1),
}
return fn(mockTx as unknown as typeof prisma)
})
const result = await tryClaimJob('user-1', 'token-1')
expect(result).toBe(jobId)
// Verify the snapshot value passed is '' (empty string, not null)
const capturedTx = {
$queryRaw: vi.fn().mockResolvedValue([{ id: jobId, implementation_plan: null }]),
$executeRaw: vi.fn().mockResolvedValue(1),
}
const txFn = mockPrisma.$transaction.mock.calls[0][0]
await txFn(capturedTx as unknown as typeof prisma)
const updateCall = capturedTx.$executeRaw.mock.calls[0]
// Template literal params: [0]=sql parts, [1]=tokenId, [2]=snapshot, [3]=jobId
expect(updateCall[2]).toBe('')
})
it('returns null when no QUEUED job is available', async () => {
mockPrisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => {
const mockTx = {
$queryRaw: vi.fn().mockResolvedValue([]),
$executeRaw: vi.fn(),
}
return fn(mockTx as unknown as typeof prisma)
})
const result = await tryClaimJob('user-1', 'token-1')
expect(result).toBeNull()
})
it('scopes to product_id when provided', async () => {
mockPrisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => {
const mockTx = {
$queryRaw: vi.fn().mockResolvedValue([]),
$executeRaw: vi.fn(),
}
return fn(mockTx as unknown as typeof prisma)
})
await tryClaimJob('user-1', 'token-1', 'product-1')
const capturedTx = {
$queryRaw: vi.fn().mockResolvedValue([]),
$executeRaw: vi.fn(),
}
const txFn = mockPrisma.$transaction.mock.calls[0][0]
await txFn(capturedTx as unknown as typeof prisma)
const queryCall = capturedTx.$queryRaw.mock.calls[0]
// product_id should be passed as a parameter
expect(queryCall).toContain('product-1')
})
})