diff --git a/__tests__/actions/claude-jobs-batch.test.ts b/__tests__/actions/claude-jobs-batch.test.ts new file mode 100644 index 0000000..fc4e5e7 --- /dev/null +++ b/__tests__/actions/claude-jobs-batch.test.ts @@ -0,0 +1,232 @@ +/** + * Uitgebreide integratie-stijl tests voor previewEnqueueAllAction en + * enqueueClaudeJobsBatchAction. Gebruikt realistische seed-data: + * 2 PBIs, elk met 1 story, elk 2 taken (4 taken totaal in PBI-volgorde). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { + mockGetSession, + mockFindFirstProduct, + mockFindFirstSprint, + mockFindManyTask, + mockTransaction, + mockExecuteRaw, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstProduct: vi.fn(), + mockFindFirstSprint: vi.fn(), + mockFindManyTask: vi.fn(), + mockTransaction: 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: { findMany: mockFindManyTask }, + product: { findFirst: mockFindFirstProduct }, + sprint: { findFirst: mockFindFirstSprint }, + claudeJob: { create: vi.fn() }, + $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, + }, +})) + +import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' + +const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } +const PRODUCT_ID = 'product-1' +const SPRINT_ID = 'sprint-1' + +// --- Seed helpers --- +const makePbi1Task = (id: string, status = 'TO_DO') => ({ + id, + title: `PBI-1 Taak ${id}`, + status, + story: { + id: 'story-pbi1', + title: 'Story van PBI 1', + code: 'ST-1', + pbi: { id: 'pbi-1', status: 'READY', priority: 1, sort_order: 1.0 }, + }, +}) + +const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({ + id, + title: `PBI-2 Taak ${id}`, + status, + story: { + id: 'story-pbi2', + title: 'Story van PBI 2', + code: 'ST-2', + pbi: { id: 'pbi-2', status: pbiStatus, priority: 2, sort_order: 2.0 }, + }, +}) + +const makeBatchTask = (id: string, hasActiveJob = false) => ({ + id, + claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [], +}) + +// Canonical seed: [pbi1-t1, pbi1-t2, pbi2-t1, pbi2-t2] +const SEED_ALL_TODO = [ + makePbi1Task('pbi1-t1'), + makePbi1Task('pbi1-t2'), + makePbi2Task('pbi2-t1'), + makePbi2Task('pbi2-t2'), +] + +beforeEach(() => { + vi.clearAllMocks() + mockExecuteRaw.mockResolvedValue(undefined) + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID }) +}) + +// ============================================================= +// previewEnqueueAllAction +// ============================================================= +describe('previewEnqueueAllAction — 2 PBI scenario', () => { + it('geen blocker: alle 4 TO_DO taken → tasks=[4], blockerIndex=null', async () => { + mockFindManyTask.mockResolvedValue(SEED_ALL_TODO) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: null, blockerReason: null }) + if (!('error' in result)) { + expect(result.tasks).toHaveLength(4) + expect(result.tasks.map(t => t.id)).toEqual(['pbi1-t1', 'pbi1-t2', 'pbi2-t1', 'pbi2-t2']) + } + }) + + it('3e taak (pbi2-t1) REVIEW → blockerIndex=2, reden=task-review, tasks=[3]', async () => { + mockFindManyTask.mockResolvedValue([ + makePbi1Task('pbi1-t1'), + makePbi1Task('pbi1-t2'), + makePbi2Task('pbi2-t1', 'REVIEW'), + makePbi2Task('pbi2-t2'), + ]) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' }) + if (!('error' in result)) { + expect(result.tasks).toHaveLength(3) + expect(result.tasks[2].id).toBe('pbi2-t1') + } + }) + + it('PBI 1 BLOCKED → blockerIndex=0, reden=pbi-blocked, tasks=[1]', async () => { + mockFindManyTask.mockResolvedValue([ + makePbi1Task('pbi1-t1', 'TO_DO'), + makePbi1Task('pbi1-t2', 'TO_DO'), + makePbi2Task('pbi2-t1'), + makePbi2Task('pbi2-t2'), + ].map((t, i) => i < 2 ? { ...t, story: { ...t.story, pbi: { ...t.story.pbi, status: 'BLOCKED' } } } : t)) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' }) + if (!('error' in result)) expect(result.tasks).toHaveLength(1) + }) + + it('ACTIVE job op pbi1-t1 → geskipt door where-clause, geen blocker bij resterende 3', async () => { + // Simuleert dat pbi1-t1 een actieve job heeft: de where-clause sluit die taak uit + mockFindManyTask.mockResolvedValue([ + makePbi1Task('pbi1-t2'), + makePbi2Task('pbi2-t1'), + makePbi2Task('pbi2-t2'), + ]) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: null, blockerReason: null }) + if (!('error' in result)) { + expect(result.tasks).toHaveLength(3) + expect(result.tasks[0].id).toBe('pbi1-t2') + } + }) + + it('ACTIVE job op pbi1-t1 AND pbi2-t1 REVIEW → blockerIndex=1 in resterende array', async () => { + mockFindManyTask.mockResolvedValue([ + makePbi1Task('pbi1-t2'), + makePbi2Task('pbi2-t1', 'REVIEW'), + makePbi2Task('pbi2-t2'), + ]) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: 1, blockerReason: 'task-review' }) + if (!('error' in result)) expect(result.tasks).toHaveLength(2) + }) + + it('demo-user → error, findMany niet aangeroepen', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockFindManyTask).not.toHaveBeenCalled() + }) +}) + +// ============================================================= +// enqueueClaudeJobsBatchAction +// ============================================================= +describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => { + it('happy path: 2 taskIds → 2 QUEUED ClaudeJobs in invoervolgorde', async () => { + mockFindManyTask.mockResolvedValue([ + makeBatchTask('pbi1-t1'), + makeBatchTask('pbi2-t1'), + ]) + mockTransaction.mockResolvedValue([ + { id: 'job-a', task_id: 'pbi1-t1' }, + { id: 'job-b', task_id: 'pbi2-t1' }, + ]) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1']) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('IDOR: taskId van niet-toegewezen story → error, geen transaction', async () => { + // Authorized tasks bevat maar 1 van de 2 gevraagde IDs + mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1')]) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'other-user-task']) + + expect(result).toMatchObject({ error: expect.stringContaining('niet toegankelijk') }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('taak met ACTIVE job wordt overgeslagen (idempotent)', async () => { + mockFindManyTask.mockResolvedValue([ + makeBatchTask('pbi1-t1'), + makeBatchTask('pbi1-t2', true), // heeft actieve job → skip + makeBatchTask('pbi2-t1'), + ]) + mockTransaction.mockResolvedValue([ + { id: 'job-a', task_id: 'pbi1-t1' }, + { id: 'job-b', task_id: 'pbi2-t1' }, + ]) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2', 'pbi2-t1']) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('demo-user → error, geen transaction', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1']) + + expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + expect(mockTransaction).not.toHaveBeenCalled() + }) +})