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() + }) +}) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 1da99ef..120124c 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -49,6 +49,8 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, + previewEnqueueAllAction, + enqueueClaudeJobsBatchAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -193,6 +195,196 @@ describe('enqueueAllTodoJobsAction', () => { }) }) +const makePbiTask = (id: string, status: string, pbiStatus = 'READY') => ({ + id, + title: `Task ${id}`, + status, + story: { id: 'story-1', title: 'Story 1', code: 'ST-1', pbi: { id: 'pbi-1', status: pbiStatus, priority: 1, sort_order: 1.0 } }, +}) + +describe('previewEnqueueAllAction', () => { + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockFindManyTask).not.toHaveBeenCalled() + }) + + it('returns error when product not accessible', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue(null) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) + expect(mockFindManyTask).not.toHaveBeenCalled() + }) + + it('returns empty tasks when no active sprint', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue(null) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toEqual({ tasks: [], blockerIndex: null, blockerReason: null }) + expect(mockFindManyTask).not.toHaveBeenCalled() + }) + + it('returns all tasks with no blocker when only TO_DO tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([ + makePbiTask('t1', 'TO_DO'), + makePbiTask('t2', 'TO_DO'), + ]) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: null, blockerReason: null }) + if (!('error' in result)) expect(result.tasks).toHaveLength(2) + }) + + it('detects REVIEW task as blocker at correct index', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([ + makePbiTask('t1', 'TO_DO'), + makePbiTask('t2', 'TO_DO'), + makePbiTask('t3', 'REVIEW'), + makePbiTask('t4', 'TO_DO'), + ]) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' }) + if (!('error' in result)) expect(result.tasks).toHaveLength(3) + }) + + it('detects BLOCKED PBI as blocker at first task of that PBI', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([ + makePbiTask('t1', 'TO_DO', 'BLOCKED'), + makePbiTask('t2', 'TO_DO', 'BLOCKED'), + ]) + + const result = await previewEnqueueAllAction(PRODUCT_ID) + + expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' }) + if (!('error' in result)) expect(result.tasks).toHaveLength(1) + }) + + it('queries without TO_DO filter to expose REVIEW tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([]) + + await previewEnqueueAllAction(PRODUCT_ID) + + expect(mockFindManyTask).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.not.objectContaining({ status: 'TO_DO' }), + }) + ) + }) +}) + +const makeBatchTask = (id: string, hasActiveJob = false) => ({ + id, + claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [], +}) + +describe('enqueueClaudeJobsBatchAction', () => { + it('happy path: 3 taskIds → 3 jobs in input order', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([ + makeBatchTask('t1'), + makeBatchTask('t2'), + makeBatchTask('t3'), + ]) + mockTransaction.mockResolvedValue([ + { id: 'job-1', task_id: 't1' }, + { id: 'job-2', task_id: 't2' }, + { id: 'job-3', task_id: 't3' }, + ]) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3']) + + expect(result).toEqual({ success: true, count: 3 }) + expect(mockExecuteRaw).toHaveBeenCalledTimes(3) + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1']) + + 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 enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1']) + + expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns error when task belongs to another user (IDOR)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + // Only 1 of 2 tasks authorized (other-user's task filtered out) + mockFindManyTask.mockResolvedValue([makeBatchTask('t1')]) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user']) + + expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('skips tasks with active jobs (idempotent)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([ + makeBatchTask('t1'), + makeBatchTask('t2', true), // has active job — skip + makeBatchTask('t3'), + ]) + mockTransaction.mockResolvedValue([ + { id: 'job-1', task_id: 't1' }, + { id: 'job-3', task_id: 't3' }, + ]) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3']) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('returns count=0 for empty taskIds', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + + const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, []) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockFindFirstProduct).not.toHaveBeenCalled() + }) +}) + describe('cancelClaudeJobAction', () => { it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) diff --git a/__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx b/__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx new file mode 100644 index 0000000..e349227 --- /dev/null +++ b/__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx @@ -0,0 +1,114 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; onOpenChange?: (v: boolean) => void; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + variant, + }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + variant?: string + }) => ( + + ), +})) +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => + r ? <>{r} : <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})) + +import { BatchEnqueueBlockerDialog } from '@/components/solo/batch-enqueue-blocker-dialog' + +const DEFAULT_PROPS = { + open: true, + onOpenChange: vi.fn(), + prefixCount: 3, + blockerReason: 'task-review' as const, + blockerLabel: 'Story X — Task Y (in review)', + onConfirm: vi.fn(), + onCancel: vi.fn(), +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('BatchEnqueueBlockerDialog', () => { + it('renders title and blocker info for task-review', () => { + render() + + expect(screen.getByRole('heading')).toHaveTextContent('Blokkade gedetecteerd') + expect(screen.getByText(/Een taak staat op 'review'/)).toBeInTheDocument() + expect(screen.getByText(/Story X — Task Y/)).toBeInTheDocument() + }) + + it('renders correct blocker label for pbi-blocked', () => { + render( + + ) + + expect(screen.getByText(/De PBI is geblokkeerd/)).toBeInTheDocument() + expect(screen.getByText(/PBI Z/)).toBeInTheDocument() + }) + + it('calls onConfirm when primary button is clicked', () => { + render() + + fireEvent.click(screen.getByText(/Stuur 3 taken tot aan blokkade/)) + + expect(DEFAULT_PROPS.onConfirm).toHaveBeenCalledTimes(1) + }) + + it('calls onCancel when cancel button is clicked', () => { + render() + + fireEvent.click(screen.getByText('Annuleer')) + + expect(DEFAULT_PROPS.onCancel).toHaveBeenCalledTimes(1) + }) + + it('disables confirm button and shows tooltip when prefixCount is 0', () => { + render() + + const confirmBtn = screen.getByText(/Stuur 0/).closest('button') + expect(confirmBtn).toBeDisabled() + expect(screen.getByTestId('tooltip-content')).toHaveTextContent('Geen taken vóór blokkade') + }) + + it('does not render when open is false', () => { + render() + + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) + + it('uses singular taak when prefixCount is 1', () => { + render() + + expect(screen.getByText(/Stuur 1 taak tot aan blokkade/)).toBeInTheDocument() + expect(screen.getByText(/1 taak vóór de blokkade/)).toBeInTheDocument() + }) +}) diff --git a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx new file mode 100644 index 0000000..392bf6e --- /dev/null +++ b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx @@ -0,0 +1,204 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' + +const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({ + mockPreviewEnqueueAllAction: vi.fn(), + mockEnqueueClaudeJobsBatchAction: vi.fn(), +})) + +vi.mock('@/actions/claude-jobs', () => ({ + previewEnqueueAllAction: mockPreviewEnqueueAllAction, + enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction, + cancelClaudeJobAction: vi.fn(), + enqueueClaudeJobAction: vi.fn(), +})) +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + DragOverlay: () => null, + PointerSensor: class {}, + useSensor: vi.fn(() => ({})), + useSensors: vi.fn(() => []), + closestCorners: vi.fn(), +})) +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children?: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => + open ?
{children}
: null, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})) +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => + r ? <>{r} : <>{children}, + TooltipContent: () => null, +})) +vi.mock('@/components/shared/demo-tooltip', () => ({ + DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) +vi.mock('@/components/split-pane/split-pane', () => ({ + SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}, +})) +vi.mock('@/components/solo/solo-column', () => ({ + SoloColumn: () =>
, +})) +vi.mock('@/components/solo/solo-task-card', () => ({ + SoloTaskCardOverlay: () => null, +})) +vi.mock('@/components/solo/task-detail-dialog', () => ({ + TaskDetailDialog: () => null, +})) +vi.mock('@/components/solo/unassigned-stories-sheet', () => ({ + UnassignedStoriesSheet: () => null, +})) +vi.mock('@/lib/task-status', () => ({ + taskStatusToApi: (s: string) => s.toLowerCase(), +})) + +import { useSoloStore } from '@/stores/solo-store' +import { SoloBoard } from '@/components/solo/solo-board' +import { toast } from 'sonner' + +const PRODUCT_ID = 'prod-1' +const TODO_TASK = { + id: 't1', + title: 'Task 1', + description: null, + implementation_plan: null, + priority: 1, + sort_order: 1, + status: 'TO_DO' as const, + verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL' as const, + story_id: 'story-1', + story_code: 'ST-1', + story_title: 'Story 1', + task_code: 'ST-1.1', +} + +const DEFAULT_PROPS = { + productId: PRODUCT_ID, + sprintGoal: 'Sprint goal', + tasks: [TODO_TASK], + unassignedStories: [], + isDemo: false, + currentUserId: 'user-1', +} + +const PREVIEW_NO_BLOCKER = { + tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }], + blockerIndex: null, + blockerReason: null, +} + +const PREVIEW_WITH_BLOCKER = { + tasks: [ + { id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, + { id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, + { id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, + ], + blockerIndex: 2, + blockerReason: 'task-review' as const, +} + +beforeEach(() => { + vi.clearAllMocks() + useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 }) +}) + +describe('SoloBoard — batch-enqueue flow', () => { + it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER) + mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 }) + + render() + + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => { + expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID) + expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1']) + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent')) + }) + }) + + it('blocker: shows dialog when preview returns blockerIndex', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) + + render() + + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => { + expect(screen.getByTestId('dialog')).toBeInTheDocument() + expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument() + }) + expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() + }) + + it('blocker dialog confirm: enqueues prefix tasks and closes', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) + mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 }) + + render() + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => screen.getByTestId('dialog')) + + fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/)) + + await waitFor(() => { + expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2']) + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents')) + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) + }) + + it('blocker dialog cancel: closes dialog without enqueuing', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) + + render() + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => screen.getByTestId('dialog')) + + fireEvent.click(screen.getByText('Annuleer')) + + await waitFor(() => { + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) + expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() + }) + + it('preview error: shows toast without opening dialog', async () => { + mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' }) + + render() + fireEvent.click(screen.getByText(/Start agents/)) + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Geen toegang') + }) + expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 17c55d3..d7ba1e3 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -16,6 +16,19 @@ type EnqueueAllResult = type CancelResult = { success: true } | { error: string } +export type PreviewTask = { + id: string + title: string + status: string + story_title: string + pbi_id: string + pbi_status: string +} + +type PreflightResult = + | { error: string } + | { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null } + export async function enqueueClaudeJobAction(taskId: string): Promise { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -125,6 +138,160 @@ export async function enqueueAllTodoJobsAction(productId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!productId) return { error: 'product_id is verplicht' } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Geen toegang tot dit product' } + + const userId = session.userId + + const sprint = await prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (!sprint) return { tasks: [], blockerIndex: null, blockerReason: null } + + const rawTasks = await prisma.task.findMany({ + where: { + story: { sprint_id: sprint.id, assignee_id: userId }, + claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, + }, + select: { + id: true, + title: true, + status: true, + story: { + select: { + id: true, + title: true, + code: true, + pbi: { select: { id: true, status: true, priority: true, sort_order: true } }, + }, + }, + }, + orderBy: [ + { story: { pbi: { priority: 'asc' } } }, + { story: { pbi: { sort_order: 'asc' } } }, + { story: { sort_order: 'asc' } }, + { priority: 'asc' }, + { sort_order: 'asc' }, + ], + }) + + let blockerIndex: number | null = null + let blockerReason: 'task-review' | 'pbi-blocked' | null = null + + for (let i = 0; i < rawTasks.length; i++) { + const t = rawTasks[i] + if (t.status === 'REVIEW') { + blockerIndex = i + blockerReason = 'task-review' + break + } + if (t.story.pbi.status === 'BLOCKED') { + blockerIndex = i + blockerReason = 'pbi-blocked' + break + } + } + + const displayTasks = blockerIndex !== null ? rawTasks.slice(0, blockerIndex + 1) : rawTasks + + const tasks: PreviewTask[] = displayTasks.map(t => ({ + id: t.id, + title: t.title, + status: t.status, + story_title: t.story.title, + pbi_id: t.story.pbi.id, + pbi_status: t.story.pbi.status, + })) + + return { tasks, blockerIndex, blockerReason } +} + +export async function enqueueClaudeJobsBatchAction( + productId: string, + taskIds: string[] +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!productId) return { error: 'product_id is verplicht' } + if (!taskIds.length) return { success: true, count: 0 } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Geen toegang tot dit product' } + + const userId = session.userId + + const sprint = await prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (!sprint) return { error: 'Geen actieve sprint gevonden' } + + const authorizedTasks = await prisma.task.findMany({ + where: { + id: { in: taskIds }, + story: { sprint_id: sprint.id, assignee_id: userId }, + }, + select: { + id: true, + claude_jobs: { + where: { status: { in: ACTIVE_JOB_STATUSES } }, + select: { id: true }, + }, + }, + }) + + if (authorizedTasks.length !== taskIds.length) { + return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' } + } + + const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0) + if (queueable.length === 0) return { success: true, count: 0 } + + const queueableIds = new Set(queueable.map(t => t.id)) + const orderedQueueable = taskIds.filter(id => queueableIds.has(id)) + + const created = await prisma.$transaction( + orderedQueueable.map(taskId => + prisma.claudeJob.create({ + data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' }, + select: { id: true, task_id: true }, + }) + ) + ) + + for (const job of created) { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: job.task_id, + user_id: userId, + product_id: productId, + status: 'queued', + })}::text) + ` + } + + revalidatePath(`/products/${productId}/solo`) + return { success: true, count: created.length } +} + export async function cancelClaudeJobAction(jobId: string): Promise { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 3ad8a25..12fdd16 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -50,6 +50,8 @@ export default async function SoloProductPage({ params }: Props) { }, }, orderBy: [ + { story: { pbi: { priority: 'asc' } } }, + { story: { pbi: { sort_order: 'asc' } } }, { story: { sort_order: 'asc' } }, { priority: 'asc' }, { sort_order: 'asc' }, diff --git a/components/solo/batch-enqueue-blocker-dialog.tsx b/components/solo/batch-enqueue-blocker-dialog.tsx new file mode 100644 index 0000000..81bf593 --- /dev/null +++ b/components/solo/batch-enqueue-blocker-dialog.tsx @@ -0,0 +1,87 @@ +'use client' + +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' + +interface BatchEnqueueBlockerDialogProps { + open: boolean + onOpenChange: (v: boolean) => void + prefixCount: number + blockerReason: 'task-review' | 'pbi-blocked' + blockerLabel: string + onConfirm: () => void + onCancel: () => void +} + +const BLOCKER_REASON_LABELS: Record = { + 'task-review': "Een taak staat op 'review'", + 'pbi-blocked': 'De PBI is geblokkeerd', +} + +export function BatchEnqueueBlockerDialog({ + open, + onOpenChange, + prefixCount, + blockerReason, + blockerLabel, + onConfirm, + onCancel, +}: BatchEnqueueBlockerDialogProps) { + const noTasksBeforeBlocker = prefixCount === 0 + + return ( + + + + Blokkade gedetecteerd + + +
+

+ {BLOCKER_REASON_LABELS[blockerReason]}:{' '} + {blockerLabel}. +

+ {noTasksBeforeBlocker ? ( +

Er zijn geen taken vóór de blokkade om in te plannen.

+ ) : ( +

+ {prefixCount === 1 + ? `Er is ${prefixCount} taak vóór de blokkade.` + : `Er zijn ${prefixCount} taken vóór de blokkade.`} +

+ )} +
+ +
+ + + + + + + } + /> + {noTasksBeforeBlocker && ( + + Geen taken vóór blokkade + + )} + + +
+
+
+ ) +} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 54ee48e..0dd6fa2 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -8,7 +8,8 @@ import { import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' import { taskStatusToApi } from '@/lib/task-status' -import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' +import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' +import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { SplitPane } from '@/components/split-pane/split-pane' @@ -61,6 +62,15 @@ export function SoloBoard({ const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() const [batchPending, startBatchTransition] = useTransition() + const [confirmPending, startConfirmTransition] = useTransition() + + type BlockerDialogState = { + prefixCount: number + blockerReason: 'task-review' | 'pbi-blocked' + blockerLabel: string + prefixIds: string[] + } + const [blockerDialog, setBlockerDialog] = useState(null) const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { @@ -140,7 +150,42 @@ export function SoloBoard({ function handleStartAll() { if (queueableCount === 0) return startBatchTransition(async () => { - const result = await enqueueAllTodoJobsAction(productId) + const preview = await previewEnqueueAllAction(productId) + if ('error' in preview) { + toast.error(preview.error) + return + } + if (preview.blockerIndex === null) { + const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id) + const result = await enqueueClaudeJobsBatchAction(productId, todoIds) + if ('error' in result) { + toast.error(result.error) + } else if (result.count === 0) { + toast.info('Geen taken om te starten') + } else { + toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`) + } + } else { + const blockerTask = preview.tasks[preview.blockerIndex] + const blockerLabel = preview.blockerReason === 'task-review' + ? `${blockerTask.story_title} — ${blockerTask.title}` + : blockerTask.story_title + setBlockerDialog({ + prefixCount: preview.blockerIndex, + blockerReason: preview.blockerReason!, + blockerLabel, + prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id), + }) + } + }) + } + + function handleBlockerConfirm() { + if (!blockerDialog) return + const { prefixIds } = blockerDialog + setBlockerDialog(null) + startConfirmTransition(async () => { + const result = await enqueueClaudeJobsBatchAction(productId, prefixIds) if ('error' in result) { toast.error(result.error) } else if (result.count === 0) { @@ -159,9 +204,9 @@ export function SoloBoard({ {sprintGoal && ( @@ -234,6 +279,18 @@ export function SoloBoard({ onOpenChange={setSheetOpen} onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))} /> + + {blockerDialog && ( + { if (!v) setBlockerDialog(null) }} + prefixCount={blockerDialog.prefixCount} + blockerReason={blockerDialog.blockerReason} + blockerLabel={blockerDialog.blockerLabel} + onConfirm={handleBlockerConfirm} + onCancel={() => setBlockerDialog(null)} + /> + )}
) }