Scrum4Me/__tests__/components/solo/solo-board-batch-enqueue.test.tsx
Janpeter Visser 0ce6076a5c
Solo batch-enqueue: per-PBI volgorde + blocker-dialog (#65)
* feat(solo): orderBy taken per PBI-hiërarchie

Voeg pbi.priority en pbi.sort_order toe aan de task.findMany orderBy in de solo-page query zodat taken per PBI gegroepeerd worden vóór story- en task-volgorde.

* feat(solo): previewEnqueueAllAction met blocker-detectie

Voeg previewEnqueueAllAction toe aan actions/claude-jobs.ts: haalt taken op in PBI-volgorde, filtert actieve jobs, detecteert eerste blocker (REVIEW taak of BLOCKED PBI). Retourneert tasks[], blockerIndex en blockerReason. Tests: 7 nieuwe cases voor alle blocker-scenario's en demo-blokkering.

* feat(solo): enqueueClaudeJobsBatchAction met IDOR-check

Voeg enqueueClaudeJobsBatchAction toe: accepteert expliciete taskIds[], verifieert dat alle IDs bij de ingelogde gebruiker horen (IDOR-preventie), slaat taken met actieve jobs over (idempotent), en maakt jobs aan in transactie in opgegeven volgorde. 6 nieuwe tests.

* feat(solo): BatchEnqueueBlockerDialog component

Nieuw dialoogvenster dat gebruiker waarschuwt bij gedetecteerde blocker: toont blockerReason in NL, prefixCount taken vóór blokkade, confirm-knop (disabled met tooltip bij count=0) en annuleer-knop. 7 tests voor rendering, click-handlers en disabled-state.

* feat(solo): preview-then-confirm flow in SoloBoard Voer-alle-uit

Vervang directe enqueueAllTodoJobsAction door previewEnqueueAllAction + BatchEnqueueBlockerDialog. Geen blocker → enqueueClaudeJobsBatchAction direct. Wel blocker → dialog met prefix-enqueue of annuleer. Loading-state op knop tijdens preview en confirm. 5 integratie-tests.

* test(solo): uitgebreide batch-preflight tests met 2 PBI's en 4 taken

Nieuw claude-jobs-batch.test.ts: 10 gevallen voor previewEnqueueAllAction (PBI-volgorde, REVIEW/BLOCKED-detectie, active-job-skip met blockerIndex-shift) en enqueueClaudeJobsBatchAction (happy path, IDOR, active-job-skip, demo).
2026-05-03 13:55:13 +02:00

204 lines
7.1 KiB
TypeScript

// @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
}) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}))
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: () => <div data-testid="solo-column" />,
}))
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(<SoloBoard {...DEFAULT_PROPS} />)
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(<SoloBoard {...DEFAULT_PROPS} />)
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(<SoloBoard {...DEFAULT_PROPS} />)
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(<SoloBoard {...DEFAULT_PROPS} />)
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(<SoloBoard {...DEFAULT_PROPS} />)
fireEvent.click(screen.getByText(/Start agents/))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Geen toegang')
})
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
})
})