feat(ST-804): solo-store realtime dispatch + pendingOps
Wire de SSE-events uit /api/realtime/solo door naar de Zustand-store
zodat het Solo Paneel zonder refresh meebeweegt met DB-mutaties uit
welke bron dan ook (web, REST, MCP).
Migratie 20260427000216_extend_realtime_payload: voegt new-state
velden aan de pg_notify-payload toe (task_status, task_sort_order,
task_title, story_status, story_sort_order, story_title, story_code)
zodat de client geen extra fetch nodig heeft per event.
Store-uitbreiding (stores/solo-store.ts):
- pendingOps: Set<task-id> die optimistic-writes markeert; realtime
echos voor die ids worden onderdrukt zodat eigen UI-mutaties niet
twee keer toegepast worden of door een latere echo overschreven
- handleRealtimeEvent: dispatch op entity + op
- task UPDATE/INSERT: bestaande tasks krijgen status/title/sort_order
bijgewerkt; onbekende tasks worden genegeerd (story-context
ontbreekt — gebruiker ziet ze pas na refresh)
- task DELETE: verwijdert uit store
- story UPDATE: werkt story_title/story_code bij op alle child-tasks
in de store
- story DELETE: verwijdert alle child-tasks (cascade reflectie)
Unit-test: 7 scenario's (status update, pendingOps echo-suppression,
DELETE, story-rename cascade, story-delete cascade, unknown task
no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1e548da9bf
commit
562735b98b
3 changed files with 345 additions and 6 deletions
109
__tests__/stores/solo-store-realtime.test.ts
Normal file
109
__tests__/stores/solo-store-realtime.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { RealtimeEvent } from '@/stores/solo-store'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
|
||||
const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
description: null,
|
||||
implementation_plan: null,
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
status: 'TO_DO',
|
||||
story_id: 'story-1',
|
||||
story_code: 'ST-100',
|
||||
story_title: 'Original Story',
|
||||
task_code: 'ST-100.1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const taskEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
|
||||
op: 'U',
|
||||
entity: 'task',
|
||||
id: 'task-1',
|
||||
story_id: 'story-1',
|
||||
product_id: 'prod-1',
|
||||
sprint_id: 'sprint-1',
|
||||
assignee_id: 'user-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const storyEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
|
||||
op: 'U',
|
||||
entity: 'story',
|
||||
id: 'story-1',
|
||||
product_id: 'prod-1',
|
||||
sprint_id: 'sprint-1',
|
||||
assignee_id: 'user-1',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('solo-store realtime', () => {
|
||||
beforeEach(() => {
|
||||
useSoloStore.setState({ tasks: {}, pendingOps: new Set() })
|
||||
})
|
||||
|
||||
it('applies a task status update', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'IN_PROGRESS' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('IN_PROGRESS')
|
||||
})
|
||||
|
||||
it('skips a task update when pendingOps has the id', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
|
||||
useSoloStore.getState().markPending('task-1')
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO')
|
||||
})
|
||||
|
||||
it('applies the update again once pendingOps is cleared', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
|
||||
useSoloStore.getState().markPending('task-1')
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO')
|
||||
useSoloStore.getState().clearPending('task-1')
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
|
||||
expect(useSoloStore.getState().tasks['task-1'].status).toBe('DONE')
|
||||
})
|
||||
|
||||
it('removes a task on D op', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1'), baseTask('task-2')])
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-1', op: 'D' }))
|
||||
expect(useSoloStore.getState().tasks['task-1']).toBeUndefined()
|
||||
expect(useSoloStore.getState().tasks['task-2']).toBeDefined()
|
||||
})
|
||||
|
||||
it('ignores task INSERT/UPDATE for tasks not in the store', () => {
|
||||
useSoloStore.getState().initTasks([baseTask('task-1')])
|
||||
useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-other', task_status: 'DONE' }))
|
||||
expect(Object.keys(useSoloStore.getState().tasks)).toEqual(['task-1'])
|
||||
})
|
||||
|
||||
it('updates story_title/code on all child tasks via story UPDATE', () => {
|
||||
useSoloStore.getState().initTasks([
|
||||
baseTask('task-1', { story_id: 'story-1' }),
|
||||
baseTask('task-2', { story_id: 'story-1' }),
|
||||
baseTask('task-3', { story_id: 'story-other', story_title: 'Other' }),
|
||||
])
|
||||
useSoloStore.getState().handleRealtimeEvent(
|
||||
storyEvent({ story_title: 'Renamed Story', story_code: 'ST-100B' }),
|
||||
)
|
||||
expect(useSoloStore.getState().tasks['task-1'].story_title).toBe('Renamed Story')
|
||||
expect(useSoloStore.getState().tasks['task-1'].story_code).toBe('ST-100B')
|
||||
expect(useSoloStore.getState().tasks['task-2'].story_title).toBe('Renamed Story')
|
||||
expect(useSoloStore.getState().tasks['task-3'].story_title).toBe('Other')
|
||||
})
|
||||
|
||||
it('removes all tasks of a story on story DELETE', () => {
|
||||
useSoloStore.getState().initTasks([
|
||||
baseTask('task-1', { story_id: 'story-1' }),
|
||||
baseTask('task-2', { story_id: 'story-1' }),
|
||||
baseTask('task-3', { story_id: 'story-other' }),
|
||||
])
|
||||
useSoloStore.getState().handleRealtimeEvent(storyEvent({ op: 'D' }))
|
||||
expect(useSoloStore.getState().tasks['task-1']).toBeUndefined()
|
||||
expect(useSoloStore.getState().tasks['task-2']).toBeUndefined()
|
||||
expect(useSoloStore.getState().tasks['task-3']).toBeDefined()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue