import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, mockFindFirstTask, mockFindManyTask, mockFindFirstProduct, mockFindFirstSprint, mockFindFirstJob, mockCreateJob, mockUpdateJob, mockExecuteRaw, mockTransaction, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockFindFirstTask: vi.fn(), mockFindManyTask: vi.fn(), mockFindFirstProduct: vi.fn(), mockFindFirstSprint: vi.fn(), mockFindFirstJob: vi.fn(), mockCreateJob: vi.fn(), mockUpdateJob: vi.fn(), mockExecuteRaw: vi.fn().mockResolvedValue(undefined), mockTransaction: vi.fn(), })) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession, })) vi.mock('@/lib/prisma', () => ({ prisma: { task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, product: { findFirst: mockFindFirstProduct }, sprint: { findFirst: mockFindFirstSprint }, claudeJob: { findFirst: mockFindFirstJob, create: mockCreateJob, update: mockUpdateJob, }, $executeRaw: mockExecuteRaw, $transaction: mockTransaction, }, })) import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, 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('enqueueAllTodoJobsAction', () => { it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => { mockGetSession.mockResolvedValue(SESSION_USER) mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }]) mockTransaction.mockResolvedValue([ { id: 'job-a', task_id: 'task-a' }, { id: 'job-b', task_id: 'task-b' }, ]) const result = await enqueueAllTodoJobsAction(PRODUCT_ID) expect(result).toEqual({ success: true, count: 2 }) expect(mockFindManyTask).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ status: 'TO_DO', story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId }, }), }) ) expect(mockExecuteRaw).toHaveBeenCalledTimes(2) }) it('returns count=0 when product has no active sprint', async () => { mockGetSession.mockResolvedValue(SESSION_USER) mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) mockFindFirstSprint.mockResolvedValue(null) const result = await enqueueAllTodoJobsAction(PRODUCT_ID) expect(result).toEqual({ success: true, count: 0 }) expect(mockFindManyTask).not.toHaveBeenCalled() expect(mockTransaction).not.toHaveBeenCalled() }) it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => { mockGetSession.mockResolvedValue(SESSION_USER) mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) mockFindManyTask.mockResolvedValue([]) const result = await enqueueAllTodoJobsAction(PRODUCT_ID) expect(result).toEqual({ success: true, count: 0 }) expect(mockTransaction).not.toHaveBeenCalled() expect(mockExecuteRaw).not.toHaveBeenCalled() }) it('blocks demo user', async () => { mockGetSession.mockResolvedValue(SESSION_DEMO) const result = await enqueueAllTodoJobsAction(PRODUCT_ID) expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) expect(mockTransaction).not.toHaveBeenCalled() }) it('returns error when product not accessible', async () => { mockGetSession.mockResolvedValue(SESSION_USER) mockFindFirstProduct.mockResolvedValue(null) const result = await enqueueAllTodoJobsAction(PRODUCT_ID) expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) expect(mockTransaction).not.toHaveBeenCalled() }) }) 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' }) }) })