test(ST-1111.8): cover job-status mappers and enqueue/cancel actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dace4271a3
commit
2c2a246347
2 changed files with 208 additions and 0 deletions
165
__tests__/actions/claude-jobs.test.ts
Normal file
165
__tests__/actions/claude-jobs.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockFindFirstTask,
|
||||
mockFindFirstJob,
|
||||
mockCreateJob,
|
||||
mockUpdateJob,
|
||||
mockExecuteRaw,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstTask: vi.fn(),
|
||||
mockFindFirstJob: vi.fn(),
|
||||
mockCreateJob: vi.fn(),
|
||||
mockUpdateJob: vi.fn(),
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
task: { findFirst: mockFindFirstTask },
|
||||
claudeJob: {
|
||||
findFirst: mockFindFirstJob,
|
||||
create: mockCreateJob,
|
||||
update: mockUpdateJob,
|
||||
},
|
||||
$executeRaw: mockExecuteRaw,
|
||||
},
|
||||
}))
|
||||
|
||||
import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs'
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
const TASK_ID = 'task-cuid-1'
|
||||
const JOB_ID = 'job-cuid-1'
|
||||
const PRODUCT_ID = 'product-cuid-1'
|
||||
|
||||
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
|
||||
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteRaw.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('enqueueClaudeJobAction', () => {
|
||||
it('happy path: creates job with QUEUED status', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue(null)
|
||||
mockCreateJob.mockResolvedValue({ id: JOB_ID })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, jobId: JOB_ID })
|
||||
expect(mockCreateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when task not found', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(null)
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Task niet gevonden' })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows new enqueue after terminal (DONE) job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue(null) // no active job
|
||||
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelClaudeJobAction', () => {
|
||||
it('happy path: cancels QUEUED job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
|
||||
mockUpdateJob.mockResolvedValue({})
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: JOB_ID },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('demo user is blocked', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when job not found (ownership check)', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(null)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Job niet gevonden' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cancelling terminal (DONE) job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cancelling FAILED job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
||||
})
|
||||
})
|
||||
43
__tests__/lib/job-status.test.ts
Normal file
43
__tests__/lib/job-status.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
jobStatusToApi,
|
||||
jobStatusFromApi,
|
||||
JOB_STATUS_API_VALUES,
|
||||
ACTIVE_JOB_STATUSES,
|
||||
} from '@/lib/job-status'
|
||||
|
||||
describe('job-status mappers', () => {
|
||||
it('round-trips every API value', () => {
|
||||
for (const api of JOB_STATUS_API_VALUES) {
|
||||
const db = jobStatusFromApi(api)
|
||||
expect(db).not.toBeNull()
|
||||
expect(jobStatusToApi(db!)).toBe(api)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns null for invalid input', () => {
|
||||
expect(jobStatusFromApi('NOT_A_STATUS')).toBeNull()
|
||||
expect(jobStatusFromApi('')).toBeNull()
|
||||
expect(jobStatusFromApi('active')).toBeNull()
|
||||
})
|
||||
|
||||
it('is case-insensitive on the API side (accepts both upper and lower)', () => {
|
||||
expect(jobStatusFromApi('running')).toBe('RUNNING')
|
||||
expect(jobStatusFromApi('RUNNING')).toBe('RUNNING')
|
||||
expect(jobStatusFromApi('QUEUED')).toBe('QUEUED')
|
||||
})
|
||||
|
||||
it('maps all 6 DB statuses to API', () => {
|
||||
expect(jobStatusToApi('QUEUED')).toBe('queued')
|
||||
expect(jobStatusToApi('CLAIMED')).toBe('claimed')
|
||||
expect(jobStatusToApi('RUNNING')).toBe('running')
|
||||
expect(jobStatusToApi('DONE')).toBe('done')
|
||||
expect(jobStatusToApi('FAILED')).toBe('failed')
|
||||
expect(jobStatusToApi('CANCELLED')).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => {
|
||||
expect(ACTIVE_JOB_STATUSES).toEqual(expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING']))
|
||||
expect(ACTIVE_JOB_STATUSES).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue