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 (
+
+ )
+}
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)}
+ />
+ )}
)
}