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', user_id: 'user-1', }), })) vi.mock('@/lib/rate-limit', () => ({ enforceUserRateLimit: vi.fn().mockReturnValue(null), })) vi.mock('@/lib/code-server', () => ({ createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')), generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'), })) 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: { create: vi.fn(), findFirst: vi.fn(), update: vi.fn(), }, story: { findMany: vi.fn(), updateMany: vi.fn(), }, task: { findMany: vi.fn(), updateMany: vi.fn(), }, pbi: { findMany: vi.fn() }, user: { findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), __txClient: txClient, }, } }) import { prisma } from '@/lib/prisma' import { createSprintWithSelectionAction, type CreateSprintWithSelectionInput, } from '@/actions/sprints' type Mocked = { sprint: { create: ReturnType findFirst: ReturnType update: 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 function baseInput( overrides: Partial = {}, ): CreateSprintWithSelectionInput { return { productId: 'product-1', metadata: { goal: 'Sprint 1' }, pbiIntent: {}, storyOverrides: {}, ...overrides, } } beforeEach(() => { vi.clearAllMocks() mockPrisma.sprint.create.mockReset() 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.sprint.create .mockReset() .mockResolvedValue({ id: 'sprint-1', code: 'SP-1' }) mockPrisma.__txClient.story.updateMany .mockReset() .mockResolvedValue({ count: 0 }) mockPrisma.__txClient.task.updateMany .mockReset() .mockResolvedValue({ count: 0 }) }) describe('createSprintWithSelectionAction', () => { it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => { // Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch. mockPrisma.story.findMany // resolve step (only for pbis with intent='all') .mockResolvedValueOnce([ { id: 's1', pbi_id: 'pbiA' }, { id: 's2', pbi_id: 'pbiA' }, { id: 's3', pbi_id: 'pbiA' }, ]) // partitionByEligibility — alle eligible .mockResolvedValueOnce([ { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, { id: 's3', sprint_id: null, status: 'OPEN', sprint: null }, ]) // affectedStories .mockResolvedValueOnce([ { pbi_id: 'pbiA' }, { pbi_id: 'pbiA' }, ]) mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) const result = await createSprintWithSelectionAction( baseInput({ pbiIntent: { pbiA: 'all' }, storyOverrides: { pbiA: { add: [], remove: ['s2'] } }, }), ) expect('success' in result).toBe(true) if ('success' in result) { expect(result.affectedStoryIds).toEqual(['s1', 's3']) expect(result.conflicts.notEligible).toEqual([]) } }) it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => { // Geen PBI met intent=all → stap 1 wordt niet uitgevoerd. mockPrisma.story.findMany // partition .mockResolvedValueOnce([ { id: 's10', sprint_id: null, status: 'OPEN', sprint: null }, ]) // affectedStories .mockResolvedValueOnce([{ pbi_id: 'pbiB' }]) mockPrisma.task.findMany.mockResolvedValueOnce([]) const result = await createSprintWithSelectionAction( baseInput({ pbiIntent: { pbiB: 'none' }, storyOverrides: { pbiB: { add: ['s10'], remove: [] } }, }), ) expect('success' in result).toBe(true) if ('success' in result) { expect(result.affectedStoryIds).toEqual(['s10']) } }) it('eligibility-filter classificeert DONE en cross-sprint stories', async () => { mockPrisma.story.findMany // resolve .mockResolvedValueOnce([ { id: 's1', pbi_id: 'pbiA' }, { id: 's2', pbi_id: 'pbiA' }, { id: 's3', pbi_id: 'pbiA' }, ]) // partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint .mockResolvedValueOnce([ { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, { id: 's3', sprint_id: 'sprint-other', status: 'IN_SPRINT', sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, }, ]) // affectedStories .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) mockPrisma.task.findMany.mockResolvedValueOnce([]) const result = await createSprintWithSelectionAction( baseInput({ pbiIntent: { pbiA: 'all' } }), ) expect('success' in result).toBe(true) if ('success' in result) { expect(result.affectedStoryIds).toEqual(['s2']) expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual( ['s1', 's3'], ) expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3']) } }) it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) .mockResolvedValueOnce([ { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, ]) .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) await createSprintWithSelectionAction( baseInput({ pbiIntent: { pbiA: 'all' } }), ) expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ sprint_id: 'sprint-1', status: 'IN_SPRINT', }), }), ) expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( expect.objectContaining({ data: { sprint_id: 'sprint-1' }, }), ) }) it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([ { id: 's1', pbi_id: 'pbiA' }, { id: 's2', pbi_id: 'pbiB' }, ]) .mockResolvedValueOnce([ { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, ]) .mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }]) mockPrisma.task.findMany.mockResolvedValueOnce([ { id: 't1' }, { id: 't2' }, ]) const result = await createSprintWithSelectionAction( baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }), ) expect('success' in result).toBe(true) if ('success' in result) { expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2']) expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2']) } }) it('returnt error wanneer geen eligible stories overblijven', async () => { mockPrisma.story.findMany .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) // s1 is DONE → notEligible .mockResolvedValueOnce([ { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, ]) const result = await createSprintWithSelectionAction( baseInput({ pbiIntent: { pbiA: 'all' } }), ) expect('error' in result).toBe(true) if ('error' in result) { expect(result.code).toBe(422) } }) })