actions/sprints.ts:
- Nieuwe commitSprintMembershipAction(activeSprintId, adds[], removes[]).
- Eligibility-filter voor adds via partitionByEligibility (sprint_id IS NULL
en niet DONE; cross-sprint conflicts → notEligible).
- Race-safety voor removes: alleen stories met huidige sprint_id ==
activeSprintId; rest → conflicts.alreadyRemoved.
- Transactie wrapt twee updateMany-paren (story status mee, task.sprint_id
cascade). Update-paren overgeslagen wanneer leeg.
- Return: { success, affectedStoryIds, affectedPbiIds, affectedTaskIds,
conflicts: { notEligible, alreadyRemoved } }.
stores/product-workspace/store.ts:
- applyMembershipCommitResult({ activeSprintId, addedStoryIds,
removedStoryIds }) patcht entities.storiesById met juiste sprint_id +
status; ledigt sprintMembership.pending. Geen task-veld omdat
BacklogTask geen sprint_id-kolom heeft in de store.
Tests: __tests__/actions/commit-sprint-membership.test.ts (8 cases) — happy
path, DONE-conflict, cross-sprint, race-safety voor removes, transactie-
inhoud (status='IN_SPRINT'/'OPEN'), task-cascade, return-shape, auth-fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
290 lines
9 KiB
TypeScript
290 lines
9 KiB
TypeScript
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<typeof vi.fn> }
|
|
story: {
|
|
findMany: ReturnType<typeof vi.fn>
|
|
updateMany: ReturnType<typeof vi.fn>
|
|
}
|
|
task: {
|
|
findMany: ReturnType<typeof vi.fn>
|
|
updateMany: ReturnType<typeof vi.fn>
|
|
}
|
|
$transaction: ReturnType<typeof vi.fn>
|
|
__txClient: {
|
|
sprint: { create: ReturnType<typeof vi.fn> }
|
|
story: { updateMany: ReturnType<typeof vi.fn> }
|
|
task: { updateMany: ReturnType<typeof vi.fn> }
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
})
|
|
})
|