Scrum4Me/__tests__/stores/sprint-workspace/store.test.ts
Janpeter Visser ff22196714
Sprint: Stories en taken krijgen één voorspelbare volgorde gekoppeld aan hun code; drag-and-drop herordening voor stories/taken verdwijnt, priority wordt puur label. (#201)
* feat(code): add parseCodeNumber helper to lib/code.ts

Pure helper that extracts the trailing numeric sequence from a code string
(ST-007 → 7, T-42 → 42). Non-conforming codes fall back to Number.MAX_SAFE_INTEGER
so they sort to the end. Includes 5 unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(tasks): add code field to BacklogTask type and all task selects

Adds `code: string | null` to BacklogTask interface and includes it in
all Prisma task.findMany selects (backlog API, stories tasks API, page
hydration routes). Updates coerceTaskPayload and test fixtures to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sort-order): derive story/task sort_order from parseCodeNumber(code)

All create paths (createStoryAction, saveTask, createTaskAction,
materializeIdeaPlanAction) and code-edit paths (updateStoryAction, saveTask
update) now set sort_order = parseCodeNumber(code) instead of last+1.
Removes stale last-record queries from create paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sort-order): decouple sprint membership actions from sort_order

createSprintAction and addStoryToSprintAction no longer write sort_order
when adding stories to a sprint. sort_order is derived from code via
parseCodeNumber, so membership should only set sprint_id + status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(ordering): remove priority from all story/task orderBy

Story- en taak-ordering is nu puur sort_order asc (created_at als
tiebreaker). PBI-ordering (priority + sort_order) blijft ongewijzigd.

Gewijzigd: backlog/route, pbis/stories/route, claude-context/route,
next-story/route, workspace/route, tasks/route, sprint-runs (query +
in-memory sort), solo-workspace-server, page.tsx (app + mobile + sprint),
store compareStory, actions/sprints story-query, next-story test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(dnd): remove drag-and-drop reorder for stories and tasks

- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction
- Delete REST route app/api/stories/[id]/tasks/reorder/route.ts
- Remove DnD from backlog story-panel and task-panel (flat list)
- Remove reorder-within-sprint branch from sprint-board-client handleDragEnd
- Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept)
- Remove all DnD from task-list (status toggle + edit kept)
- Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers
- Remove related tests for deleted reorder route; fix sprint store tests

* feat(backlog): toon code-badge op backlog-taakkaarten

Geeft code={task.code} door aan <BacklogCard> in TaskCard (task-panel.tsx).
BacklogCard rendert de CodeBadge al conditionally — alleen de prop ontbrak.

* feat(migration): backfill story/task sort_order from code numeric suffix

One-time Prisma migration that sets sort_order = trailing numeric part
of code for all existing stories and tasks, consistent with
parseCodeNumber (fallback = Number.MAX_SAFE_INTEGER for non-conforming
codes). PBIs are intentionally excluded.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs+tests(sort-order): update for code-binding order on stories/tasks

- Rewrite docs/patterns/sort-order.md: float-insertion PBI only; story/task
  sort_order = parseCodeNumber(code), never drag/membership mutated
- Update plan-to-pbi-flow.md: sort_order auto, sprint_id param, priority=label
- Update make-plan.md: priority=label, array order = execution order
- Update rest-contract.md: fix sprint-tasks ordering, remove reorder endpoint
- Add ADR-0011: code is bindende volgordesleutel voor stories/taken
- Regenerate docs/INDEX.md via npm run docs
- Remove reorderStoriesAction/reorderTasksAction mocks from backlog tests
- Remove dnd-kit mocks from task-panel test (panel no longer uses dnd)
- Extend materializeIdeaPlanAction test: assert sort_order=parseCodeNumber(code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:02:36 +02:00

875 lines
31 KiB
TypeScript

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<SprintWorkspaceSprint> & { 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<SprintWorkspaceStory> & { 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<SprintWorkspaceTask> & { 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<string, SprintWorkspaceTask[]> = {},
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 | ((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, 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<typeof vi.fn> {
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<SprintWorkspaceSnapshot>((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('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')
})
})