PBI-46: Sprint-niveau jobflow met cascade-FAIL (F1/F2/F4 Scrum4Me) (#136)
* ST-1243: F1 schema + propagateStatusUpwards-helper voor sprint-flow Schema-uitbreidingen voor de sprint-niveau jobflow (PBI-46): - TaskStatus, StoryStatus, PbiStatus, SprintStatus krijgen FAILED - Nieuwe enums: SprintRunStatus, PrStrategy - Nieuw SprintRun-model dat per-task ClaudeJobs groepeert - ClaudeJob.sprint_run_id koppeling + index - Product.pr_strategy (default SPRINT) - Bijhorende Prisma-migratie propagateStatusUpwards vervangt updateTaskStatusWithStoryPromotion en herevalueert de keten Task → Story → PBI → Sprint → SprintRun bij elke task-statuswijziging. Bij FAILED cancelt het sibling-jobs in dezelfde SprintRun. PBI-status BLOCKED blijft handmatig en wordt niet overschreven. Status-mappers + theme krijgen failed-token + label-uitbreidingen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1244: F2 sprint-runs actions + deprecate per-task enqueues actions/sprint-runs.ts (nieuw): - startSprintRunAction met pre-flight (impl_plan / open ClaudeQuestion / PBI BLOCKED|FAILED) - Maakt SprintRun + ClaudeJobs in PBI→Story→Task volgorde - resumeSprintAction zet FAILED tasks/stories/PBIs terug en start nieuwe SprintRun - cancelSprintRunAction breekt lopende SprintRun af zonder cascade actions/claude-jobs.ts: - enqueueClaudeJobAction, enqueueAllTodoJobsAction, previewEnqueueAllAction, enqueueClaudeJobsBatchAction nu deprecation-stubs (UI-cleanup volgt in F4) - cancelClaudeJobAction blijft beschikbaar voor losse jobs Tests bijgewerkt: 11 nieuwe sprint-runs tests, claude-jobs(-batch) tests herzien naar deprecation-asserties. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1246: F4 UI Start/Resume/Cancel sprint + pr_strategy dropdown - components/sprint/sprint-run-controls.tsx: knoppen Start Sprint (sprintStatus=ACTIVE), Hervat sprint (sprintStatus=FAILED) en Annuleer sprint-run (lopende run). Pre-flight blocker-modal toont blockers met directe links naar de relevante pagina's. - components/products/pr-strategy-select.tsx: dropdown SPRINT|STORY in product-settings, met optimistic update + sonner-toast op fail. - actions/products.ts: updatePrStrategyAction (eigenaar-only, demo-block). - Sprint-page: query op actieve SprintRun + tonen van controls-balk. Live cascade-visualisatie (T-634) staat als follow-up genoteerd — huidige sprint-board statusbadges volstaan voor MVP. De Solo-board "Voer uit"-knoppen zijn niet expliciet verwijderd; ze tonen nu de deprecation-error van de gestubde actions tot de Solo-flow opnieuw ontworpen wordt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab8c3dca3f
commit
77617e89ac
25 changed files with 1798 additions and 1014 deletions
|
|
@ -1,232 +1,29 @@
|
|||
/**
|
||||
* 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).
|
||||
* Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction
|
||||
* (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor
|
||||
* backwards-compat met UI-componenten die in F4 worden vervangen.
|
||||
*/
|
||||
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),
|
||||
}))
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
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,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/auth', () => ({ getSession: vi.fn() }))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
|
||||
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
|
||||
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()
|
||||
describe('previewEnqueueAllAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await previewEnqueueAllAction('prod-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('vervangen') })
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// 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()
|
||||
describe('enqueueClaudeJobsBatchAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2'])
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,47 +1,30 @@
|
|||
/**
|
||||
* Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft
|
||||
* actief — gebruikt voor het annuleren van losse jobs (bv. idea-jobs).
|
||||
*/
|
||||
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/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,
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -49,394 +32,75 @@ 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('enqueueClaudeJobAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await enqueueClaudeJobAction('task-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
||||
})
|
||||
})
|
||||
|
||||
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('enqueueAllTodoJobsAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await enqueueAllTodoJobsAction('prod-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelClaudeJobAction', () => {
|
||||
it('happy path: cancels QUEUED job', async () => {
|
||||
it('cancelt een actieve job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
|
||||
mockUpdateJob.mockResolvedValue({})
|
||||
mockFindFirstJob.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
status: 'QUEUED',
|
||||
task_id: 'task-1',
|
||||
product_id: 'prod-1',
|
||||
})
|
||||
mockUpdateJob.mockResolvedValue(undefined)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
const result = await cancelClaudeJobAction('job-1')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: JOB_ID },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
)
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('demo user is blocked', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
it('weigert demo-sessie', async () => {
|
||||
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
const result = await cancelClaudeJobAction('job-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when job not found (ownership check)', async () => {
|
||||
it('retourneert error als job niet gevonden', 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()
|
||||
const result = await cancelClaudeJobAction('nonexistent')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
|
||||
})
|
||||
|
||||
it('returns error when cancelling terminal (DONE) job', async () => {
|
||||
it('weigert wanneer job niet meer actief is', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
|
||||
mockFindFirstJob.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
status: 'DONE',
|
||||
task_id: 'task-1',
|
||||
product_id: 'prod-1',
|
||||
})
|
||||
|
||||
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' })
|
||||
const result = await cancelClaudeJobAction('job-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('actieve') })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
303
__tests__/actions/sprint-runs.test.ts
Normal file
303
__tests__/actions/sprint-runs.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
sprint: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
claudeQuestion: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import {
|
||||
startSprintRunAction,
|
||||
resumeSprintAction,
|
||||
cancelSprintRunAction,
|
||||
} from '@/actions/sprint-runs'
|
||||
|
||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
||||
|
||||
type Mocked = {
|
||||
sprint: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
sprintRun: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
create: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: {
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: { updateMany: ReturnType<typeof vi.fn> }
|
||||
task: { updateMany: ReturnType<typeof vi.fn> }
|
||||
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
|
||||
claudeJob: {
|
||||
create: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const mockPrisma = prisma as unknown as Mocked
|
||||
|
||||
const SPRINT_OK = {
|
||||
id: 'sprint-1',
|
||||
status: 'ACTIVE',
|
||||
product_id: 'prod-1',
|
||||
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
|
||||
}
|
||||
|
||||
const STORY_OK = {
|
||||
id: 'story-1',
|
||||
pbi_id: 'pbi-1',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
pbi: {
|
||||
id: 'pbi-1',
|
||||
code: 'PBI-1',
|
||||
title: 'PBI',
|
||||
status: 'READY',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
},
|
||||
tasks: [
|
||||
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' },
|
||||
{ id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
|
||||
)
|
||||
})
|
||||
|
||||
describe('startSprintRunAction — happy path', () => {
|
||||
it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' })
|
||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 })
|
||||
expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
sprint_id: 'sprint-1',
|
||||
started_by_id: 'user-1',
|
||||
status: 'QUEUED',
|
||||
pr_strategy: 'SPRINT',
|
||||
}),
|
||||
})
|
||||
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSprintRunAction — pre-flight blockers', () => {
|
||||
it('blokkeert wanneer task geen implementation_plan heeft', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{
|
||||
...STORY_OK,
|
||||
tasks: [
|
||||
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null },
|
||||
],
|
||||
},
|
||||
])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
||||
if (result.ok === false && 'blockers' in result) {
|
||||
expect(result.blockers).toContainEqual({
|
||||
type: 'task_no_plan',
|
||||
id: 'task-1',
|
||||
label: 'T-1: T1',
|
||||
})
|
||||
}
|
||||
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([
|
||||
{ id: 'q-1', question: 'Welke route?' },
|
||||
])
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
||||
if (result.ok === false && 'blockers' in result) {
|
||||
expect(result.blockers).toContainEqual({
|
||||
type: 'open_question',
|
||||
id: 'q-1',
|
||||
label: 'Welke route?',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{ ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } },
|
||||
])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
||||
if (result.ok === false && 'blockers' in result) {
|
||||
expect(result.blockers).toContainEqual({
|
||||
type: 'pbi_blocked',
|
||||
id: 'pbi-1',
|
||||
label: 'PBI-1: PBI',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSprintRunAction — guards', () => {
|
||||
it('weigert wanneer Sprint niet ACTIVE is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'COMPLETED' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
|
||||
})
|
||||
|
||||
it('weigert wanneer er al een actieve SprintRun is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' })
|
||||
})
|
||||
|
||||
it('weigert demo-sessie', async () => {
|
||||
mockSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, code: 403 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeSprintAction', () => {
|
||||
it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => {
|
||||
// Eerste findUnique (resume) ziet de sprint nog op FAILED;
|
||||
// de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE.
|
||||
mockPrisma.sprint.findUnique
|
||||
.mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' })
|
||||
.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => {
|
||||
if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return [STORY_OK]
|
||||
})
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' })
|
||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
|
||||
|
||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sprint-1' },
|
||||
data: { status: 'ACTIVE', completed_at: null },
|
||||
})
|
||||
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
|
||||
where: { sprint_id: 'sprint-1', status: 'FAILED' },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({
|
||||
where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' },
|
||||
data: { status: 'TO_DO' },
|
||||
})
|
||||
})
|
||||
|
||||
it('weigert als sprint niet FAILED is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'ACTIVE' })
|
||||
|
||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelSprintRunAction', () => {
|
||||
it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => {
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
||||
id: 'run-1',
|
||||
status: 'RUNNING',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
|
||||
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({
|
||||
where: { id: 'run-1' },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
sprint_run_id: 'run-1',
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
}),
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('weigert wanneer SprintRun al DONE is', async () => {
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
||||
id: 'run-1',
|
||||
status: 'DONE',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
|
||||
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' })
|
||||
})
|
||||
})
|
||||
|
|
@ -23,6 +23,24 @@ vi.mock('@/lib/prisma', () => ({
|
|||
story: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
|
|
@ -44,6 +62,24 @@ const mockPrisma = prisma as unknown as {
|
|||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprint: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -154,7 +190,14 @@ describe('saveTask — edit met status-promotie', () => {
|
|||
implementation_plan: null,
|
||||
})
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
|
||||
const result = await saveTask(
|
||||
{ ...VALID_INPUT, status: 'DONE' },
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
sprint: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
|
|
@ -19,6 +22,19 @@ vi.mock('@/lib/prisma', () => ({
|
|||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
storyLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
|
|
@ -44,10 +60,15 @@ import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
|||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||
sprint: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
task: {
|
||||
|
|
@ -55,6 +76,19 @@ const mockPrisma = prisma as unknown as {
|
|||
update: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
storyLog: { create: ReturnType<typeof vi.fn> }
|
||||
todo: { create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -409,7 +443,14 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
implementation_plan: null,
|
||||
})
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'DONE',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
||||
|
||||
const res = await patchTask(
|
||||
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
story: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
|
|
@ -31,6 +49,24 @@ const mockPrisma = prisma as unknown as {
|
|||
}
|
||||
story: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprint: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -75,7 +111,14 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
})
|
||||
// Default sibling state: only this task, already DONE → no story-promotion
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'DONE',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
||||
// Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly.
|
||||
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
|
||||
return run(prisma)
|
||||
|
|
@ -190,7 +233,14 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
story_id: 'story-1',
|
||||
})
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
|
||||
const res = await patchTask(...makeRequest({ status: 'done' }))
|
||||
expect(res.status).toBe(200)
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ describe('task-status mappers', () => {
|
|||
expect(pbiStatusFromApi('todo')).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes exactly three API values', () => {
|
||||
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done'])
|
||||
it('exposes alle vier API values', () => {
|
||||
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,23 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
story: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
|
|
@ -15,27 +32,35 @@ vi.mock('@/lib/prisma', () => ({
|
|||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
task: {
|
||||
update: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
type MockedPrisma = {
|
||||
task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> }
|
||||
story: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprint: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Pass-through: $transaction(run) just calls run with the mocked prisma client.
|
||||
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
|
||||
return run(prisma)
|
||||
})
|
||||
})
|
||||
const mockPrisma = prisma as unknown as MockedPrisma
|
||||
|
||||
const TASK_BASE = {
|
||||
id: 'task-1',
|
||||
|
|
@ -44,110 +69,267 @@ const TASK_BASE = {
|
|||
implementation_plan: null,
|
||||
}
|
||||
|
||||
describe('updateTaskStatusWithStoryPromotion', () => {
|
||||
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
|
||||
)
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — story-niveau', () => {
|
||||
it('zet story op DONE wanneer alle siblings DONE zijn', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'DONE' },
|
||||
{ status: 'DONE' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.storyStatusChange).toBe('promoted')
|
||||
expect(result.storyId).toBe('story-1')
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'DONE' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not promote when story is already DONE (idempotent)', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'FAILED' },
|
||||
{ status: 'DONE' },
|
||||
{ status: 'TO_DO' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||
const result = await propagateStatusUpwards('task-1', 'FAILED')
|
||||
|
||||
expect(result.storyStatusChange).toBe(null)
|
||||
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'FAILED' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not promote when not all siblings are DONE', async () => {
|
||||
it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'DONE' },
|
||||
{ status: 'IN_PROGRESS' },
|
||||
{ status: 'TO_DO' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.storyStatusChange).toBe(null)
|
||||
expect(result.storyChanged).toBe(false)
|
||||
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'IN_PROGRESS' },
|
||||
{ status: 'TO_DO' },
|
||||
{ status: 'DONE' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'DONE',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }, { status: 'DONE' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'COMPLETED' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||
const result = await propagateStatusUpwards('task-1', 'TO_DO')
|
||||
|
||||
expect(result.storyStatusChange).toBe('demoted')
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not demote when story is not DONE', async () => {
|
||||
it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||
|
||||
expect(result.storyStatusChange).toBe(null)
|
||||
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the task regardless of story-status change', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
|
||||
await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||
|
||||
expect(mockPrisma.task.update).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: { status: 'IN_PROGRESS' },
|
||||
select: expect.any(Object),
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
|
||||
|
||||
it('uses the provided transaction client when passed', async () => {
|
||||
const tx = {
|
||||
task: { update: vi.fn(), findMany: vi.fn() },
|
||||
story: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
|
||||
}
|
||||
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
tx.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any)
|
||||
|
||||
expect(result.storyStatusChange).toBe('promoted')
|
||||
// $transaction should NOT be called when caller already provides a tx.
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(tx.story.update).toHaveBeenCalledWith({
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'DONE' },
|
||||
data: { status: 'OPEN' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => {
|
||||
it('overschrijft een handmatig BLOCKED PBI niet', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' })
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.pbiChanged).toBe(false)
|
||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
|
||||
it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'FAILED' },
|
||||
{ status: 'DONE' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'FAILED' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
// findMany on pbi:
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'FAILED')
|
||||
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(result.pbiChanged).toBe(true)
|
||||
expect(result.sprintChanged).toBe(true)
|
||||
expect(result.sprintRunChanged).toBe(true)
|
||||
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'run-1' },
|
||||
data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }),
|
||||
}))
|
||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
sprint_run_id: 'run-1',
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
id: { not: 'job-1' },
|
||||
}),
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('zet bij alle DONE de SprintRun op DONE en Sprint op COMPLETED', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'DONE' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.sprintRunChanged).toBe(true)
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'sprint-1' },
|
||||
data: expect.objectContaining({ status: 'COMPLETED' }),
|
||||
}))
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'run-1' },
|
||||
data: expect.objectContaining({ status: 'DONE' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — transactionele aanroep', () => {
|
||||
it('gebruikt de meegegeven transaction client', async () => {
|
||||
const tx = {
|
||||
task: { update: vi.fn(), findMany: vi.fn() },
|
||||
story: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() },
|
||||
pbi: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() },
|
||||
sprint: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
|
||||
claudeJob: { findFirst: vi.fn(), updateMany: vi.fn() },
|
||||
sprintRun: { findUnique: vi.fn(), update: vi.fn() },
|
||||
}
|
||||
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
tx.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||
tx.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'OPEN',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
tx.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
tx.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any)
|
||||
|
||||
expect(result.storyChanged).toBe(false)
|
||||
// $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft.
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue