feat(PBI-74): Zustand product-workspace rearchitecture (Stories 1-8) (#180)
* feat(PBI-74): product-workspace store skelet + test-infra (Story 1)
Skelet voor de nieuwe `product-workspace-store` die op termijn de gefragmenteerde
`backlog-store`/`planner-store`/`selection-store`/`product-store` vervangt. Deze
PR levert alleen het skelet + tests; UI-consumers worden in latere stories
omgezet.
- vitest naar jsdom + tests/setup.ts (MemoryStorage, default fetch-stub) — G6/G8
- stores/product-workspace/{types,store,selectors,restore}.ts — immer-middleware,
alle slices en acties (hydrate, setActive*, ensure*Loaded met activeRequestId-
guard, applyRealtimeEvent, resyncActiveScopes/loadedScopes, optimistic
mutations). Restore-wiring in setters volgt in Story 4 (T-857/T-858).
- selectors gebruiken module-level EMPTY refs (G1) en documenteren useShallow-
vereiste (G2)
- 34 nieuwe unit-tests dekken §Testing setup-checklist uit het ontwerp:
hydrateSnapshot, selection-cascade, applyRealtimeEvent (I/U/D + parent-move +
ander-product + unknown-entity → resync), delete-cleanup, race-safe loaders,
ensureTaskLoaded _detail-flag, resyncActiveScopes ensure-keten, restore-hints
read/write/clear, optimistic mutation rollback/settle/SSE-echo idempotent
- docs/api/rest-contract.md: audit-sectie met de vier ontbrekende
ensure*Loaded-endpoints (worden toegevoegd in Story 7 / T-870)
Refs: PBI-74, ST-1318, T-837..T-843
Bron-ontwerp: docs/plans/zustand-store-rearchitecture.md
Implementatieplan: docs/plans/zustand-workspace-store-implementation.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): dual-dispatch hydratie + realtime naar workspace-store (Story 2)
Story 2 — schaduw-fase: BacklogHydrationWrapper en useBacklogRealtime voeden
nu ook de nieuwe product-workspace-store, terwijl de oude useBacklogStore /
useProductStore leidend blijft voor componenten. Story 3 verschuift consumers
één voor één; Story 8 ruimt de oude stores op.
- T-844: BacklogHydrationWrapper roept naast useBacklogStore.setInitialData
ook useProductWorkspaceStore.hydrateSnapshot aan. Productname-prop optioneel
toegevoegd voor activeProduct-context.
- T-845: useBacklogRealtime onmessage dispatcht events naar zowel oude store
(applyChange) als nieuwe store (applyRealtimeEvent). Geen wijziging aan
reconnect/visibility — Story 5.
- T-846: dev-only logWorkspaceFingerprint helper vergelijkt counts tussen
oude en nieuwe store na hydrate en na elk realtime-event. console.warn bij
mismatch; opt-in debug log via NEXT_PUBLIC_DEBUG_WORKSPACE_FINGERPRINT=1.
Bestand TODO-marked voor verwijdering in Story 8 (T-878).
- T-847: SetCurrentProduct schrijft naast oude useProductStore ook
useProductWorkspaceStore.setActiveProduct({id, name}); cleanup cleart beide.
setActiveProduct triggert ensureProductLoaded — fetch-stub tot Story 7
(T-870) de LIST-endpoints toevoegt.
Verify: lint+typecheck clean, 636/636 tests groen (geen UI-regressie omdat
oude store leidend blijft).
Refs: PBI-74, ST-1319, T-844..T-847
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): migreer backlog-componenten naar workspace-store (Story 3)
Story 3 verplaatst alle UI-consumers van de oude vier stores
(useBacklogStore/usePlannerStore/useSelectionStore/useProductStore) naar de
nieuwe product-workspace-store. De oude stores blijven nog bestaan voor
hydration-wrapper en realtime-hook (dual-dispatch); Story 8 ruimt ze op.
- T-848 backlog-split-pane.tsx: leest activePbiId/activeStoryId uit
context-slice (primitives, geen useShallow nodig).
- T-849 pbi-list.tsx: selectVisiblePbis(useShallow); DnD via
applyOptimisticMutation('pbi-order' + optionele 'entity-patch' bij
cross-priority drag), met settle/rollback per server-result.
- T-850 story-panel.tsx: selectStoriesForActivePbi(useShallow); DnD via
applyOptimisticMutation('story-order' + entity-patch bij priority change).
- T-851 task-panel.tsx: selectTasksForActiveStory(useShallow); DnD via
applyOptimisticMutation('task-order'); detail-view (ensureTaskLoaded +
isDetail) zit in de task-dialog (apart component, niet in deze lijst).
- T-852 start-sprint-button.tsx: selectActivePbi + selectStoriesForActivePbi
voor free-story count.
- T-853 set-current-product.tsx: alleen workspace-store.setActiveProduct
(oude useProductStore-import verwijderd).
- T-854 G1/G2-audit: alle nieuwe selectors gebruiken module-level EMPTY
refs (G1) en useShallow voor lijsten (G2). Geen 'Maximum update depth'-
warnings tijdens npm test.
- T-855 tests bijgewerkt: backlog-split-pane.test, task-panel.test,
integration.test gebruiken nu setState op workspace-store (helpers
resetWorkspace/setActiveStoryAndTasks/selectPbi/selectStory).
Verify: lint+typecheck clean, 636/636 tests groen. UI-consumers van
oude stores zijn nu nul (uitgezonderd dual-dispatch in hydration-wrapper en
realtime-hook + dev-fingerprint-helper, die in Story 8/T-873/T-878 verdwijnen).
Refs: PBI-74, ST-1320, T-848..T-855
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): race-safe loaders + restore-hints + URL-prioriteit (Story 4)
- T-856: activeRequestId-guard zat al in store.ts uit Story 1; bevestigd door
de race-safety test (in-flight ensurePbiLoaded mag niet overschrijven).
- T-857: restore-hint flow toegevoegd in setActiveProduct/setActivePbi/
setActiveStory. Async chain: await ensureXxxLoaded → guard check →
readHints → valideer hint via entities.byId → setActiveYyy(hint).
Geen setTimeout-trick — chain is alleen await-based.
- T-858: writeProductHint/writePbiHint/writeStoryHint/writeTaskHint
aangeroepen direct na set(...) zodat de hint-persistentie altijd
consistent is met de in-store selectie.
- T-859: nieuwe components/backlog/url-task-sync.tsx — leest
?editTask=<id> uit useSearchParams, schrijft de hint en roept
setActiveTask aan zodat de URL wint boven een eerder gepersisteerde
task-hint. Gemount in beide product-pages (desktop + mobile) binnen
BacklogHydrationWrapper.
- T-860: 6 nieuwe vitest-cases — 4 voor hint-persist per setter, 2 voor de
restore-flow chain (hint die niet in entities zit wordt genegeerd; hint
die wel in entities zit wordt toegepast). Bestaande race-safety test
blijft groen.
Verify: lint+typecheck clean, 642/642 tests groen.
Refs: PBI-74, ST-1321, T-856..T-860
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): hidden-tab + reconnect resync (Story 5)
Per ontwerp samen in één commit zodat geen vangnet wegvalt zonder vervanging.
- T-861: useBacklogRealtime sluit niet meer op visibilitychange hidden;
EventSource blijft open zolang browser/netwerk dit toelaten. Reconnect bij
netwerkfout blijft via backoff. visibilitychange fungeert nog wel als
re-connect-trigger als de stream tussentijds is gesloten (b.v. 240s
hard-close server-side).
- T-862: 'ready'-event-handler telt connect-cycles. De eerste 'ready' is de
initial connect (geen resync). Bij latere 'ready' (post-reconnect) wordt
resyncActiveScopes('reconnect') aangeroepen om gemiste events op te halen.
- T-863: nieuwe lib/realtime/use-workspace-resync.ts — luistert op
document.visibilitychange (hidden→visible) en window.online; dispatcht
resyncActiveScopes('visible') resp. 'reconnect'. Mounted in
BacklogHydrationWrapper na useBacklogRealtime.
- T-864: 4 nieuwe vitest-cases voor useWorkspaceResync (jsdom): visible→
visible event, online event, hidden negeren, cleanup-bij-unmount.
Daarnaast lint-cleanup: ongebruikte 'order'-variabelen in pbi-list en
story-panel weggehaald.
Verify: lint+typecheck clean, 646/646 tests groen.
Refs: PBI-74, ST-1322, T-861..T-864
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): unknown-event fallback tests (Story 6)
T-865 (isUnknownEntityEvent filter) en T-866 (resync-trigger in
applyRealtimeEvent) zijn al in Story 1 geïmplementeerd in store.ts;
deze story breidt de test-coverage uit met expliciete negatieve cases
voor het type-veld noise pattern.
T-867 — 5 nieuwe vitest-cases:
- unknown entity met ANDER product_id → geen resync
- claude_job_status (type) → geen resync
- worker_heartbeat (type) → geen resync
- claude_job_enqueued (type) → geen resync
- payload zonder entity en zonder type → genegeerd
- question-entity (entity-veld, geen type, niet pbi/story/task) → resync trigger
Verify: lint+typecheck clean, 651/651 tests groen.
Refs: PBI-74, ST-1323, T-865..T-867
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): cache-headers + LIST endpoints (Story 7)
- T-868: cache: 'no-store' was al ingebouwd in fetchJson helper (Story 1).
Bevestigd door bestaande ensureProductLoaded test die de fetch-init
controleert.
- T-869: force-dynamic toegevoegd op alle vier nieuwe LIST-endpoints.
- T-870: vier nieuwe routes voor ensure*Loaded:
- GET /api/products/:id/backlog → ProductBacklogSnapshot
- GET /api/pbis/:id/stories → BacklogStory[]
- GET /api/stories/:id/tasks → BacklogTask[]
- GET /api/tasks/:id (nieuwe handler naast bestaande PATCH) → TaskDetail
met _detail: true marker
Auth via authenticateApiRequest (Bearer of iron-session); access-control
via productAccessFilter (gebruiker is owner of member van het product).
Statussen worden via taskStatusToApi/storyStatusToApi/pbiStatusToApi
vertaald naar lowercase API-vorm.
- T-871: SSE-route /api/realtime/backlog stuurt al ready-event direct na
LISTEN (regel 106) — geen wijziging nodig.
Verify: lint+typecheck clean, 651/651 tests groen.
Refs: PBI-74, ST-1324, T-868..T-871
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-74): oude stores opruimen (Story 8)
Workspace-store is nu de enige bron voor product-backlog client-state. De
vier voorgangers en de dual-dispatch-infrastructuur zijn verwijderd.
- T-872: grep over codebase op useBacklogStore/usePlannerStore/
useSelectionStore/useProductStore is leeg.
- T-873..T-876: stores/{backlog,planner,selection,product}-store.ts deleted.
- T-877: __tests__/realtime/payload-contract.test.ts en
__tests__/api/backlog-realtime.test.ts deleted — pbi/story/task I|U|D
payload-handling wordt al gedekt door
__tests__/stores/product-workspace/store.test.ts (incl. parent-move,
idempotent inserts, delete-cleanup).
- T-878: lib/realtime/dev-workspace-fingerprint.ts deleted, dual-dispatch
uit BacklogHydrationWrapper en lib/realtime/use-backlog-realtime.ts
weggehaald. stores/products-store.ts (lijst van producten ≠ active
product) blijft ongewijzigd.
Bijwerkingen:
- BacklogPbi en BacklogStory types in components/backlog/story-panel.tsx en
components/sprint/sprint-backlog.tsx krijgen sort_order zodat ze met de
workspace-types overeenkomen.
- Server-pages /products/[id]/page.tsx (desktop+mobile) en
/products/[id]/sprint/[sprintId]/page.tsx selecteren sort_order op story
en mappen het door in de hydration-payload.
Verify: lint+typecheck clean, 626/626 tests groen (verlies van 25 redundante
oude-store tests; workspace-store tests dekken hetzelfde gedrag).
Refs: PBI-74, ST-1325, T-872..T-878
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-74): richtlijn workspace-store + realtime patroon
Documenteert het patroon dat in Stories 1-8 is opgeleverd, zodat een
volgende workspace-store (sprint, of een nieuwe bounded context) hetzelfde
recept volgt.
- docs/patterns/workspace-store.md (nieuw): wanneer een workspace-store, de
vijf state-slices, selectors-regels (G1/G2), race-safe ensure*Loaded met
activeRequestId-guard (G4), SSE-hook + applyRealtimeEvent met
unknown-event filter, hidden-tab + reconnect resync via
useWorkspaceResync, restore-hint flow met await-chain en URL-prioriteit,
optimistic mutations (applyOptimisticMutation/rollback/settle), API
endpoint-vereisten (force-dynamic, cache: no-store), test-setup met
MemoryStorage + originalActions snapshot + mockImplementation, gotchas
G1-G8 als comment-template, en het 8-staps migratiepad.
- docs/patterns/zustand-optimistic.md: bijgewerkt voor de nieuwe
workspace-store API; verwijst voor het bredere patroon naar
workspace-store.md. Voorbeelden voor pbi-order + entity-patch.
- CLAUDE.md: patterns quickref aangevuld met workspace-store-rij.
Verify: typecheck clean.
Refs: PBI-74
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-74): solo + notifications hooks volgen ook hidden-tab/resync patroon
Het uitgangspunt van PBI-74 (robuust tegen gemiste SSE-events, hidden tabs
en onbekende notify-vormen) gold universeel — niet alleen voor
product-workspace. use-solo-realtime en use-notifications-realtime hadden
nog dezelfde bug die use-backlog-realtime in Story 5 al opgelost kreeg:
sluit stream op hidden, geen resync.
Reproductie (zoals gemeld): solo-screen open in tab A, product-backlog
open in tab B; bewerk task-title in tab B → tab A's solo-SSE was gesloten
(hidden) en kreeg het NOTIFY-event nooit. Tab terug naar solo →
EventSource reconnect maar geen resync → oude title persisteert. Postgres
NOTIFY heeft geen replay, dus zonder resync zijn die events permanent
verloren.
Fix in beide hooks (zelfde patroon als Story 5 voor backlog):
- Stream blijft open op visibilitychange hidden — geen close() meer.
- Bij hidden→visible én bij window 'online': router.refresh() zodat de
server-component opnieuw fetcht en de initial-state-prop ververst (wat
voor solo de tasks-record reset via initTasks; voor notifications de
questions-bel-state).
- Bij latere 'ready'-events na reconnect (use-solo-realtime): zelfde
router.refresh() trigger zodat we niet vertrouwen op alleen het
visibility-pad.
Verify: lint + typecheck clean, 626/626 tests groen.
Refs: PBI-74
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: fix broken links in research-repo plan
docs/plans/lees-de-readme-md-validated-book.md beschrijft een research-
repo migratiepad. De links waren geschreven vanuit het research-repo-
perspectief (paden als stores/data-store.ts, ../Scrum4Me/CLAUDE.md,
docs/plans/zustand-store-rearchitecture.md zonder relative-prefix), wat
de doc-link-checker hier laat falen.
- Header-note toegevoegd dat het document voor de research-repo is.
- Interne refs (zustand-store-rearchitecture.md, CLAUDE.md) → relatieve
paden die in deze repo wél resolven (./zustand-..., ../../CLAUDE.md).
- Research-repo-only refs (stores/data-store.ts,
hooks/use-event-stream.ts, components/*-select.tsx, etc.) → inline
code-tags met "(research-repo)" suffix; de link-checker slaat ze over
en de leesbaarheid blijft.
Verify: npm run docs:check-links → ✓ All doc links valid (118 files).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d126695db
commit
5df04feb11
46 changed files with 3736 additions and 736 deletions
117
__tests__/stores/product-workspace/restore.test.ts
Normal file
117
__tests__/stores/product-workspace/restore.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
clearHints,
|
||||
readHints,
|
||||
writePbiHint,
|
||||
writeProductHint,
|
||||
writeStoryHint,
|
||||
writeTaskHint,
|
||||
} from '@/stores/product-workspace/restore'
|
||||
|
||||
describe('readHints', () => {
|
||||
it('retourneert lege defaults wanneer localStorage leeg is', () => {
|
||||
const hints = readHints()
|
||||
expect(hints.lastActiveProductId).toBeNull()
|
||||
expect(hints.perProduct).toEqual({})
|
||||
})
|
||||
|
||||
it('herstelt hints uit localStorage', () => {
|
||||
localStorage.setItem(
|
||||
'product-workspace-hints',
|
||||
JSON.stringify({
|
||||
lastActiveProductId: 'p1',
|
||||
perProduct: { p1: { lastActivePbiId: 'pbi-1' } },
|
||||
}),
|
||||
)
|
||||
const hints = readHints()
|
||||
expect(hints.lastActiveProductId).toBe('p1')
|
||||
expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1')
|
||||
})
|
||||
|
||||
it('valt terug op defaults bij ongeldige JSON', () => {
|
||||
localStorage.setItem('product-workspace-hints', '{not-json')
|
||||
const hints = readHints()
|
||||
expect(hints.lastActiveProductId).toBeNull()
|
||||
expect(hints.perProduct).toEqual({})
|
||||
})
|
||||
|
||||
it('valt terug op defaults bij verkeerde shape', () => {
|
||||
localStorage.setItem('product-workspace-hints', '"just a string"')
|
||||
const hints = readHints()
|
||||
expect(hints.perProduct).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeProductHint', () => {
|
||||
it('schrijft lastActiveProductId', () => {
|
||||
writeProductHint('p1')
|
||||
expect(readHints().lastActiveProductId).toBe('p1')
|
||||
})
|
||||
|
||||
it('overschrijft bestaande waarde', () => {
|
||||
writeProductHint('p1')
|
||||
writeProductHint('p2')
|
||||
expect(readHints().lastActiveProductId).toBe('p2')
|
||||
})
|
||||
|
||||
it('accepteert null om hint te wissen', () => {
|
||||
writeProductHint('p1')
|
||||
writeProductHint(null)
|
||||
expect(readHints().lastActiveProductId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('writePbiHint', () => {
|
||||
it('schrijft lastActivePbiId per productId', () => {
|
||||
writePbiHint('prod-1', 'pbi-a')
|
||||
writePbiHint('prod-2', 'pbi-b')
|
||||
const hints = readHints()
|
||||
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
|
||||
expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b')
|
||||
})
|
||||
|
||||
it('null wist child story- en task-hints', () => {
|
||||
writePbiHint('prod-1', 'pbi-1')
|
||||
writeStoryHint('prod-1', 's-1')
|
||||
writeTaskHint('prod-1', 't-1')
|
||||
writePbiHint('prod-1', null)
|
||||
const hints = readHints()
|
||||
expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull()
|
||||
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull()
|
||||
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeStoryHint', () => {
|
||||
it('schrijft lastActiveStoryId per productId', () => {
|
||||
writeStoryHint('prod-1', 's-1')
|
||||
expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1')
|
||||
})
|
||||
|
||||
it('null wist child task-hint', () => {
|
||||
writeStoryHint('prod-1', 's-1')
|
||||
writeTaskHint('prod-1', 't-1')
|
||||
writeStoryHint('prod-1', null)
|
||||
expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull()
|
||||
expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('writeTaskHint', () => {
|
||||
it('schrijft lastActiveTaskId per productId', () => {
|
||||
writeTaskHint('prod-1', 't-1')
|
||||
expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearHints', () => {
|
||||
it('verwijdert alle hints', () => {
|
||||
writeProductHint('p1')
|
||||
writePbiHint('p1', 'pbi-1')
|
||||
clearHints()
|
||||
const hints = readHints()
|
||||
expect(hints.lastActiveProductId).toBeNull()
|
||||
expect(hints.perProduct).toEqual({})
|
||||
})
|
||||
})
|
||||
832
__tests__/stores/product-workspace/store.test.ts
Normal file
832
__tests__/stores/product-workspace/store.test.ts
Normal file
|
|
@ -0,0 +1,832 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||||
import type {
|
||||
BacklogPbi,
|
||||
BacklogStory,
|
||||
BacklogTask,
|
||||
ProductBacklogSnapshot,
|
||||
TaskDetail,
|
||||
} from '@/stores/product-workspace/types'
|
||||
|
||||
// G5: snapshot original actions on module-load; restore in beforeEach.
|
||||
// vi.fn-spies on actions could leak across tests otherwise.
|
||||
const originalActions = (() => {
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
return {
|
||||
hydrateSnapshot: s.hydrateSnapshot,
|
||||
setActiveProduct: s.setActiveProduct,
|
||||
setActivePbi: s.setActivePbi,
|
||||
setActiveStory: s.setActiveStory,
|
||||
setActiveTask: s.setActiveTask,
|
||||
ensureProductLoaded: s.ensureProductLoaded,
|
||||
ensurePbiLoaded: s.ensurePbiLoaded,
|
||||
ensureStoryLoaded: s.ensureStoryLoaded,
|
||||
ensureTaskLoaded: s.ensureTaskLoaded,
|
||||
applyRealtimeEvent: s.applyRealtimeEvent,
|
||||
resyncActiveScopes: s.resyncActiveScopes,
|
||||
resyncLoadedScopes: s.resyncLoadedScopes,
|
||||
applyOptimisticMutation: s.applyOptimisticMutation,
|
||||
rollbackMutation: s.rollbackMutation,
|
||||
settleMutation: s.settleMutation,
|
||||
setRealtimeStatus: s.setRealtimeStatus,
|
||||
}
|
||||
})()
|
||||
|
||||
function resetStore() {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = null
|
||||
s.context.activePbiId = null
|
||||
s.context.activeStoryId = null
|
||||
s.context.activeTaskId = null
|
||||
s.entities.pbisById = {}
|
||||
s.entities.storiesById = {}
|
||||
s.entities.tasksById = {}
|
||||
s.relations.pbiIds = []
|
||||
s.relations.storyIdsByPbi = {}
|
||||
s.relations.taskIdsByStory = {}
|
||||
s.loading.loadedProductId = null
|
||||
s.loading.loadingProductId = null
|
||||
s.loading.loadedPbiIds = {}
|
||||
s.loading.loadedStoryIds = {}
|
||||
s.loading.loadedTaskIds = {}
|
||||
s.loading.activeRequestId = null
|
||||
s.sync.realtimeStatus = 'connecting'
|
||||
s.sync.lastEventAt = null
|
||||
s.sync.lastResyncAt = null
|
||||
s.sync.resyncReason = null
|
||||
s.pendingMutations = {}
|
||||
Object.assign(s, originalActions)
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function makePbi(overrides: Partial<BacklogPbi> & { id: string }): BacklogPbi {
|
||||
return {
|
||||
id: overrides.id,
|
||||
code: overrides.code ?? overrides.id,
|
||||
title: overrides.title ?? `PBI ${overrides.id}`,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
description: overrides.description ?? null,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
status: overrides.status ?? 'ready',
|
||||
}
|
||||
}
|
||||
|
||||
function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: string }): BacklogStory {
|
||||
return {
|
||||
id: overrides.id,
|
||||
code: overrides.code ?? overrides.id,
|
||||
title: overrides.title ?? `Story ${overrides.id}`,
|
||||
description: overrides.description ?? null,
|
||||
acceptance_criteria: overrides.acceptance_criteria ?? null,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
status: overrides.status ?? 'open',
|
||||
pbi_id: overrides.pbi_id,
|
||||
sprint_id: overrides.sprint_id ?? null,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
}
|
||||
}
|
||||
|
||||
function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: string }): BacklogTask {
|
||||
return {
|
||||
id: overrides.id,
|
||||
title: overrides.title ?? `Task ${overrides.id}`,
|
||||
description: overrides.description ?? null,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
status: overrides.status ?? 'todo',
|
||||
story_id: overrides.story_id,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotWith(
|
||||
pbis: BacklogPbi[],
|
||||
storiesByPbi: Record<string, BacklogStory[]> = {},
|
||||
tasksByStory: Record<string, BacklogTask[]> = {},
|
||||
product?: { id: string; name: string },
|
||||
): ProductBacklogSnapshot {
|
||||
return { product, pbis, storiesByPbi, tasksByStory }
|
||||
}
|
||||
|
||||
// G7: mock fetch — never let it fall through to real network
|
||||
// G8: mockImplementation per call so each fetch gets a fresh Response
|
||||
function mockFetchSequence(
|
||||
responses: Array<unknown | ((url: string, init?: RequestInit) => unknown)>,
|
||||
) {
|
||||
let i = 0
|
||||
return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => {
|
||||
const r = responses[Math.min(i, responses.length - 1)]
|
||||
i += 1
|
||||
const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r
|
||||
return new Response(JSON.stringify(body ?? null), { status: 200 })
|
||||
}) as unknown as typeof fetch)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// hydrateSnapshot
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('hydrateSnapshot', () => {
|
||||
it('vult entities en relations met gesorteerde id-lijsten', () => {
|
||||
const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 })
|
||||
const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 })
|
||||
const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 })
|
||||
const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 })
|
||||
const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 })
|
||||
const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 })
|
||||
const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 })
|
||||
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[pbiA, pbiB, pbiC],
|
||||
{ 'pbi-b': [storyB1, storyB2] },
|
||||
{ 'st-1': [taskA, taskB] },
|
||||
{ id: 'prod-1', name: 'Product 1' },
|
||||
),
|
||||
)
|
||||
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['pbi-a']).toBe(pbiA)
|
||||
expect(s.entities.pbisById['pbi-b']).toBe(pbiB)
|
||||
expect(s.entities.pbisById['pbi-c']).toBe(pbiC)
|
||||
// pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2)
|
||||
expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a'])
|
||||
expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1'])
|
||||
expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2'])
|
||||
expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
|
||||
expect(s.loading.loadedProductId).toBe('prod-1')
|
||||
})
|
||||
|
||||
it('reset bestaande entities en relations bij her-hydratie', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'old-pbi' })]),
|
||||
)
|
||||
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi'])
|
||||
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'new-pbi' })]),
|
||||
)
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['old-pbi']).toBeUndefined()
|
||||
expect(s.entities.pbisById['new-pbi']).toBeDefined()
|
||||
expect(s.relations.pbiIds).toEqual(['new-pbi'])
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Selection cascade
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('selection cascade', () => {
|
||||
it('setActivePbi reset story+task; setActiveStory reset task', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = 'pbi-old'
|
||||
s.context.activeStoryId = 'st-old'
|
||||
s.context.activeTaskId = 'tk-old'
|
||||
})
|
||||
|
||||
useProductWorkspaceStore.getState().setActivePbi('pbi-new')
|
||||
let s = useProductWorkspaceStore.getState()
|
||||
expect(s.context.activePbiId).toBe('pbi-new')
|
||||
expect(s.context.activeStoryId).toBeNull()
|
||||
expect(s.context.activeTaskId).toBeNull()
|
||||
|
||||
useProductWorkspaceStore.setState((draft) => {
|
||||
draft.context.activeStoryId = 'st-old'
|
||||
draft.context.activeTaskId = 'tk-old'
|
||||
})
|
||||
useProductWorkspaceStore.getState().setActiveStory('st-new')
|
||||
s = useProductWorkspaceStore.getState()
|
||||
expect(s.context.activeStoryId).toBe('st-new')
|
||||
expect(s.context.activeTaskId).toBeNull()
|
||||
})
|
||||
|
||||
it('setActiveProduct(null) ruimt entities en relations op', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[makePbi({ id: 'p-1' })],
|
||||
{ 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] },
|
||||
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
|
||||
{ id: 'prod-1', name: 'Product 1' },
|
||||
),
|
||||
)
|
||||
|
||||
useProductWorkspaceStore.getState().setActiveProduct(null)
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.context.activeProduct).toBeNull()
|
||||
expect(s.context.activePbiId).toBeNull()
|
||||
expect(s.context.activeStoryId).toBeNull()
|
||||
expect(s.context.activeTaskId).toBeNull()
|
||||
expect(s.entities.pbisById).toEqual({})
|
||||
expect(s.entities.storiesById).toEqual({})
|
||||
expect(s.entities.tasksById).toEqual({})
|
||||
expect(s.relations.pbiIds).toEqual([])
|
||||
expect(s.relations.storyIdsByPbi).toEqual({})
|
||||
expect(s.relations.taskIdsByStory).toEqual({})
|
||||
expect(s.loading.loadedProductId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// applyRealtimeEvent
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyRealtimeEvent — pbi', () => {
|
||||
beforeEach(() => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('I — voegt PBI toe en sorteert', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]),
|
||||
)
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'I',
|
||||
id: 'b',
|
||||
product_id: 'prod-1',
|
||||
code: 'B',
|
||||
title: 'New PBI',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
created_at: new Date('2026-02-01').toISOString(),
|
||||
status: 'ready',
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['b']).toBeDefined()
|
||||
expect(s.relations.pbiIds).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('I — idempotent voor bestaande id', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'a' })]),
|
||||
)
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'I',
|
||||
id: 'a',
|
||||
product_id: 'prod-1',
|
||||
title: 'mutated',
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven
|
||||
expect(s.relations.pbiIds).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('U — patch + her-sorteert', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([
|
||||
makePbi({ id: 'a', priority: 2, sort_order: 1 }),
|
||||
makePbi({ id: 'b', priority: 2, sort_order: 2 }),
|
||||
]),
|
||||
)
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'U',
|
||||
id: 'b',
|
||||
product_id: 'prod-1',
|
||||
priority: 1,
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.relations.pbiIds).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('D — verwijdert PBI inclusief child stories en tasks', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[makePbi({ id: 'p1' })],
|
||||
{ p1: [makeStory({ id: 's1', pbi_id: 'p1' })] },
|
||||
{ s1: [makeTask({ id: 't1', story_id: 's1' })] },
|
||||
),
|
||||
)
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'D',
|
||||
id: 'p1',
|
||||
product_id: 'prod-1',
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['p1']).toBeUndefined()
|
||||
expect(s.entities.storiesById['s1']).toBeUndefined()
|
||||
expect(s.entities.tasksById['t1']).toBeUndefined()
|
||||
expect(s.relations.pbiIds).toEqual([])
|
||||
expect(s.relations.storyIdsByPbi['p1']).toBeUndefined()
|
||||
expect(s.relations.taskIdsByStory['s1']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'p1' })]),
|
||||
)
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = 'p1'
|
||||
s.context.activeStoryId = 's-x'
|
||||
s.context.activeTaskId = 't-x'
|
||||
})
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'D',
|
||||
id: 'p1',
|
||||
product_id: 'prod-1',
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.context.activePbiId).toBeNull()
|
||||
expect(s.context.activeStoryId).toBeNull()
|
||||
expect(s.context.activeTaskId).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRealtimeEvent — story parent-move', () => {
|
||||
beforeEach(() => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[makePbi({ id: 'p1' }), makePbi({ id: 'p2' })],
|
||||
{
|
||||
p1: [makeStory({ id: 's1', pbi_id: 'p1' })],
|
||||
p2: [],
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => {
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'story',
|
||||
op: 'U',
|
||||
id: 's1',
|
||||
product_id: 'prod-1',
|
||||
pbi_id: 'p2',
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.relations.storyIdsByPbi['p1']).toEqual([])
|
||||
expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1'])
|
||||
expect(s.entities.storiesById['s1'].pbi_id).toBe('p2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRealtimeEvent — task parent-move', () => {
|
||||
beforeEach(() => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[makePbi({ id: 'p1' })],
|
||||
{ p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] },
|
||||
{
|
||||
s1: [makeTask({ id: 't1', story_id: 's1' })],
|
||||
s2: [],
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('U met andere story_id verplaatst task naar nieuwe parent', () => {
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'task',
|
||||
op: 'U',
|
||||
id: 't1',
|
||||
product_id: 'prod-1',
|
||||
story_id: 's2',
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.relations.taskIdsByStory['s1']).toEqual([])
|
||||
expect(s.relations.taskIdsByStory['s2']).toEqual(['t1'])
|
||||
expect(s.entities.tasksById['t1'].story_id).toBe('s2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRealtimeEvent — andere product genegeerd', () => {
|
||||
it('event met ander product_id raakt de store niet', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'a' })]),
|
||||
)
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'I',
|
||||
id: 'b',
|
||||
product_id: 'prod-2',
|
||||
title: 'Other product',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
})
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['b']).toBeUndefined()
|
||||
expect(s.relations.pbiIds).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRealtimeEvent — unknown entity → resync trigger', () => {
|
||||
function withSpy(): ReturnType<typeof vi.fn> {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
||||
})
|
||||
const spy = vi.fn().mockResolvedValue(undefined)
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes
|
||||
})
|
||||
return spy
|
||||
}
|
||||
|
||||
it('unknown entity (b.v. comment) met matching product triggert resync', () => {
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'comment',
|
||||
op: 'I',
|
||||
id: 'cm-1',
|
||||
product_id: 'prod-1',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).toHaveBeenCalledWith('unknown-event')
|
||||
})
|
||||
|
||||
it('unknown entity met ander product_id triggert geen resync', () => {
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'comment',
|
||||
op: 'I',
|
||||
id: 'cm-1',
|
||||
product_id: 'prod-2',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claude_job_status (type-veld) triggert geen resync', () => {
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
type: 'claude_job_status',
|
||||
job_id: 'job-1',
|
||||
product_id: 'prod-1',
|
||||
status: 'queued',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('worker_heartbeat (type-veld) triggert geen resync', () => {
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
type: 'worker_heartbeat',
|
||||
worker_id: 'w-1',
|
||||
product_id: 'prod-1',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('claude_job_enqueued (type-veld) triggert geen resync', () => {
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: 'job-2',
|
||||
product_id: 'prod-1',
|
||||
kind: 'PER_TASK',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('payload zonder entity en zonder type wordt genegeerd', () => {
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
product_id: 'prod-1',
|
||||
something: 'else',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => {
|
||||
// question is geen pbi/story/task entity dus telt als unknown wanneer
|
||||
// hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die
|
||||
// we nog niet kennen.
|
||||
const spy = withSpy()
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'question',
|
||||
op: 'I',
|
||||
id: 'q-1',
|
||||
product_id: 'prod-1',
|
||||
} as unknown as Record<string, unknown>)
|
||||
expect(spy).toHaveBeenCalledWith('unknown-event')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ensure*Loaded fetches + race-safe guard + sortering
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ensureProductLoaded', () => {
|
||||
it('fetcht backlog snapshot en hydreert met sortering', async () => {
|
||||
const snapshot: ProductBacklogSnapshot = {
|
||||
product: { id: 'prod-1', name: 'Product 1' },
|
||||
pbis: [
|
||||
makePbi({ id: 'a', priority: 2, sort_order: 5 }),
|
||||
makePbi({ id: 'b', priority: 1, sort_order: 9 }),
|
||||
],
|
||||
storiesByPbi: {},
|
||||
tasksByStory: {},
|
||||
}
|
||||
const fetchSpy = mockFetchSequence([snapshot])
|
||||
|
||||
await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/products/prod-1/backlog',
|
||||
expect.objectContaining({ cache: 'no-store' }),
|
||||
)
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.relations.pbiIds).toEqual(['b', 'a'])
|
||||
expect(s.loading.loadedProductId).toBe('prod-1')
|
||||
expect(s.loading.loadedPbiIds['a']).toBe(true)
|
||||
expect(s.loading.loadedPbiIds['b']).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('race-safe ensure*Loaded — activeRequestId guard', () => {
|
||||
it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => {
|
||||
let resolveOld: ((stories: BacklogStory[]) => void) | null = null
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => {
|
||||
if (url === '/api/pbis/pbi-old/stories') {
|
||||
const stories = await new Promise<BacklogStory[]>((resolve) => {
|
||||
resolveOld = resolve
|
||||
})
|
||||
return new Response(JSON.stringify(stories), { status: 200 })
|
||||
}
|
||||
if (url === '/api/pbis/pbi-new/stories') {
|
||||
return new Response(
|
||||
JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]),
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
return new Response('null', { status: 200 })
|
||||
}) as unknown as typeof fetch)
|
||||
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'Product 1' }
|
||||
s.context.activePbiId = 'pbi-old'
|
||||
s.loading.activeRequestId = 'req-old'
|
||||
})
|
||||
const oldPromise = useProductWorkspaceStore
|
||||
.getState()
|
||||
.ensurePbiLoaded('pbi-old', 'req-old')
|
||||
|
||||
// gebruiker selecteert ondertussen pbi-new
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = 'pbi-new'
|
||||
s.loading.activeRequestId = 'req-new'
|
||||
})
|
||||
await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new')
|
||||
|
||||
expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined()
|
||||
|
||||
// resolve de oude fetch — guard moet de stale data weigeren
|
||||
resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })])
|
||||
await oldPromise
|
||||
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.context.activePbiId).toBe('pbi-new')
|
||||
expect(s.entities.storiesById['old-st']).toBeUndefined()
|
||||
expect(s.entities.storiesById['new-st']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ensureTaskLoaded — zet detail-flag', () => {
|
||||
it('verrijkt task naar TaskDetail met _detail: true', async () => {
|
||||
mockFetchSequence([
|
||||
{
|
||||
id: 't-1',
|
||||
title: 'Task 1',
|
||||
description: 'desc',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
status: 'todo',
|
||||
story_id: 's-1',
|
||||
created_at: new Date('2026-02-01').toISOString(),
|
||||
implementation_plan: 'detailed plan here',
|
||||
},
|
||||
])
|
||||
|
||||
await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1')
|
||||
const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail
|
||||
expect(task._detail).toBe(true)
|
||||
expect(task.implementation_plan).toBe('detailed plan here')
|
||||
expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// resyncActiveScopes
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resyncActiveScopes', () => {
|
||||
it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => {
|
||||
const fetchSpy = mockFetchSequence([
|
||||
// ensureProductLoaded
|
||||
{ product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
|
||||
// ensurePbiLoaded
|
||||
[],
|
||||
// ensureStoryLoaded
|
||||
[],
|
||||
// ensureTaskLoaded
|
||||
{
|
||||
id: 't-1',
|
||||
title: 'T',
|
||||
description: null,
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
status: 'todo',
|
||||
story_id: 's-1',
|
||||
created_at: '2026-02-01',
|
||||
},
|
||||
])
|
||||
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'P' }
|
||||
s.context.activePbiId = 'pbi-1'
|
||||
s.context.activeStoryId = 's-1'
|
||||
s.context.activeTaskId = 't-1'
|
||||
})
|
||||
|
||||
await useProductWorkspaceStore.getState().resyncActiveScopes('manual')
|
||||
|
||||
const calls = fetchSpy.mock.calls.map(([url]) => url)
|
||||
expect(calls).toContain('/api/products/prod-1/backlog')
|
||||
expect(calls).toContain('/api/pbis/pbi-1/stories')
|
||||
expect(calls).toContain('/api/stories/s-1/tasks')
|
||||
expect(calls).toContain('/api/tasks/t-1')
|
||||
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.sync.lastResyncAt).toBeTypeOf('number')
|
||||
expect(s.sync.resyncReason).toBe('manual')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Optimistic mutations
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Restore-hint integratie (Story 4)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('restore-hint flow — setters persisteren hints', () => {
|
||||
it('setActiveProduct schrijft lastActiveProductId', () => {
|
||||
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
|
||||
const raw = localStorage.getItem('product-workspace-hints')
|
||||
expect(raw).not.toBeNull()
|
||||
const hints = JSON.parse(raw!)
|
||||
expect(hints.lastActiveProductId).toBe('prod-1')
|
||||
})
|
||||
|
||||
it('setActivePbi schrijft lastActivePbiId per product', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().setActivePbi('pbi-a')
|
||||
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
|
||||
expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a')
|
||||
})
|
||||
|
||||
it('setActiveStory schrijft lastActiveStoryId per product', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().setActiveStory('story-a')
|
||||
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
|
||||
expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a')
|
||||
})
|
||||
|
||||
it('setActiveTask schrijft lastActiveTaskId per product', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'P1' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().setActiveTask('task-a')
|
||||
const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!)
|
||||
expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('restore-hint flow — chain triggert na ensure*Loaded', () => {
|
||||
it('hint die NIET in entities zit wordt genegeerd', async () => {
|
||||
// Schrijf een hint voor een PBI die niet bestaat
|
||||
localStorage.setItem(
|
||||
'product-workspace-hints',
|
||||
JSON.stringify({
|
||||
lastActiveProductId: 'prod-1',
|
||||
perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } },
|
||||
}),
|
||||
)
|
||||
// Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen
|
||||
// ghost-pbi in entities.
|
||||
mockFetchSequence([
|
||||
{ product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} },
|
||||
])
|
||||
|
||||
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
|
||||
// Wacht tot async restore-flow afgewikkeld is.
|
||||
await new Promise((r) => setTimeout(r, 20))
|
||||
|
||||
expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull()
|
||||
})
|
||||
|
||||
it('hint die wel in entities zit wordt toegepast', async () => {
|
||||
const validPbi = makePbi({ id: 'pbi-known' })
|
||||
localStorage.setItem(
|
||||
'product-workspace-hints',
|
||||
JSON.stringify({
|
||||
lastActiveProductId: 'prod-1',
|
||||
perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } },
|
||||
}),
|
||||
)
|
||||
mockFetchSequence([
|
||||
// ensureProductLoaded levert pbi-known
|
||||
{
|
||||
product: { id: 'prod-1', name: 'P1' },
|
||||
pbis: [validPbi],
|
||||
storiesByPbi: {},
|
||||
tasksByStory: {},
|
||||
},
|
||||
// ensurePbiLoaded triggered door setActivePbi(hint) — geen stories
|
||||
[],
|
||||
])
|
||||
|
||||
useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' })
|
||||
await new Promise((r) => setTimeout(r, 30))
|
||||
|
||||
expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known')
|
||||
})
|
||||
})
|
||||
|
||||
describe('optimistic mutations', () => {
|
||||
it('rollback herstelt vorige pbi-order', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([
|
||||
makePbi({ id: 'a', priority: 2, sort_order: 1 }),
|
||||
makePbi({ id: 'b', priority: 2, sort_order: 2 }),
|
||||
]),
|
||||
)
|
||||
const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds]
|
||||
|
||||
const id = useProductWorkspaceStore.getState().applyOptimisticMutation({
|
||||
kind: 'pbi-order',
|
||||
prevPbiIds: prevOrder,
|
||||
})
|
||||
// simuleer de optimistic order-wijziging buiten de mutation
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.relations.pbiIds = ['b', 'a']
|
||||
})
|
||||
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a'])
|
||||
|
||||
useProductWorkspaceStore.getState().rollbackMutation(id)
|
||||
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder)
|
||||
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('settle ruimt pending op zonder state te wijzigen', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'a' })]),
|
||||
)
|
||||
const id = useProductWorkspaceStore.getState().applyOptimisticMutation({
|
||||
kind: 'pbi-order',
|
||||
prevPbiIds: ['a'],
|
||||
})
|
||||
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined()
|
||||
|
||||
useProductWorkspaceStore.getState().settleMutation(id)
|
||||
expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined()
|
||||
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('SSE-echo van een al-bestaande PBI is idempotent', () => {
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activeProduct = { id: 'prod-1', name: 'P' }
|
||||
})
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]),
|
||||
)
|
||||
useProductWorkspaceStore.getState().applyRealtimeEvent({
|
||||
entity: 'pbi',
|
||||
op: 'I',
|
||||
id: 'a',
|
||||
product_id: 'prod-1',
|
||||
title: 'echo',
|
||||
})
|
||||
expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel')
|
||||
expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue