* 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).
442 lines
15 KiB
TypeScript
442 lines
15 KiB
TypeScript
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,
|
|
previewEnqueueAllAction,
|
|
enqueueClaudeJobsBatchAction,
|
|
} 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()
|
|
})
|
|
})
|
|
|
|
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)
|
|
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' })
|
|
})
|
|
})
|