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(), findUnique: vi.fn().mockResolvedValue(null), }, claudeQuestion: { findMany: vi.fn(), }, claudeJob: { create: vi.fn(), updateMany: vi.fn(), }, product: { findUnique: vi.fn().mockResolvedValue(null), }, $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 type Mocked = { sprint: { findUnique: ReturnType; update: ReturnType } sprintRun: { findFirst: ReturnType findUnique: ReturnType create: ReturnType update: ReturnType } story: { findMany: ReturnType updateMany: ReturnType } pbi: { updateMany: ReturnType } task: { updateMany: ReturnType } claudeQuestion: { findMany: ReturnType } claudeJob: { create: ReturnType updateMany: ReturnType } $transaction: ReturnType } const mockPrisma = prisma as unknown as Mocked const SPRINT_OK = { id: 'sprint-1', status: 'OPEN', 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) => 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 — SPRINT_BATCH', () => { const SPRINT_BATCH = { ...SPRINT_OK, product: { id: 'prod-1', pr_strategy: 'SPRINT_BATCH', repo_url: 'https://github.com/example/main', }, } it('blokkeert task met afwijkende repo_url', async () => { mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH) mockPrisma.sprintRun.findFirst.mockResolvedValue(null) mockPrisma.story.findMany.mockResolvedValue([ { ...STORY_OK, tasks: [ { id: 'task-1', code: 'T-1', title: 'In main repo', priority: 1, sort_order: 1, implementation_plan: 'plan', repo_url: null, }, { id: 'task-2', code: 'T-2', title: 'Cross-repo', priority: 1, sort_order: 2, implementation_plan: 'plan', repo_url: 'https://github.com/example/other', }, ], }, ]) 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_cross_repo', id: 'task-2', label: 'T-2: Cross-repo', }) } expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled() }) it('staat tasks toe wanneer repo_url leeg is of gelijk aan product.repo_url', async () => { mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH) mockPrisma.sprintRun.findFirst.mockResolvedValue(null) mockPrisma.story.findMany.mockResolvedValue([ { ...STORY_OK, tasks: [ { id: 'task-1', code: 'T-1', title: 'No override', priority: 1, sort_order: 1, implementation_plan: 'plan', repo_url: null, }, { id: 'task-2', code: 'T-2', title: 'Same repo', priority: 1, sort_order: 2, implementation_plan: 'plan', repo_url: 'https://github.com/example/main', }, ], }, ]) mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-batch' }) mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-sprint' }) const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-batch' }) // Eén SPRINT_IMPLEMENTATION-job, niet per-task expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(1) expect(mockPrisma.claudeJob.create).toHaveBeenCalledWith({ data: expect.objectContaining({ kind: 'SPRINT_IMPLEMENTATION', sprint_run_id: 'run-batch', product_id: 'prod-1', }), }) }) }) describe('startSprintRunAction — guards', () => { it('weigert wanneer Sprint niet ACTIVE is', async () => { mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' }) 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: 'OPEN', 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: 'OPEN' }) 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' }) }) })