import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' import type { SprintWorkspaceSnapshot, SprintWorkspaceSprint, SprintWorkspaceStory, SprintWorkspaceTask, SprintWorkspaceTaskDetail, } from '@/stores/sprint-workspace/types' // G5: snapshot original actions on module-load; restore in beforeEach. const originalActions = (() => { const s = useSprintWorkspaceStore.getState() return { hydrateSnapshot: s.hydrateSnapshot, hydrateProductSprints: s.hydrateProductSprints, setActiveProduct: s.setActiveProduct, setActiveSprint: s.setActiveSprint, setActiveStory: s.setActiveStory, setActiveTask: s.setActiveTask, ensureProductSprintsLoaded: s.ensureProductSprintsLoaded, ensureSprintLoaded: s.ensureSprintLoaded, 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() { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = null s.context.activeSprintId = null s.context.activeStoryId = null s.context.activeTaskId = null s.entities.sprintsById = {} s.entities.storiesById = {} s.entities.tasksById = {} s.relations.sprintIdsByProduct = {} s.relations.storyIdsBySprint = {} s.relations.taskIdsByStory = {} s.loading.loadedProductSprintsIds = {} s.loading.loadingProductId = null s.loading.loadedSprintIds = {} s.loading.loadingSprintId = null 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 makeSprint( overrides: Partial & { id: string; product_id: string }, ): SprintWorkspaceSprint { return { id: overrides.id, product_id: overrides.product_id, code: overrides.code ?? `S-${overrides.id}`, sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`, status: overrides.status ?? 'OPEN', start_date: overrides.start_date ?? '2026-04-01', end_date: overrides.end_date ?? '2026-04-14', created_at: overrides.created_at ?? new Date('2026-03-15'), completed_at: overrides.completed_at ?? null, } } function makeStory( overrides: Partial & { id: string; pbi_id: string }, ): SprintWorkspaceStory { 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 & { id: string; story_id: string }, ): SprintWorkspaceTask { return { id: overrides.id, code: overrides.code ?? null, title: overrides.title ?? `Task ${overrides.id}`, description: overrides.description ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, status: overrides.status ?? 'TO_DO', story_id: overrides.story_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), } } function snapshotWith( sprint: SprintWorkspaceSprint | undefined, stories: SprintWorkspaceStory[] = [], tasksByStory: Record = {}, product?: { id: string; name: string }, ): SprintWorkspaceSnapshot { return { product, sprint, stories, tasksByStory } } // G7/G8: mock fetch via mockImplementation (vers Response per call) function mockFetchSequence( responses: Array 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, relations en loaded-marker', () => { const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 }) const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 }) const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 }) const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 }) useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( sprint, [storyA, storyB], { 's-a': [taskA, taskB] }, { id: 'prod-1', name: 'Product 1' }, ), ) const s = useSprintWorkspaceStore.getState() expect(s.entities.sprintsById['sp-1']).toEqual(sprint) expect(s.entities.storiesById['s-a']).toEqual(storyA) expect(s.entities.storiesById['s-b']).toEqual(storyB) // sort_order: storyB (1) before storyA (2) expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a']) // sort_order: taskB (1) before taskA (2) expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a']) expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) expect(s.loading.loadedSprintIds['sp-1']).toBe(true) }) it('normaliseert API-statussen naar het interne store-contract', () => { useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })], { 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] }, ), ) const s = useSprintWorkspaceStore.getState() expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT') expect(s.entities.tasksById['t-1'].status).toBe('TO_DO') }) }) describe('hydrateProductSprints', () => { it('sorteert OPEN voor CLOSED, dan op start_date desc', () => { const closedOld = makeSprint({ id: 'sp-closed-old', product_id: 'prod-1', status: 'CLOSED', start_date: '2026-01-01', }) const openNew = makeSprint({ id: 'sp-open-new', product_id: 'prod-1', status: 'OPEN', start_date: '2026-04-01', }) const openOld = makeSprint({ id: 'sp-open-old', product_id: 'prod-1', status: 'OPEN', start_date: '2026-02-01', }) useSprintWorkspaceStore .getState() .hydrateProductSprints('prod-1', [closedOld, openOld, openNew]) const s = useSprintWorkspaceStore.getState() expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([ 'sp-open-new', 'sp-open-old', 'sp-closed-old', ]) expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) }) }) // ───────────────────────────────────────────────────────────────────────── // Selection cascade // ───────────────────────────────────────────────────────────────────────── describe('selection cascade', () => { it('setActiveSprint reset story+task; setActiveStory reset task', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeSprintId = 'sp-old' s.context.activeStoryId = 's-old' s.context.activeTaskId = 't-old' }) useSprintWorkspaceStore.getState().setActiveSprint('sp-new') let s = useSprintWorkspaceStore.getState() expect(s.context.activeSprintId).toBe('sp-new') expect(s.context.activeStoryId).toBeNull() expect(s.context.activeTaskId).toBeNull() useSprintWorkspaceStore.setState((draft) => { draft.context.activeStoryId = 's-old' draft.context.activeTaskId = 't-old' }) useSprintWorkspaceStore.getState().setActiveStory('s-new') s = useSprintWorkspaceStore.getState() expect(s.context.activeStoryId).toBe('s-new') expect(s.context.activeTaskId).toBeNull() }) it('setActiveProduct(null) ruimt entities en relations op', () => { useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, { id: 'prod-1', name: 'Product 1' }, ), ) useSprintWorkspaceStore.getState().setActiveProduct(null) const s = useSprintWorkspaceStore.getState() expect(s.context.activeProduct).toBeNull() expect(s.context.activeSprintId).toBeNull() expect(s.entities.sprintsById).toEqual({}) expect(s.entities.storiesById).toEqual({}) expect(s.entities.tasksById).toEqual({}) expect(s.relations.sprintIdsByProduct).toEqual({}) expect(s.relations.storyIdsBySprint).toEqual({}) expect(s.relations.taskIdsByStory).toEqual({}) }) }) // ───────────────────────────────────────────────────────────────────────── // applyRealtimeEvent // ───────────────────────────────────────────────────────────────────────── describe('applyRealtimeEvent — sprint', () => { beforeEach(() => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } }) }) it('I — voegt sprint toe aan product-lijst', () => { useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'I', id: 'sp-new', product_id: 'prod-1', code: 'S-1', sprint_goal: 'New Sprint', status: 'OPEN', start_date: '2026-05-01', end_date: '2026-05-14', created_at: new Date('2026-04-15').toISOString(), }) const s = useSprintWorkspaceStore.getState() expect(s.entities.sprintsById['sp-new']).toBeDefined() expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new') }) it('I — idempotent voor bestaande id', () => { useSprintWorkspaceStore .getState() .hydrateProductSprints('prod-1', [ makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), ]) useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'I', id: 'sp-1', product_id: 'prod-1', sprint_goal: 'echo', }) expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe( 'Origineel', ) }) it('U — patch + her-sorteert', () => { useSprintWorkspaceStore .getState() .hydrateProductSprints('prod-1', [ makeSprint({ id: 'sp-a', product_id: 'prod-1', status: 'OPEN', start_date: '2026-04-01', }), makeSprint({ id: 'sp-b', product_id: 'prod-1', status: 'OPEN', start_date: '2026-03-01', }), ]) // sp-a (newer) komt eerst expect( useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], ).toEqual(['sp-a', 'sp-b']) // Sluit sp-a → moet naar achteren useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'U', id: 'sp-a', product_id: 'prod-1', status: 'CLOSED', }) expect( useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], ).toEqual(['sp-b', 'sp-a']) }) it('D — verwijdert sprint inclusief child stories en tasks', () => { useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, { id: 'prod-1', name: 'Product 1' }, ), ) useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'D', id: 'sp-1', product_id: 'prod-1', }) const s = useSprintWorkspaceStore.getState() expect(s.entities.sprintsById['sp-1']).toBeUndefined() expect(s.entities.storiesById['s-1']).toBeUndefined() expect(s.entities.tasksById['t-1']).toBeUndefined() expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined() expect(s.relations.taskIdsByStory['s-1']).toBeUndefined() }) it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => { useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []), ) useSprintWorkspaceStore.setState((s) => { s.context.activeSprintId = 'sp-1' s.context.activeStoryId = 's-x' s.context.activeTaskId = 't-x' }) useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'D', id: 'sp-1', product_id: 'prod-1', }) const s = useSprintWorkspaceStore.getState() expect(s.context.activeSprintId).toBeNull() expect(s.context.activeStoryId).toBeNull() expect(s.context.activeTaskId).toBeNull() }) }) describe('applyRealtimeEvent — story sprint-move', () => { beforeEach(() => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } }) useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], ), ) }) it('U met andere sprint_id verplaatst story uit sprint-relatie', () => { useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'story', op: 'U', id: 's-1', product_id: 'prod-1', pbi_id: 'pbi-1', sprint_id: 'sp-other', }) const s = useSprintWorkspaceStore.getState() expect(s.relations.storyIdsBySprint['sp-1']).toEqual([]) expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1']) expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other') }) it('U met sprint_id=null haalt story uit sprint-relatie', () => { useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'story', op: 'U', id: 's-1', product_id: 'prod-1', pbi_id: 'pbi-1', sprint_id: null, }) expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([]) expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull() }) }) describe('applyRealtimeEvent — task parent-move', () => { beforeEach(() => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } }) useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [ makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), ], { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], 's-2': [], }, ), ) }) it('U met andere story_id verplaatst task naar nieuwe parent', () => { useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'task', op: 'U', id: 't-1', product_id: 'prod-1', story_id: 's-2', }) const s = useSprintWorkspaceStore.getState() expect(s.relations.taskIdsByStory['s-1']).toEqual([]) expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1']) expect(s.entities.tasksById['t-1'].story_id).toBe('s-2') }) }) describe('applyRealtimeEvent — andere product genegeerd', () => { it('event met ander product_id raakt de store niet', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } }) useSprintWorkspaceStore .getState() .hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })]) useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'I', id: 'sp-other', product_id: 'prod-2', code: 'X', }) const s = useSprintWorkspaceStore.getState() expect(s.entities.sprintsById['sp-other']).toBeUndefined() }) }) describe('applyRealtimeEvent — unknown entity → resync trigger', () => { function withSpy(): ReturnType { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } }) const spy = vi.fn().mockResolvedValue(undefined) useSprintWorkspaceStore.setState((s) => { s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes }) return spy } it('unknown entity met matching product triggert resync', () => { const spy = withSpy() useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'comment', op: 'I', id: 'cm-1', product_id: 'prod-1', }) expect(spy).toHaveBeenCalledWith('unknown-event') }) it('unknown entity met ander product_id triggert geen resync', () => { const spy = withSpy() useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'comment', op: 'I', id: 'cm-1', product_id: 'prod-2', }) expect(spy).not.toHaveBeenCalled() }) it('claude_job_status (type-veld) triggert geen resync', () => { const spy = withSpy() useSprintWorkspaceStore.getState().applyRealtimeEvent({ type: 'claude_job_status', job_id: 'job-1', product_id: 'prod-1', status: 'queued', }) expect(spy).not.toHaveBeenCalled() }) it('worker_heartbeat (type-veld) triggert geen resync', () => { const spy = withSpy() useSprintWorkspaceStore.getState().applyRealtimeEvent({ type: 'worker_heartbeat', worker_id: 'w-1', product_id: 'prod-1', }) expect(spy).not.toHaveBeenCalled() }) it('payload zonder entity en zonder type wordt genegeerd', () => { const spy = withSpy() useSprintWorkspaceStore.getState().applyRealtimeEvent({ product_id: 'prod-1', something: 'else', }) expect(spy).not.toHaveBeenCalled() }) }) // ───────────────────────────────────────────────────────────────────────── // ensure*Loaded // ───────────────────────────────────────────────────────────────────────── describe('ensureProductSprintsLoaded', () => { it('fetcht sprint-list en hydreert met sortering', async () => { const sprints = [ makeSprint({ id: 'sp-old', product_id: 'prod-1', status: 'CLOSED', start_date: '2026-01-01', }), makeSprint({ id: 'sp-new', product_id: 'prod-1', status: 'OPEN', start_date: '2026-04-01', }), ] const fetchSpy = mockFetchSequence([sprints]) await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1') expect(fetchSpy).toHaveBeenCalledWith( '/api/products/prod-1/sprints', expect.objectContaining({ cache: 'no-store' }), ) const s = useSprintWorkspaceStore.getState() expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old']) expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) }) }) describe('ensureSprintLoaded', () => { it('fetcht sprint-snapshot en hydreert', async () => { const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) const snapshot: SprintWorkspaceSnapshot = { product: { id: 'prod-1', name: 'P1' }, sprint, stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], tasksByStory: { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], }, } const fetchSpy = mockFetchSequence([snapshot]) await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1') expect(fetchSpy).toHaveBeenCalledWith( '/api/sprints/sp-1/workspace', expect.objectContaining({ cache: 'no-store' }), ) const s = useSprintWorkspaceStore.getState() expect(s.entities.sprintsById['sp-1']).toBeDefined() expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1']) expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1']) expect(s.loading.loadedSprintIds['sp-1']).toBe(true) }) }) describe('race-safe ensure*Loaded — activeRequestId guard', () => { it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => { let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { if (url === '/api/sprints/sp-old/workspace') { const snap = await new Promise((resolve) => { resolveOld = resolve }) return new Response(JSON.stringify(snap), { status: 200 }) } if (url === '/api/sprints/sp-new/workspace') { return new Response( JSON.stringify({ sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }), stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })], tasksByStory: {}, }), { status: 200 }, ) } return new Response('null', { status: 200 }) }) as unknown as typeof fetch) useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } s.context.activeSprintId = 'sp-old' s.loading.activeRequestId = 'req-old' }) const oldPromise = useSprintWorkspaceStore .getState() .ensureSprintLoaded('sp-old', 'req-old') useSprintWorkspaceStore.setState((s) => { s.context.activeSprintId = 'sp-new' s.loading.activeRequestId = 'req-new' }) await useSprintWorkspaceStore .getState() .ensureSprintLoaded('sp-new', 'req-new') expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() resolveOld!({ sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }), stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })], tasksByStory: {}, }) await oldPromise const s = useSprintWorkspaceStore.getState() expect(s.context.activeSprintId).toBe('sp-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', code: 'C1', title: 'Task 1', description: 'desc', priority: 1, sort_order: 1, status: 'todo', story_id: 's-1', sprint_id: 'sp-1', created_at: new Date('2026-02-01').toISOString(), implementation_plan: 'detailed plan here', }, ]) await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1') const task = useSprintWorkspaceStore.getState().entities.tasksById[ 't-1' ] as SprintWorkspaceTaskDetail expect(task._detail).toBe(true) expect(task.status).toBe('TO_DO') expect(task.implementation_plan).toBe('detailed plan here') expect(useSprintWorkspaceStore.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([ // ensureProductSprintsLoaded [], // ensureSprintLoaded { sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }), stories: [], tasksByStory: {}, }, // ensureStoryLoaded [], // ensureTaskLoaded { id: 't-1', title: 'T', description: null, priority: 1, sort_order: 1, status: 'todo', story_id: 's-1', sprint_id: 'sp-1', created_at: '2026-02-01', }, ]) useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'P' } s.context.activeSprintId = 'sp-1' s.context.activeStoryId = 's-1' s.context.activeTaskId = 't-1' }) await useSprintWorkspaceStore.getState().resyncActiveScopes('manual') const calls = fetchSpy.mock.calls.map(([url]) => url) expect(calls).toContain('/api/products/prod-1/sprints') expect(calls).toContain('/api/sprints/sp-1/workspace') expect(calls).toContain('/api/stories/s-1/tasks') expect(calls).toContain('/api/tasks/t-1') const s = useSprintWorkspaceStore.getState() expect(s.sync.lastResyncAt).toBeTypeOf('number') expect(s.sync.resyncReason).toBe('manual') }) }) // ───────────────────────────────────────────────────────────────────────── // Restore-hint flow // ───────────────────────────────────────────────────────────────────────── describe('restore-hint flow — setters persisteren hints', () => { it('setActiveProduct schrijft lastActiveProductId', () => { useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) const raw = localStorage.getItem('sprint-workspace-hints') expect(raw).not.toBeNull() const hints = JSON.parse(raw!) expect(hints.lastActiveProductId).toBe('prod-1') }) it('setActiveSprint schrijft lastActiveSprintId per product', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'P1' } }) useSprintWorkspaceStore.getState().setActiveSprint('sp-a') const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') }) it('setActiveStory schrijft lastActiveStoryId per sprint', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeSprintId = 'sp-1' }) useSprintWorkspaceStore.getState().setActiveStory('s-a') const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a') }) it('setActiveTask schrijft lastActiveTaskId per sprint', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeSprintId = 'sp-1' }) useSprintWorkspaceStore.getState().setActiveTask('t-a') const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a') }) }) describe('restore-hint flow — chain triggert na ensure*Loaded', () => { it('hint die NIET in entities zit wordt genegeerd', async () => { localStorage.setItem( 'sprint-workspace-hints', JSON.stringify({ lastActiveProductId: 'prod-1', perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } }, perSprint: {}, }), ) mockFetchSequence([[]]) useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) await new Promise((r) => setTimeout(r, 20)) expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull() }) it('hint die wel in entities zit wordt toegepast', async () => { const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' }) localStorage.setItem( 'sprint-workspace-hints', JSON.stringify({ lastActiveProductId: 'prod-1', perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } }, perSprint: {}, }), ) mockFetchSequence([ // ensureProductSprintsLoaded — levert sp-known [validSprint], // ensureSprintLoaded triggered door setActiveSprint(hint) { sprint: validSprint, stories: [], tasksByStory: {} }, ]) useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) await new Promise((r) => setTimeout(r, 30)) expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known') }) }) // ───────────────────────────────────────────────────────────────────────── // Optimistic mutations // ───────────────────────────────────────────────────────────────────────── describe('optimistic mutations', () => { it('rollback herstelt vorige sprint-story-order', () => { useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith( makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [ makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 1 }), makeStory({ id: 'b', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 2 }), ], ), ) const prevOrder = [ ...useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1'], ] const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({ kind: 'sprint-story-order', sprintId: 'sp-1', prevStoryIds: prevOrder, }) useSprintWorkspaceStore.setState((s) => { s.relations.storyIdsBySprint['sp-1'] = ['b', 'a'] }) useSprintWorkspaceStore.getState().rollbackMutation(id) expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual( prevOrder, ) expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() }) it('settle ruimt pending op zonder state te wijzigen', () => { useSprintWorkspaceStore.getState().hydrateSnapshot( snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [ makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1' }), ]), ) const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({ kind: 'sprint-story-order', sprintId: 'sp-1', prevStoryIds: ['a'], }) expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeDefined() useSprintWorkspaceStore.getState().settleMutation(id) expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([ 'a', ]) }) it('SSE-echo van een al-bestaande sprint is idempotent', () => { useSprintWorkspaceStore.setState((s) => { s.context.activeProduct = { id: 'prod-1', name: 'P' } }) useSprintWorkspaceStore .getState() .hydrateProductSprints('prod-1', [ makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), ]) useSprintWorkspaceStore.getState().applyRealtimeEvent({ entity: 'sprint', op: 'I', id: 'sp-1', product_id: 'prod-1', sprint_goal: 'echo', }) expect( useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal, ).toBe('Origineel') }) })