* 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).
232 lines
7.7 KiB
TypeScript
232 lines
7.7 KiB
TypeScript
/**
|
|
* 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()
|
|
})
|
|
})
|