feat(PBI-79): Product Backlog sprint-membership via vinkjes (#190)
* feat(PBI-79/ST-1333): active-sprint null-contract + clearActiveSprintAction
- lib/user-settings.ts: activeSprints values nullable in Zod-schema.
Key-aanwezigheid heeft nu betekenis (key+null = bewust geen sprint;
key ontbreekt = fallback-cascade).
- lib/active-sprint.ts: nieuwe readStoredActiveSprintState helper +
resolveActiveSprint respecteert expliciet 'cleared' state zonder fallback.
clearActiveSprintInSettings schrijft null i.p.v. de key te verwijderen.
- actions/active-sprint.ts: nieuwe clearActiveSprintAction met auth +
membership-check.
- components/shared/sprint-switcher.tsx: '— Geen actieve sprint —'-optie
in dropdown, disabled wanneer er geen actieve sprint is.
- Tests: nieuwe active-sprint.test.ts (resolver-paden + clear),
active-sprint-action.test.ts (action-laag), uitbreiding user-settings.test.ts.
Plan: docs/plans/PBI-79-backlog-sprint-workflow.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot
- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met
compacte intent-shape (pbiIntent + per-PBI storyOverrides).
- actions/sprint-draft.ts: setPendingSprintDraftAction +
clearPendingSprintDraftAction met product-membership-check + Zod-validatie.
- stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft
optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride.
Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests
zonder DATABASE_URL niet falen.
- Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding
user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1343): sprint-conflicts helper-library
- lib/sprint-conflicts.ts: drie pure/server-side helpers voor eligibility
+ cross-sprint detectie.
- isEligibleForSprint(story): sprint_id IS NULL en status != DONE
- partitionByEligibility(prisma, storyIds, excludeSprintId): split in
eligible / notEligible / crossSprint met reden per story
- getBlockingSprintMap(prisma, productId, storyIds, excludeSprintId):
map storyId → { sprintId, sprintName } voor stories in andere OPEN sprint
- Tests: __tests__/lib/sprint-conflicts.test.ts (16 cases) — alle eligibility
paden + cross-sprint scoping + CLOSED-sprint filtering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1335): sprint-membership-summary + cross-sprint-blocks endpoints
Twee nieuwe GET-route handlers, beide verplicht gescoped op pbiIds (geen
product-brede aanroepen).
- app/api/products/[id]/sprint-membership-summary/route.ts
Response: { [pbiId]: { total, inSprint } } via twee prisma.groupBy calls
(totaal + binnen actieve sprint). Voor state-B tri-state.
- app/api/products/[id]/cross-sprint-blocks/route.ts
Response: { [storyId]: { sprintId, sprintName } } voor stories in andere
OPEN sprints. UX-hint voor disabled-vinkjes; commit-acties blijven
autoritatief.
Tests: 13 cases dekken happy path, 400 zonder pbiIds, 400 zonder sprintId,
404 zonder product-access, auth-fail, en NOT-clause voor excludeSprintId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1336): product-workspace sprint-membership slice + selectors
Datalaag voor de vinkje-UI van state A′ en state B.
types.ts:
- PbiSummaryEntry, CrossSprintBlock, SprintMembershipSlice toegevoegd.
store.ts:
- Nieuwe slice `sprintMembership` met pbiSummary, crossSprintBlocks,
pending: { adds[], removes[] }, loadedSummaryForSprintId.
- Acties: setPbiSummary, setCrossSprintBlocks, toggleStorySprintMembership
(cancel-out logic), resetSprintMembershipPending, fetchSprintMembershipSummary,
fetchCrossSprintBlocks.
- hydrateSnapshot reset óók de membership-slice.
selectors.ts:
- selectPbiTriState (aggregate-only zolang stories niet geladen; rekent
pending mee bij loaded PBI's).
- selectStoryEffectiveInSprint (DB ⊕ pending).
- selectStoryIsBlocked (cross-sprint hint).
- selectIsDirty, selectPendingCount.
Tests: 25 cases in nieuwe sprint-membership.test.ts dekken alle selector-
paden, toggle-cancel-out, fetch-helpers, en pbiId-scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1337): state A′ UI — metadata dialog + sticky banner + PbiList ombouw
UI-laag voor de sprint-definitie-flow (state A′).
Nieuw:
- NewSprintMetadataDialog (stap 1): sprint_goal + optionele dates;
'Verder' schrijft via useUserSettingsStore.setPendingSprintDraft.
- SprintDefinitionBanner (sticky): toont doel + X PBI's / Y stories teller;
'Annuleren' → AlertDialog confirm → clearPendingSprintDraft;
'Sprint aanmaken' nog niet aangesloten (wacht op ST-1339).
- NewSprintTrigger: button in page header die de metadata-dialog opent;
verbergt zichzelf zolang er al een draft loopt.
- SprintDraftBanner: client-wrapper, rendert banner alleen als draft bestaat.
Wijzigingen:
- lib/user-settings.ts: pendingSprintDraft startAt/endAt → z.string().date().
- PbiList: oude selectionMode + selectedIds + NewSprintDialog vervangen door
hasDraft-afgeleide A′-mode met tri-state vinkjes; togglen muteert
upsertPbiIntent('all'|'none') en wist storyOverrides per PBI.
- StoryPanel: in A′-mode toont elke story een cherrypick-checkbox die
upsertStoryOverride('add'/'remove'/'clear') aanroept; cross-sprint-blocked
stories krijgen disabled-icoon met sprint-naam tooltip.
- app/(app)/products/[id]/page.tsx: StartSprintButton vervangen door
NewSprintTrigger; SprintDraftBanner gepositioneerd boven split-pane.
Tests: bestaande tests blijven groen (806 cases) — UI-specifieke component
tests volgen later. ST-1339 sluit createSprintWithSelectionAction aan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up
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>
* feat(PBI-79/ST-1340): commitSprintMembershipAction + gerichte client-store patches
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>
* feat(PBI-79/ST-1338): state B vinkjes-UI + 'Sprint opslaan'-knop met teller
State B (actieve sprint geselecteerd, geen draft) hangt nu aan dezelfde
vinkje-UI als state A′, maar muteert de transient pending-buffer in plaats
van de draft.
- PbiList: nieuwe prop activeSprintId. selectionMode = hasDraft ||
stateBMode. togglePbiInDraft routeert naar upsertPbiIntent (A′) of bulk-
toggleStorySprintMembership over eligible child-stories (B, skip blocked).
- StoryPanel: idem prop activeSprintId. StoryBlockWithCherrypick muteert
draft via upsertStoryOverride in A′ of pending buffer via
toggleStorySprintMembership in B (cross-sprint blocked = disabled).
- SaveSprintButton (nieuw): client component in page header, alleen
zichtbaar als er een actieve sprint is. Disabled bij clean buffer,
enabled met teller bij dirty. Klikken calls commitSprintMembershipAction
→ applyMembershipCommitResult gericht in store + toast bij conflicts.
- page.tsx: activeSprintItem.id wordt doorgegeven aan PbiList, StoryPanel
en SaveSprintButton.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1341+ST-1342): SprintEditDialog metadata-edit + multi-OPEN sprints
ST-1341 (T-946):
- actions/sprints.ts: nieuwe updateSprintAction(sprintId, fields) — JSON
input, accepteert optionele goal/startAt/endAt; auth + product-access
check, prisma.sprint.update, revalidatePath. Type-safe return.
- components/backlog/sprint-edit-dialog.tsx: Entity-Dialog-pattern voor
metadata-edit van een sprint. Velden: sprint_goal, start_date, end_date.
Link 'Sprint afronden… →' naar bestaande /products/[id]/sprint/[sprintId]
zodat de completion-flow (per-story DONE/OPEN beslissing + PBI-promotie)
niet wordt geduplicereerd. useDirtyCloseGuard.
ST-1342 (T-947):
- actions/sprints.ts: OPEN-uniqueness check in createSprintAction
verwijderd. Een product mag nu meerdere OPEN sprints tegelijk hebben;
cross-sprint-conflicts per story worden afgevangen door
partitionByEligibility in de membership-commit-flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(PBI-79/ST-1344): updateSprintAction regression coverage
Audits van de geplande non-regressie-tests laten zien dat alle invarianten
uit het ST-1344 plan reeds gedekt zijn door eerder toegevoegde tests:
- clearActiveSprintAction null-not-delete → __tests__/lib/active-sprint.test.ts
+ __tests__/actions/active-sprint-action.test.ts
- Endpoints rejecten zonder pbiIds (400) → __tests__/api/sprint-membership-summary.test.ts
+ __tests__/api/cross-sprint-blocks.test.ts
- Status-mutaties story.status=IN_SPRINT/OPEN met task.sprint_id cascade
in dezelfde transactie → __tests__/actions/create-sprint-with-selection.test.ts
+ __tests__/actions/commit-sprint-membership.test.ts
- Cross-sprint conflicts + DONE-eligibility → __tests__/lib/sprint-conflicts.test.ts
Nieuw: __tests__/actions/update-sprint.test.ts (6 cases) dekt
updateSprintAction die nog geen tests had — goal alleen, dates alleen,
null-clear, 403 zonder access, lege goal weigering, leeg fields-object
weigering.
Handmatige E2E checklist (T-949) blijft staan voor menselijke browser-
validatie tijdens PR-review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): PBI-rij selecteert weer in A′/B-modus; vinkje is aparte trigger
Voor PBI-79 maakte het hele PBI-kaartje in selectionMode (state A′ én B)
de toggle. Daardoor:
- klik op rij = bulk-toggle stories (teller liep op);
- geen setActivePbi, dus StoryPanel kreeg geen content.
Fix: in selectionMode wordt onClick = onSelect (PBI activeren → stories
laden) en de tri-state-iconen verhuizen naar een eigen <button> in de
actions-slot met stopPropagation. Toggle gedrag (bulk add/remove in B,
upsertPbiIntent in A′) blijft ongewijzigd via die knop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): cascade-restore alleen als hint-story bij nieuwe PBI hoort
Bug: setActivePbi reset activeStoryId/activeTaskId, maar het cascade-
restore-pad zette daarna een hint-story actief zonder te valideren of die
story bij de nieuw-geselecteerde PBI hoort. Bij PBI-switch bleef daardoor
de task-kolom de taken van de vorige story tonen.
Fix: alleen setActiveStory(hint) als entities.storiesById[hint].pbi_id ===
pbiId. Bij mismatch blijft activeStoryId null en is de task-kolom leeg
totdat de gebruiker een story uit de nieuwe PBI kiest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): sprint-switch auto-select PBI/story + user-settings persist
Bij sprint-switch wordt de sprint-content server-side opgevraagd. Wanneer
de sprint precies één PBI (en die PBI exact één story binnen de sprint)
heeft, worden PBI en story automatisch geselecteerd. Alle drie keuzes
(sprint, pbi, story) worden atomair in user-settings opgeslagen zodat ze
cross-device blijven hangen.
- lib/user-settings.ts: layout krijgt nullable activePbis +
activeStories per product.
- lib/active-sprint.ts: setActiveSelectionInSettings schrijft de drie
keys atomair + notify pg_notify.
- actions/active-sprint.ts: switchActiveSprintAction(productId, sprintId)
doet de server-side auto-select-resolutie (single PBI → single story)
en returnt { sprintId, pbiId, storyId }.
- components/shared/sprint-switcher.tsx: handleSwitchSprint roept de
nieuwe action aan en synchroniseert de workspace-store gelijk zodat
de UI geen flash krijgt voor de SSR-refresh.
- components/backlog/active-selection-hydrator.tsx (nieuw): client-side
effect dat user-settings.activePbis/activeStories naar workspace-store
spiegelt; wint van de localStorage hint-restore.
- app/(app)/products/[id]/page.tsx: ActiveSelectionHydrator gemount
binnen BacklogHydrationWrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-79): plan-update met implementatie-stand + scope-aanpassing
Documenteert wat er sinds de eerste implementatie-pass is gebeurd:
- Tabel van 14 commits met hun rol.
- Twee bugs die tijdens testen boven kwamen (PBI-rij-klik, cascade-restore).
- Nieuwe feature sprint-switch auto-select (server resolveert single-PBI/
single-story; user-settings persist).
En kondigt scope-aanpassing aan voor de volgende implementatie-ronde:
- pendingSprintDraft wordt session-only (geen server-persist meer).
- useDirtyCloseGuard wist draft op leave-with-confirm.
- Sprint-switcher krijgt concept-entry zolang er een draft loopt.
De rest van het plan beneden blijft van kracht behalve waar deze sectie
het overruled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard
Scope-aanpassing uit plan-revisie: drafts persisten niet meer server-side.
Wijzigingen:
- stores/user-settings/store.ts:
- hydrate() strip nu workflow.pendingSprintDraft uit serverstate
(legacy DB-entries blijven harmless aanwezig maar worden niet
gehydreerd → effectief unreachable voor de UI).
- setPendingSprintDraft / clearPendingSprintDraft worden lokale-only;
geen import van sprint-draft-actions, geen server-roundtrip.
- upsertPbiIntent / upsertStoryOverride blijven via setPendingSprintDraft
routeren → ook session-only.
- components/shared/sprint-switcher.tsx: leest draft-goal uit user-settings
store en toont '⚙ Concept — [goal]' als niet-selecteerbare entry
bovenaan de dropdown zolang er een draft loopt.
- components/backlog/sprint-draft-leave-guard.tsx (nieuw): registreert
een beforeunload-listener zolang er een draft is. Browser-refresh,
tab-close en back-navigatie tonen daarmee de standaard confirm. In-app
route-changes blijven via de banner-Annuleren-knop lopen.
- app/(app)/products/[id]/page.tsx: SprintDraftLeaveGuard gemount naast
de banner.
- Tests: user-settings store-tests aangepast (geen server-call assert
meer, hydrate strip-assert toegevoegd; upsert-tests seed nu via
setPendingSprintDraft i.p.v. legacy hydrate).
setPendingSprintDraftAction + clearPendingSprintDraftAction blijven bestaan
voor eventuele toekomstige opruim-flows, maar worden niet meer aangeroepen
vanuit de UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-79): mark scope-aanpassing afgerond + localStorage overzicht
- Drie open punten uit plan-revisie afgevinkt (commit 2a4ee6a).
- Sectie 'Bewust niet geïmplementeerd': server-persist van manuele
PBI/story-klikken — op vraag van user nu out-of-scope voor deze PR.
- Tabel localStorage-gebruik in de codebase voor toekomstige referentie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bf7162a5fc
commit
d587be2fb3
39 changed files with 5404 additions and 133 deletions
103
__tests__/actions/active-sprint-action.test.ts
Normal file
103
__tests__/actions/active-sprint-action.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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({}),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
sprint: { findFirst: vi.fn() },
|
||||
product: { findFirst: vi.fn() },
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { clearActiveSprintAction } from '@/actions/active-sprint'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
user: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
describe('clearActiveSprintAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes null instead of deleting the key', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||
settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } },
|
||||
})
|
||||
|
||||
const result = await clearActiveSprintAction('p1')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
|
||||
}
|
||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
||||
p1: null,
|
||||
p2: 'sprint-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves other product keys when clearing one', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||
settings: {
|
||||
layout: {
|
||||
activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await clearActiveSprintAction('p1')
|
||||
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
|
||||
}
|
||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
||||
p1: null,
|
||||
p2: 'sprint-2',
|
||||
p3: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when product is not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await clearActiveSprintAction('p1')
|
||||
|
||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects invalid productId', async () => {
|
||||
const result = await clearActiveSprintAction('')
|
||||
|
||||
expect(result).toEqual({ error: 'Ongeldig product-id' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
|
|
@ -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<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)
|
||||
}
|
||||
})
|
||||
})
|
||||
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
167
__tests__/actions/sprint-draft.test.ts
Normal file
167
__tests__/actions/sprint-draft.test.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
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({}),
|
||||
}))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn() },
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
clearPendingSprintDraftAction,
|
||||
setPendingSprintDraftAction,
|
||||
} from '@/actions/sprint-draft'
|
||||
import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
user: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
const validDraft: PendingSprintDraft = {
|
||||
goal: 'Sprint 1',
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
storyOverrides: { pbiA: { add: [], remove: ['story-1'] } },
|
||||
}
|
||||
|
||||
describe('setPendingSprintDraftAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findFirst.mockReset()
|
||||
mockPrisma.user.findUnique.mockReset()
|
||||
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('persists draft for accessible product', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
||||
|
||||
const result = await setPendingSprintDraftAction('p1', validDraft)
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({
|
||||
goal: 'Sprint 1',
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves drafts for other products', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||
settings: {
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await setPendingSprintDraftAction('p1', validDraft)
|
||||
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
const drafts = updateArg.data.settings.workflow?.pendingSprintDraft
|
||||
expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2']))
|
||||
})
|
||||
|
||||
it('rejects invalid draft (empty goal)', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
|
||||
const result = await setPendingSprintDraftAction('p1', {
|
||||
...validDraft,
|
||||
goal: '',
|
||||
} as PendingSprintDraft)
|
||||
|
||||
expect(result).toHaveProperty('error')
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when product not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await setPendingSprintDraftAction('p1', validDraft)
|
||||
|
||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearPendingSprintDraftAction', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findFirst.mockReset()
|
||||
mockPrisma.user.findUnique.mockReset()
|
||||
mockPrisma.user.update.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('removes draft key for product', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({
|
||||
settings: {
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} },
|
||||
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await clearPendingSprintDraftAction('p1')
|
||||
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({
|
||||
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
|
||||
})
|
||||
})
|
||||
|
||||
it('is a no-op when there is no draft for the product', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
|
||||
|
||||
const result = await clearPendingSprintDraftAction('p1')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockPrisma.user.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when product not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
|
||||
|
||||
const result = await clearPendingSprintDraftAction('p1')
|
||||
|
||||
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
|
||||
})
|
||||
})
|
||||
148
__tests__/actions/update-sprint.test.ts
Normal file
148
__tests__/actions/update-sprint.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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', () => ({
|
||||
prisma: {
|
||||
sprint: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { updateSprintAction } from '@/actions/sprints'
|
||||
|
||||
type Mocked = {
|
||||
sprint: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
const mockPrisma = prisma as unknown as Mocked
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
|
||||
id: 'sprint-1',
|
||||
product_id: 'product-1',
|
||||
})
|
||||
mockPrisma.sprint.update.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('updateSprintAction', () => {
|
||||
it('updates sprint_goal alone', async () => {
|
||||
const result = await updateSprintAction({
|
||||
sprintId: 'sprint-1',
|
||||
fields: { goal: 'Nieuw doel' },
|
||||
})
|
||||
|
||||
expect('success' in result).toBe(true)
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sprint-1' },
|
||||
data: { sprint_goal: 'Nieuw doel' },
|
||||
})
|
||||
})
|
||||
|
||||
it('updates dates only', async () => {
|
||||
await updateSprintAction({
|
||||
sprintId: 'sprint-1',
|
||||
fields: { startAt: '2026-06-01', endAt: '2026-06-14' },
|
||||
})
|
||||
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sprint-1' },
|
||||
data: {
|
||||
start_date: new Date('2026-06-01'),
|
||||
end_date: new Date('2026-06-14'),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts null to clear a date', async () => {
|
||||
await updateSprintAction({
|
||||
sprintId: 'sprint-1',
|
||||
fields: { startAt: null },
|
||||
})
|
||||
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sprint-1' },
|
||||
data: { start_date: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when sprint not accessible', async () => {
|
||||
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
||||
|
||||
const result = await updateSprintAction({
|
||||
sprintId: 'sprint-1',
|
||||
fields: { goal: 'x' },
|
||||
})
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) {
|
||||
expect(result.code).toBe(403)
|
||||
}
|
||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects empty goal', async () => {
|
||||
const result = await updateSprintAction({
|
||||
sprintId: 'sprint-1',
|
||||
fields: { goal: '' },
|
||||
})
|
||||
|
||||
expect('error' in result).toBe(true)
|
||||
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects when no fields are supplied', async () => {
|
||||
const result = await updateSprintAction({
|
||||
sprintId: 'sprint-1',
|
||||
fields: {},
|
||||
})
|
||||
|
||||
// Schema-refine should reject; OR action treats empty data as no-op success.
|
||||
// Current implementation: refine forces minstens één veld → 422 error.
|
||||
expect('error' in result).toBe(true)
|
||||
if ('error' in result) {
|
||||
expect(result.code).toBe(422)
|
||||
}
|
||||
})
|
||||
})
|
||||
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn() },
|
||||
story: { findMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => ({
|
||||
authenticateApiRequest: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
story: { findMany: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeRequest(url: string) {
|
||||
return new Request(url)
|
||||
}
|
||||
|
||||
describe('GET /api/products/[id]/cross-sprint-blocks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findFirst.mockReset()
|
||||
mockPrisma.story.findMany.mockReset()
|
||||
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
|
||||
})
|
||||
|
||||
it('returns blocking sprint info per story for happy path', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'story-1',
|
||||
sprint: { id: 'sprint-x', code: 'SP-X' },
|
||||
},
|
||||
{
|
||||
id: 'story-2',
|
||||
sprint: { id: 'sprint-y', code: 'SP-Y' },
|
||||
},
|
||||
])
|
||||
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({
|
||||
'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
|
||||
'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when pbiIds is missing', async () => {
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects when pbiIds is empty', async () => {
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when product is not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns auth error when authenticate fails', async () => {
|
||||
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('passes NOT excludeSprintId to prisma when provided', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([])
|
||||
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA',
|
||||
)
|
||||
await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
|
||||
const callArg = mockPrisma.story.findMany.mock.calls[0][0] as {
|
||||
where: Record<string, unknown>
|
||||
}
|
||||
expect(callArg.where).toMatchObject({
|
||||
pbi_id: { in: ['pbiA'] },
|
||||
product_id: 'p1',
|
||||
sprint_id: { not: null },
|
||||
NOT: { sprint_id: 'sp-active' },
|
||||
sprint: { status: 'OPEN' },
|
||||
})
|
||||
})
|
||||
})
|
||||
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
product: { findFirst: vi.fn() },
|
||||
story: { groupBy: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api-auth', () => ({
|
||||
authenticateApiRequest: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/product-access', () => ({
|
||||
productAccessFilter: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { authenticateApiRequest } from '@/lib/api-auth'
|
||||
import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findFirst: ReturnType<typeof vi.fn> }
|
||||
story: { groupBy: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
||||
|
||||
function makeRequest(url: string) {
|
||||
return new Request(url)
|
||||
}
|
||||
|
||||
describe('GET /api/products/[id]/sprint-membership-summary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.product.findFirst.mockReset()
|
||||
mockPrisma.story.groupBy.mockReset()
|
||||
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
|
||||
})
|
||||
|
||||
it('returns counts per PBI for happy path', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||
mockPrisma.story.groupBy
|
||||
.mockResolvedValueOnce([
|
||||
{ pbi_id: 'pbiA', _count: { _all: 5 } },
|
||||
{ pbi_id: 'pbiB', _count: { _all: 3 } },
|
||||
])
|
||||
.mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }])
|
||||
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({
|
||||
pbiA: { total: 5, inSprint: 2 },
|
||||
pbiB: { total: 3, inSprint: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects when pbiIds is missing', async () => {
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects when pbiIds is empty', async () => {
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('rejects when sprintId is missing', async () => {
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when product is not accessible', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue(null)
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns auth error when authenticate fails', async () => {
|
||||
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns zero counts for PBIs without stories', async () => {
|
||||
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
|
||||
mockPrisma.story.groupBy
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([])
|
||||
|
||||
const req = makeRequest(
|
||||
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
|
||||
)
|
||||
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
|
||||
|
||||
const body = await res.json()
|
||||
expect(body).toEqual({
|
||||
pbiA: { total: 0, inSprint: 0 },
|
||||
pbiB: { total: 0, inSprint: 0 },
|
||||
})
|
||||
})
|
||||
})
|
||||
190
__tests__/lib/active-sprint.test.ts
Normal file
190
__tests__/lib/active-sprint.test.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
sprint: { findFirst: vi.fn() },
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { UserSettings } from '@/lib/user-settings'
|
||||
import {
|
||||
clearActiveSprintInSettings,
|
||||
readStoredActiveSprintState,
|
||||
resolveActiveSprint,
|
||||
} from '@/lib/active-sprint'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||
user: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$executeRaw: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function withSettings(settings: UserSettings) {
|
||||
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings })
|
||||
}
|
||||
|
||||
describe('readStoredActiveSprintState', () => {
|
||||
it('returns unset when activeSprints map is absent', () => {
|
||||
expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' })
|
||||
})
|
||||
|
||||
it('returns unset when productId key is absent', () => {
|
||||
const settings: UserSettings = {
|
||||
layout: { activeSprints: { p2: 'sprint-2' } },
|
||||
}
|
||||
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
||||
kind: 'unset',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns cleared when key is present with null value', () => {
|
||||
const settings: UserSettings = {
|
||||
layout: { activeSprints: { p1: null } },
|
||||
}
|
||||
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
||||
kind: 'cleared',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns set when key is present with string value', () => {
|
||||
const settings: UserSettings = {
|
||||
layout: { activeSprints: { p1: 'sprint-1' } },
|
||||
}
|
||||
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
|
||||
kind: 'set',
|
||||
sprintId: 'sprint-1',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveActiveSprint', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null without fallback when key is explicitly null (cleared)', async () => {
|
||||
withSettings({ layout: { activeSprints: { p1: null } } })
|
||||
|
||||
const result = await resolveActiveSprint('p1', 'user-1')
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the stored sprint when key is set and sprint exists', async () => {
|
||||
withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } })
|
||||
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
|
||||
id: 'sprint-1',
|
||||
code: 'SP-1',
|
||||
status: 'OPEN',
|
||||
})
|
||||
|
||||
const result = await resolveActiveSprint('p1', 'user-1')
|
||||
|
||||
expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' })
|
||||
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('falls back when stored sprint is not found in DB', async () => {
|
||||
withSettings({ layout: { activeSprints: { p1: 'stale-id' } } })
|
||||
mockPrisma.sprint.findFirst
|
||||
.mockResolvedValueOnce(null) // stored lookup misses
|
||||
.mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' })
|
||||
|
||||
const result = await resolveActiveSprint('p1', 'user-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'sprint-open',
|
||||
code: 'SP-O',
|
||||
status: 'OPEN',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to first OPEN sprint when key is absent', async () => {
|
||||
withSettings({})
|
||||
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
|
||||
id: 'sprint-open',
|
||||
code: 'SP-O',
|
||||
status: 'OPEN',
|
||||
})
|
||||
|
||||
const result = await resolveActiveSprint('p1', 'user-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'sprint-open',
|
||||
code: 'SP-O',
|
||||
status: 'OPEN',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to recent CLOSED sprint when no OPEN exists', async () => {
|
||||
withSettings({})
|
||||
mockPrisma.sprint.findFirst
|
||||
.mockResolvedValueOnce(null) // no OPEN
|
||||
.mockResolvedValueOnce({
|
||||
id: 'sprint-closed',
|
||||
code: 'SP-C',
|
||||
status: 'CLOSED',
|
||||
})
|
||||
|
||||
const result = await resolveActiveSprint('p1', 'user-1')
|
||||
|
||||
expect(result).toEqual({
|
||||
id: 'sprint-closed',
|
||||
code: 'SP-C',
|
||||
status: 'CLOSED',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when key absent and no sprints exist', async () => {
|
||||
withSettings({})
|
||||
mockPrisma.sprint.findFirst.mockResolvedValue(null)
|
||||
|
||||
const result = await resolveActiveSprint('p1', 'user-1')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearActiveSprintInSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('writes null instead of deleting the key', async () => {
|
||||
withSettings({
|
||||
layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } },
|
||||
})
|
||||
|
||||
await clearActiveSprintInSettings('user-1', 'p1')
|
||||
|
||||
expect(mockPrisma.user.update).toHaveBeenCalledTimes(1)
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
|
||||
p1: null,
|
||||
p2: 'sprint-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('adds the key with null when previously unset', async () => {
|
||||
withSettings({})
|
||||
|
||||
await clearActiveSprintInSettings('user-1', 'p1')
|
||||
|
||||
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
|
||||
data: { settings: UserSettings }
|
||||
}
|
||||
expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null })
|
||||
})
|
||||
})
|
||||
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { StoryStatus } from '@prisma/client'
|
||||
|
||||
import {
|
||||
getBlockingSprintMap,
|
||||
isEligibleForSprint,
|
||||
partitionByEligibility,
|
||||
} from '@/lib/sprint-conflicts'
|
||||
|
||||
function mockPrisma(stories: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
story: {
|
||||
findMany: vi.fn().mockResolvedValue(stories),
|
||||
},
|
||||
} as unknown as Parameters<typeof partitionByEligibility>[0]
|
||||
}
|
||||
|
||||
describe('isEligibleForSprint', () => {
|
||||
it('returns true for OPEN story without sprint', () => {
|
||||
expect(
|
||||
isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => {
|
||||
expect(
|
||||
isEligibleForSprint({
|
||||
sprint_id: null,
|
||||
status: 'IN_SPRINT' as StoryStatus,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for DONE story without sprint', () => {
|
||||
expect(
|
||||
isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when story is in any sprint (open status)', () => {
|
||||
expect(
|
||||
isEligibleForSprint({
|
||||
sprint_id: 'abc',
|
||||
status: 'OPEN' as StoryStatus,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when story is in any sprint (done status)', () => {
|
||||
expect(
|
||||
isEligibleForSprint({
|
||||
sprint_id: 'abc',
|
||||
status: 'DONE' as StoryStatus,
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('partitionByEligibility', () => {
|
||||
it('returns empty partition for empty input', async () => {
|
||||
const prisma = mockPrisma([])
|
||||
const result = await partitionByEligibility(prisma, [])
|
||||
expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] })
|
||||
})
|
||||
|
||||
it('classifies all eligible when stories are free + OPEN', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
|
||||
{ id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null },
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1', 's2'])
|
||||
expect(result.eligible).toEqual(['s1', 's2'])
|
||||
expect(result.notEligible).toEqual([])
|
||||
expect(result.crossSprint).toEqual([])
|
||||
})
|
||||
|
||||
it('marks DONE stories as notEligible with reason=DONE', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.eligible).toEqual([])
|
||||
expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }])
|
||||
})
|
||||
|
||||
it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-other',
|
||||
status: 'IN_SPRINT',
|
||||
sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.crossSprint).toEqual([
|
||||
{ storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' },
|
||||
])
|
||||
expect(result.notEligible).toEqual([
|
||||
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
|
||||
])
|
||||
expect(result.eligible).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: null,
|
||||
status: 'OPEN',
|
||||
sprint: null,
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.eligible).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark crossSprint for stories in CLOSED other sprint', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-closed',
|
||||
status: 'DONE',
|
||||
sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' },
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'])
|
||||
expect(result.crossSprint).toEqual([])
|
||||
expect(result.notEligible).toEqual([
|
||||
{ storyId: 's1', reason: 'IN_OTHER_SPRINT' },
|
||||
])
|
||||
})
|
||||
|
||||
it('respects excludeSprintId — story in same sprint is eligible', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-active',
|
||||
status: 'IN_SPRINT',
|
||||
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active')
|
||||
expect(result.eligible).toEqual(['s1'])
|
||||
expect(result.crossSprint).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBlockingSprintMap', () => {
|
||||
it('returns empty map for empty input', async () => {
|
||||
const prisma = mockPrisma([])
|
||||
const result = await getBlockingSprintMap(prisma, 'p1', [])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns blocking sprint info for stories in OPEN sprints', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-x',
|
||||
sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
|
||||
expect(result.get('s1')).toEqual({
|
||||
sprintId: 'sprint-x',
|
||||
sprintName: 'SP-X',
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes the active sprint from blocking', async () => {
|
||||
const prisma = mockPrisma([
|
||||
{
|
||||
id: 's1',
|
||||
sprint_id: 'sprint-active',
|
||||
sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' },
|
||||
},
|
||||
])
|
||||
const result = await getBlockingSprintMap(
|
||||
prisma,
|
||||
'p1',
|
||||
['s1'],
|
||||
'sprint-active',
|
||||
)
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it('does not include CLOSED sprints (filtered at DB query level)', async () => {
|
||||
// The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories
|
||||
// are already filtered out before reaching this function's mapping logic.
|
||||
const prisma = mockPrisma([])
|
||||
const result = await getBlockingSprintMap(prisma, 'p1', ['s1'])
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -122,4 +122,65 @@ describe('UserSettingsSchema', () => {
|
|||
layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } },
|
||||
}).success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts null values in activeSprints (explicit "no active sprint")', () => {
|
||||
const result = UserSettingsSchema.safeParse({
|
||||
layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.layout?.activeSprints).toEqual({
|
||||
'product-1': null,
|
||||
'product-2': 'sprint-2',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts pendingSprintDraft with per-PBI intent and overrides', () => {
|
||||
const result = UserSettingsSchema.safeParse({
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'Sprint goal',
|
||||
pbiIntent: { pbiA: 'all', pbiB: 'none' },
|
||||
storyOverrides: {
|
||||
pbiA: { add: [], remove: ['story-1'] },
|
||||
pbiB: { add: ['story-2'], remove: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('fills empty defaults for pbiIntent and storyOverrides in draft', () => {
|
||||
const result = UserSettingsSchema.safeParse({
|
||||
workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } },
|
||||
})
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
const draft = result.data.workflow?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.pbiIntent).toEqual({})
|
||||
expect(draft?.storyOverrides).toEqual({})
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects pendingSprintDraft with empty goal', () => {
|
||||
const result = UserSettingsSchema.safeParse({
|
||||
workflow: { pendingSprintDraft: { 'p': { goal: '' } } },
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown intent value', () => {
|
||||
const result = UserSettingsSchema.safeParse({
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
p: { goal: 'x', pbiIntent: { a: 'partial' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import {
|
||||
selectIsDirty,
|
||||
selectPbiTriState,
|
||||
selectPendingCount,
|
||||
selectStoryEffectiveInSprint,
|
||||
selectStoryIsBlocked,
|
||||
} from '@/stores/product-workspace/selectors'
|
||||
import type { BacklogStory } from '@/stores/product-workspace/types'
|
||||
|
||||
function resetMembership() {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.entities.storiesById = {}
|
||||
s.relations.storyIdsByPbi = {}
|
||||
s.sprintMembership = {
|
||||
pbiSummary: {},
|
||||
crossSprintBlocks: {},
|
||||
pending: { adds: [], removes: [] },
|
||||
loadedSummaryForSprintId: null,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory {
|
||||
return {
|
||||
id,
|
||||
code: id,
|
||||
title: id,
|
||||
description: null,
|
||||
acceptance_criteria: null,
|
||||
priority: 2,
|
||||
sort_order: 1,
|
||||
status: sprintId ? 'IN_SPRINT' : 'OPEN',
|
||||
pbi_id: pbiId,
|
||||
sprint_id: sprintId,
|
||||
created_at: new Date('2026-01-01'),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetMembership()
|
||||
})
|
||||
|
||||
describe('toggleStorySprintMembership', () => {
|
||||
it('adds storyId to pending.adds when currently not in sprint', () => {
|
||||
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false)
|
||||
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||
expect(pending.adds).toEqual(['s1'])
|
||||
expect(pending.removes).toEqual([])
|
||||
})
|
||||
|
||||
it('adds storyId to pending.removes when currently in sprint', () => {
|
||||
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true)
|
||||
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||
expect(pending.removes).toEqual(['s1'])
|
||||
expect(pending.adds).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => {
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
store.toggleStorySprintMembership('s1', false) // adds
|
||||
// Story now appears to be "in sprint" via pending; calling with true should cancel
|
||||
store.toggleStorySprintMembership('s1', false) // second click with same baseline
|
||||
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||
expect(pending.adds).toEqual([])
|
||||
expect(pending.removes).toEqual([])
|
||||
})
|
||||
|
||||
it('removes from pending.removes when toggled back', () => {
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
store.toggleStorySprintMembership('s1', true)
|
||||
store.toggleStorySprintMembership('s1', true)
|
||||
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||
expect(pending.removes).toEqual([])
|
||||
expect(pending.adds).toEqual([])
|
||||
})
|
||||
|
||||
it('resetSprintMembershipPending empties both arrays', () => {
|
||||
const store = useProductWorkspaceStore.getState()
|
||||
store.toggleStorySprintMembership('s1', false)
|
||||
store.toggleStorySprintMembership('s2', true)
|
||||
store.resetSprintMembershipPending()
|
||||
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
|
||||
expect(pending.adds).toEqual([])
|
||||
expect(pending.removes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectPbiTriState', () => {
|
||||
function seedSummary(pbiId: string, total: number, inSprint: number) {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.sprintMembership.pbiSummary[pbiId] = {
|
||||
totalStoryCount: total,
|
||||
inActiveSprintStoryCount: inSprint,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('returns empty for PBI without summary', () => {
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns empty when totalStoryCount == 0', () => {
|
||||
seedSummary('pbi-1', 0, 0)
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('empty')
|
||||
})
|
||||
|
||||
it('returns full when all stories in sprint (no pending)', () => {
|
||||
seedSummary('pbi-1', 3, 3)
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('full')
|
||||
})
|
||||
|
||||
it('returns partial when some stories in sprint', () => {
|
||||
seedSummary('pbi-1', 3, 2)
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('partial')
|
||||
})
|
||||
|
||||
it('returns empty when inSprint == 0', () => {
|
||||
seedSummary('pbi-1', 3, 0)
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('empty')
|
||||
})
|
||||
|
||||
it('applies pending adds when stories are loaded for the PBI', () => {
|
||||
seedSummary('pbi-1', 3, 1)
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
|
||||
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1')
|
||||
s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null)
|
||||
s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null)
|
||||
s.sprintMembership.pending.adds = ['s2', 's3']
|
||||
})
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('full')
|
||||
})
|
||||
|
||||
it('applies pending removes when stories are loaded for the PBI', () => {
|
||||
seedSummary('pbi-1', 3, 3)
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
|
||||
s.sprintMembership.pending.removes = ['s2']
|
||||
})
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('partial')
|
||||
})
|
||||
|
||||
it('ignores pending entries for stories of other PBIs', () => {
|
||||
seedSummary('pbi-1', 3, 3)
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
|
||||
s.sprintMembership.pending.removes = ['s99'] // not in pbi-1
|
||||
})
|
||||
expect(
|
||||
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
|
||||
).toBe('full')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStoryEffectiveInSprint', () => {
|
||||
it('returns true when story.sprint_id matches activeSprintId and no pending', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
|
||||
})
|
||||
expect(
|
||||
selectStoryEffectiveInSprint(
|
||||
useProductWorkspaceStore.getState(),
|
||||
's1',
|
||||
'sprint-A',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when story.sprint_id is null', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
|
||||
})
|
||||
expect(
|
||||
selectStoryEffectiveInSprint(
|
||||
useProductWorkspaceStore.getState(),
|
||||
's1',
|
||||
'sprint-A',
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when story in pending.adds even if DB says no', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
|
||||
s.sprintMembership.pending.adds = ['s1']
|
||||
})
|
||||
expect(
|
||||
selectStoryEffectiveInSprint(
|
||||
useProductWorkspaceStore.getState(),
|
||||
's1',
|
||||
'sprint-A',
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when story in pending.removes even if DB says yes', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
|
||||
s.sprintMembership.pending.removes = ['s1']
|
||||
})
|
||||
expect(
|
||||
selectStoryEffectiveInSprint(
|
||||
useProductWorkspaceStore.getState(),
|
||||
's1',
|
||||
'sprint-A',
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when activeSprintId is null', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
|
||||
})
|
||||
expect(
|
||||
selectStoryEffectiveInSprint(
|
||||
useProductWorkspaceStore.getState(),
|
||||
's1',
|
||||
null,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectStoryIsBlocked', () => {
|
||||
it('returns null when no block', () => {
|
||||
expect(
|
||||
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns block info when story is in another sprint', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.sprintMembership.crossSprintBlocks['s1'] = {
|
||||
sprintId: 'sprint-x',
|
||||
sprintName: 'SP-X',
|
||||
}
|
||||
})
|
||||
expect(
|
||||
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
|
||||
).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectIsDirty + selectPendingCount', () => {
|
||||
it('clean by default', () => {
|
||||
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false)
|
||||
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0)
|
||||
})
|
||||
|
||||
it('counts adds + removes', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.sprintMembership.pending = {
|
||||
adds: ['a1', 'a2'],
|
||||
removes: ['r1'],
|
||||
}
|
||||
})
|
||||
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true)
|
||||
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetch helpers', () => {
|
||||
it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const responseBody = {
|
||||
pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 },
|
||||
}
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), { status: 200 }),
|
||||
) as unknown as typeof fetch
|
||||
try {
|
||||
await useProductWorkspaceStore
|
||||
.getState()
|
||||
.fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA'])
|
||||
|
||||
const slice = useProductWorkspaceStore.getState().sprintMembership
|
||||
expect(slice.pbiSummary.pbiA).toEqual({
|
||||
totalStoryCount: 5,
|
||||
inActiveSprintStoryCount: 2,
|
||||
})
|
||||
expect(slice.loadedSummaryForSprintId).toBe('sprint-A')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('fetchCrossSprintBlocks populates store', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const responseBody = {
|
||||
's1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
|
||||
}
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(responseBody), { status: 200 }),
|
||||
) as unknown as typeof fetch
|
||||
try {
|
||||
await useProductWorkspaceStore
|
||||
.getState()
|
||||
.fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA'])
|
||||
|
||||
const slice = useProductWorkspaceStore.getState().sprintMembership
|
||||
expect(slice.crossSprintBlocks['s1']).toEqual({
|
||||
sprintId: 'sprint-x',
|
||||
sprintName: 'SP-X',
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => {
|
||||
const fetchSpy = vi.fn()
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch
|
||||
try {
|
||||
await useProductWorkspaceStore
|
||||
.getState()
|
||||
.fetchSprintMembershipSummary('prod-1', 'sprint-A', [])
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -56,6 +56,12 @@ function resetStore() {
|
|||
s.sync.lastResyncAt = null
|
||||
s.sync.resyncReason = null
|
||||
s.pendingMutations = {}
|
||||
s.sprintMembership = {
|
||||
pbiSummary: {},
|
||||
crossSprintBlocks: {},
|
||||
pending: { adds: [], removes: [] },
|
||||
loadedSummaryForSprintId: null,
|
||||
}
|
||||
Object.assign(s, originalActions)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,21 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const updateAction = vi.fn()
|
||||
const setDraftAction = vi.fn()
|
||||
const clearDraftAction = vi.fn()
|
||||
|
||||
vi.mock('@/actions/user-settings', () => ({
|
||||
updateUserSettingsAction: (...args: unknown[]) => updateAction(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/actions/sprint-draft', () => ({
|
||||
setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args),
|
||||
clearPendingSprintDraftAction: (...args: unknown[]) =>
|
||||
clearDraftAction(...args),
|
||||
}))
|
||||
|
||||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||||
import type { PendingSprintDraft } from '@/lib/user-settings'
|
||||
|
||||
function resetStore() {
|
||||
useUserSettingsStore.setState((s) => {
|
||||
|
|
@ -20,6 +29,8 @@ function resetStore() {
|
|||
beforeEach(() => {
|
||||
resetStore()
|
||||
updateAction.mockReset()
|
||||
setDraftAction.mockReset()
|
||||
clearDraftAction.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -85,6 +96,130 @@ describe('useUserSettingsStore', () => {
|
|||
expect(updateAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
|
||||
const draft: PendingSprintDraft = {
|
||||
goal: 'Sprint 1',
|
||||
pbiIntent: { pbiA: 'all' },
|
||||
storyOverrides: {},
|
||||
}
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.setPendingSprintDraft('product-1', draft)
|
||||
|
||||
const s = useUserSettingsStore.getState()
|
||||
expect(
|
||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||
).toMatchObject({ goal: 'Sprint 1' })
|
||||
expect(setDraftAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{
|
||||
workflow: {
|
||||
pendingSprintDraft: {
|
||||
'product-1': {
|
||||
goal: 'Legacy draft',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
const s = useUserSettingsStore.getState()
|
||||
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||
goal: 'Old',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {},
|
||||
})
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.clearPendingSprintDraft('product-1')
|
||||
|
||||
const s = useUserSettingsStore.getState()
|
||||
expect(
|
||||
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
|
||||
).toBeUndefined()
|
||||
expect(clearDraftAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||
goal: 'g',
|
||||
pbiIntent: { pbiA: 'none' },
|
||||
storyOverrides: {
|
||||
pbiA: { add: ['s-1'], remove: [] },
|
||||
pbiB: { add: [], remove: ['s-2'] },
|
||||
},
|
||||
})
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.upsertPbiIntent('product-1', 'pbiA', 'all')
|
||||
|
||||
const draft =
|
||||
useUserSettingsStore.getState().entities.settings.workflow
|
||||
?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.pbiIntent.pbiA).toBe('all')
|
||||
expect(draft?.storyOverrides.pbiA).toBeUndefined()
|
||||
expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] })
|
||||
})
|
||||
|
||||
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||
goal: 'g',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {
|
||||
pbiA: { add: [], remove: ['story-1'] },
|
||||
},
|
||||
})
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add')
|
||||
|
||||
const draft =
|
||||
useUserSettingsStore.getState().entities.settings.workflow
|
||||
?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.storyOverrides.pbiA).toEqual({
|
||||
add: ['story-1'],
|
||||
remove: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
|
||||
useUserSettingsStore.getState().hydrate({}, false)
|
||||
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
|
||||
goal: 'g',
|
||||
pbiIntent: {},
|
||||
storyOverrides: {
|
||||
pbiA: { add: ['story-1'], remove: [] },
|
||||
},
|
||||
})
|
||||
|
||||
await useUserSettingsStore
|
||||
.getState()
|
||||
.upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear')
|
||||
|
||||
const draft =
|
||||
useUserSettingsStore.getState().entities.settings.workflow
|
||||
?.pendingSprintDraft?.['product-1']
|
||||
expect(draft?.storyOverrides.pbiA).toBeUndefined()
|
||||
})
|
||||
|
||||
it('applyServerPatch merges without optimistic state', () => {
|
||||
useUserSettingsStore.getState().hydrate(
|
||||
{ views: { sprintBacklog: { sort: 'code' } } },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue