actions/sprints.ts:
- Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent,
storyOverrides).
- Server-side intent-resolve:
1. Voor elke PBI met intent='all': fetch child-story-IDs minus
storyOverrides[pbi].remove.
2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan).
- Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status
!= DONE; stories in andere OPEN sprint → conflicts.crossSprint).
- Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') +
task.updateMany (sprint_id cascade) — alles atomair.
- setActiveSprintInSettings na success.
- Return: { success, sprintId, affectedStoryIds, affectedPbiIds,
affectedTaskIds, conflicts: { notEligible, crossSprint } } of error.
components/backlog/sprint-definition-banner.tsx:
- 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction;
toast bij conflicts, success-toast anders, router.refresh() voor SSR
cycle. Pending draft wordt door de action zelf nog niet expliciet gewist
— dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden.
Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases)
dekken intent-resolve, override-respect, cross-sprint conflict, transactie-
binding van story.status + task.sprint_id, return-shape, en error-pad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
300 lines
9.1 KiB
TypeScript
300 lines
9.1 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',
|
|
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<typeof vi.fn>
|
|
findFirst: ReturnType<typeof vi.fn>
|
|
update: 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
|
|
|
|
function baseInput(
|
|
overrides: Partial<CreateSprintWithSelectionInput> = {},
|
|
): 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)
|
|
}
|
|
})
|
|
})
|