diff --git a/__tests__/actions/commit-sprint-membership.test.ts b/__tests__/actions/commit-sprint-membership.test.ts new file mode 100644 index 0000000..af80547 --- /dev/null +++ b/__tests__/actions/commit-sprint-membership.test.ts @@ -0,0 +1,290 @@ +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) + } + }) +}) diff --git a/actions/sprints.ts b/actions/sprints.ts index 7e01145..78fe7ce 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -54,6 +54,147 @@ export type CreateSprintWithSelectionResult = } | { error: string; code: number } +const commitSprintMembershipSchema = z.object({ + activeSprintId: z.string().min(1), + adds: z.array(z.string()), + removes: z.array(z.string()), +}) + +export type CommitSprintMembershipInput = z.infer< + typeof commitSprintMembershipSchema +> + +type CommitConflicts = { + notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] + alreadyRemoved: string[] +} + +export type CommitSprintMembershipResult = + | { + success: true + affectedStoryIds: string[] + affectedPbiIds: string[] + affectedTaskIds: string[] + conflicts: CommitConflicts + } + | { error: string; code: number } + +export async function commitSprintMembershipAction( + input: CommitSprintMembershipInput, +): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd', code: 403 } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + + const parsed = commitSprintMembershipSchema.safeParse(input) + if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } + + // Sprint moet bestaan en bereikbaar zijn via product-access. + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.activeSprintId, + product: productAccessFilter(session.userId), + }, + select: { id: true, product_id: true }, + }) + if (!sprint) { + return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 } + } + + // Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN + // sprint → conflicts.notEligible + crossSprint). + const addPartition = await partitionByEligibility( + prisma, + parsed.data.adds, + parsed.data.activeSprintId, + ) + const eligibleAdds = addPartition.eligible + const notEligibleAdds = addPartition.notEligible + + // Race-safety voor removes: alleen stories die feitelijk in de actieve + // sprint zitten worden verwijderd. + const removeRows = + parsed.data.removes.length > 0 + ? await prisma.story.findMany({ + where: { + id: { in: parsed.data.removes }, + sprint_id: parsed.data.activeSprintId, + }, + select: { id: true }, + }) + : [] + const validRemoves = removeRows.map((r) => r.id) + const validRemoveSet = new Set(validRemoves) + const alreadyRemoved = parsed.data.removes.filter( + (id) => !validRemoveSet.has(id), + ) + + if (eligibleAdds.length === 0 && validRemoves.length === 0) { + // Geen werk te doen — geef toch een success-shape terug zodat de client + // pending buffer kan resetten + conflicts kan tonen. + return { + success: true, + affectedStoryIds: [], + affectedPbiIds: [], + affectedTaskIds: [], + conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, + } + } + + await prisma.$transaction(async (tx) => { + if (eligibleAdds.length > 0) { + await tx.story.updateMany({ + where: { id: { in: eligibleAdds } }, + data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' }, + }) + await tx.task.updateMany({ + where: { story_id: { in: eligibleAdds } }, + data: { sprint_id: parsed.data.activeSprintId }, + }) + } + if (validRemoves.length > 0) { + await tx.story.updateMany({ + where: { id: { in: validRemoves } }, + data: { sprint_id: null, status: 'OPEN' }, + }) + await tx.task.updateMany({ + where: { story_id: { in: validRemoves } }, + data: { sprint_id: null }, + }) + } + }) + + const affectedStoryIds = [...eligibleAdds, ...validRemoves] + const affectedStories = + affectedStoryIds.length > 0 + ? await prisma.story.findMany({ + where: { id: { in: affectedStoryIds } }, + select: { pbi_id: true }, + }) + : [] + const affectedPbiIds = Array.from( + new Set(affectedStories.map((s) => s.pbi_id)), + ) + const affectedTasks = + affectedStoryIds.length > 0 + ? await prisma.task.findMany({ + where: { story_id: { in: affectedStoryIds } }, + select: { id: true }, + }) + : [] + const affectedTaskIds = affectedTasks.map((t) => t.id) + + revalidatePath(`/products/${sprint.product_id}`, 'layout') + + return { + success: true, + affectedStoryIds, + affectedPbiIds, + affectedTaskIds, + conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, + } +} + export async function createSprintWithSelectionAction( input: CreateSprintWithSelectionInput, ): Promise { diff --git a/stores/product-workspace/store.ts b/stores/product-workspace/store.ts index 54955bb..b815b37 100644 --- a/stores/product-workspace/store.ts +++ b/stores/product-workspace/store.ts @@ -120,6 +120,15 @@ interface Actions { excludeSprintId: string | null, pbiIds: string[], ): Promise + + // PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in + // de client-store hebben geen sprint_id-veld dus alleen story-records + // worden gemuteerd. + applyMembershipCommitResult(input: { + activeSprintId: string + addedStoryIds: string[] + removedStoryIds: string[] + }): void } export type ProductWorkspaceStore = State & Actions @@ -667,6 +676,33 @@ export const useProductWorkspaceStore = create()( } }) }, + + applyMembershipCommitResult({ + activeSprintId, + addedStoryIds, + removedStoryIds, + }) { + // Task-records in de client-store hebben geen sprint_id-veld (alleen + // story_id); de sprint-membership wordt afgeleid via story.sprint_id. + // Hier patchen we daarom alleen story-entities + de pending buffer. + set((s) => { + for (const id of addedStoryIds) { + const story = s.entities.storiesById[id] + if (story) { + story.sprint_id = activeSprintId + story.status = 'IN_SPRINT' + } + } + for (const id of removedStoryIds) { + const story = s.entities.storiesById[id] + if (story) { + story.sprint_id = null + story.status = 'OPEN' + } + } + s.sprintMembership.pending = { adds: [], removes: [] } + }) + }, })), )