import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn(), }), })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' }, })) vi.mock('@/lib/product-access', () => ({ productAccessFilter: vi.fn().mockReturnValue({}), getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), })) vi.mock('@/lib/rate-limit', () => ({ enforceUserRateLimit: vi.fn().mockReturnValue(null), })) vi.mock('@/lib/code-server', () => ({ createWithCodeRetry: vi.fn(), generateNextSprintCode: vi.fn(), })) vi.mock('@/lib/active-sprint', () => ({ setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), })) vi.mock('@/lib/prisma', () => { const txClient = { sprint: { create: vi.fn() }, story: { updateMany: vi.fn() }, task: { updateMany: vi.fn() }, } return { prisma: { sprint: { findFirst: vi.fn() }, story: { findMany: vi.fn(), updateMany: vi.fn(), }, task: { findMany: vi.fn(), updateMany: vi.fn(), }, $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), __txClient: txClient, }, } }) import { prisma } from '@/lib/prisma' import { commitSprintMembershipAction } from '@/actions/sprints' type Mocked = { sprint: { findFirst: ReturnType } story: { findMany: ReturnType updateMany: ReturnType } task: { findMany: ReturnType updateMany: ReturnType } $transaction: ReturnType __txClient: { sprint: { create: ReturnType } story: { updateMany: ReturnType } task: { updateMany: ReturnType } } } const mockPrisma = prisma as unknown as Mocked beforeEach(() => { vi.clearAllMocks() mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ id: 'sprint-active', product_id: 'product-1', }) mockPrisma.story.findMany.mockReset() mockPrisma.story.updateMany.mockReset() mockPrisma.task.findMany.mockReset() mockPrisma.task.updateMany.mockReset() mockPrisma.$transaction.mockImplementation( async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => fn(mockPrisma.__txClient), ) mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 }) mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 }) }) describe('commitSprintMembershipAction', () => { it('happy path: eligible adds + valid removes → transactie commits', async () => { // adds-partition: alle eligible (sprint_id=null + niet DONE) mockPrisma.story.findMany // partition lookup .mockResolvedValueOnce([ { id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null }, ]) // removes-filter (sprint_id == activeSprintId) .mockResolvedValueOnce([{ id: 's-rem-1' }]) // affectedStories .mockResolvedValueOnce([ { pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }, ]) mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) const result = await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: ['s-add-1'], removes: ['s-rem-1'], }) expect('success' in result).toBe(true) if ('success' in result) { expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1']) expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) expect(result.affectedTaskIds).toEqual(['t1']) expect(result.conflicts.notEligible).toEqual([]) expect(result.conflicts.alreadyRemoved).toEqual([]) } expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2) expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2) }) it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([ { id: 's-done', sprint_id: null, status: 'DONE', sprint: null }, ]) // removes-filter (geen removes) .mockResolvedValueOnce([]) const result = await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: ['s-done'], removes: [], }) expect('success' in result).toBe(true) if ('success' in result) { expect(result.affectedStoryIds).toEqual([]) expect(result.conflicts.notEligible).toEqual([ { storyId: 's-done', reason: 'DONE' }, ]) } // Geen transaction omdat er niets te commiten valt. expect(mockPrisma.$transaction).not.toHaveBeenCalled() }) it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([ { id: 's-elsewhere', sprint_id: 'sprint-other', status: 'IN_SPRINT', sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, }, ]) .mockResolvedValueOnce([]) const result = await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: ['s-elsewhere'], removes: [], }) if ('success' in result) { expect(result.conflicts.notEligible).toEqual([ { storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' }, ]) } }) it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => { mockPrisma.story.findMany // adds-partition (geen adds) .mockResolvedValueOnce([]) // removes-filter — race scenario: story zit niet meer in active sprint .mockResolvedValueOnce([]) const result = await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: [], removes: ['s-was-removed'], }) if ('success' in result) { expect(result.affectedStoryIds).toEqual([]) expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed']) } }) it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([ { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, ]) .mockResolvedValueOnce([{ id: 's-rem' }]) .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) mockPrisma.task.findMany.mockResolvedValueOnce([]) await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: ['s-add'], removes: ['s-rem'], }) const calls = mockPrisma.__txClient.story.updateMany.mock.calls // Add: status=IN_SPRINT + sprint_id=sprint-active expect(calls[0][0].data).toEqual({ sprint_id: 'sprint-active', status: 'IN_SPRINT', }) // Remove: status=OPEN + sprint_id=null expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' }) }) it('task.sprint_id wordt in dezelfde transactie ge-update', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([ { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, ]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) mockPrisma.task.findMany.mockResolvedValueOnce([]) await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: ['s-add'], removes: [], }) expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( expect.objectContaining({ where: { story_id: { in: ['s-add'] } }, data: { sprint_id: 'sprint-active' }, }), ) }) it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([ { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, ]) .mockResolvedValueOnce([{ id: 's-rem' }]) .mockResolvedValueOnce([ { pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }, ]) mockPrisma.task.findMany.mockResolvedValueOnce([ { id: 't1' }, { id: 't2' }, ]) const result = await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: ['s-add'], removes: ['s-rem'], }) expect(result).toMatchObject({ success: true, affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']), affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']), affectedTaskIds: expect.arrayContaining(['t1', 't2']), }) }) it('rejects when sprint is not accessible', async () => { mockPrisma.sprint.findFirst.mockResolvedValue(null) const result = await commitSprintMembershipAction({ activeSprintId: 'sprint-active', adds: [], removes: [], }) expect('error' in result).toBe(true) if ('error' in result) { expect(result.code).toBe(403) } }) })