diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts new file mode 100644 index 0000000..fea9d8c --- /dev/null +++ b/__tests__/actions/claude-jobs.test.ts @@ -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' }) + }) +}) diff --git a/__tests__/lib/job-status.test.ts b/__tests__/lib/job-status.test.ts new file mode 100644 index 0000000..db8d1ab --- /dev/null +++ b/__tests__/lib/job-status.test.ts @@ -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) + }) +})