End-to-end smoke-test: PBI/Story/Task verschijnen zonder refresh (#57)

* fix(backlog-store): make INSERT handlers idempotent to prevent duplicate entries on duplicate SSE-events

* docs(realtime-smoke): add manual smoke-checklist for PBI/Story/Task realtime end-to-end verification

---------

Co-authored-by: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com>
This commit is contained in:
Janpeter Visser 2026-05-02 21:09:37 +02:00 committed by GitHub
parent 311f413e24
commit 6375ed6949
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 245 additions and 3 deletions

View file

@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useBacklogStore } from '@/stores/backlog-store'
import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store'
const PBI: BacklogPbi = {
id: 'pbi-1',
code: 'PBI-1',
title: 'Realtime PBI',
priority: 2,
description: 'desc',
created_at: new Date('2024-01-01T00:00:00Z'),
status: 'ready',
}
const STORY: BacklogStory = {
id: 'story-1',
code: 'ST-1',
title: 'Realtime story',
description: null,
acceptance_criteria: null,
priority: 2,
status: 'OPEN',
pbi_id: 'pbi-1',
created_at: new Date('2024-01-01T00:00:00Z'),
}
const TASK: BacklogTask = {
id: 'task-1',
title: 'Realtime task',
description: null,
priority: 2,
status: 'TO_DO',
sort_order: 1,
story_id: 'story-1',
created_at: new Date('2024-01-01T00:00:00Z'),
}
beforeEach(() => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
})
// ---------------------------------------------------------------------------
// PBI
// ---------------------------------------------------------------------------
describe('PBI payload contract', () => {
it('INSERT: entity appears in pbis with correct title and status', () => {
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
const state = useBacklogStore.getState()
expect(state.pbis).toHaveLength(1)
expect(state.pbis[0].id).toBe('pbi-1')
expect(state.pbis[0].title).toBe('Realtime PBI')
expect(state.pbis[0].status).toBe('ready')
})
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI })
expect(useBacklogStore.getState().pbis).toHaveLength(1)
})
it('UPDATE: changed_fields partial merges into existing entity', () => {
useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const })
const pbi = useBacklogStore.getState().pbis[0]
expect(pbi.title).toBe('Updated PBI')
expect(pbi.status).toBe('in_sprint')
expect(pbi.priority).toBe(2) // unchanged field retained
})
it('DELETE: entity is removed from pbis', () => {
useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' })
expect(useBacklogStore.getState().pbis).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// Story
// ---------------------------------------------------------------------------
describe('Story payload contract', () => {
it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
const bucket = useBacklogStore.getState().storiesByPbi['pbi-1']
expect(bucket).toHaveLength(1)
expect(bucket[0].id).toBe('story-1')
expect(bucket[0].title).toBe('Realtime story')
expect(bucket[0].status).toBe('OPEN')
})
it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => {
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
})
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
useBacklogStore.getState().applyChange('story', 'I', { ...STORY })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
})
it('UPDATE: changed_fields partial merges into existing story', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' })
const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0]
expect(story.title).toBe('Updated story')
expect(story.status).toBe('IN_SPRINT')
expect(story.priority).toBe(2) // unchanged field retained
})
it('DELETE: entity is removed from its pbi bucket', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
})
})
// ---------------------------------------------------------------------------
// Task
// ---------------------------------------------------------------------------
describe('Task payload contract', () => {
it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } })
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
const bucket = useBacklogStore.getState().tasksByStory['story-1']
expect(bucket).toHaveLength(1)
expect(bucket[0].id).toBe('task-1')
expect(bucket[0].title).toBe('Realtime task')
expect(bucket[0].status).toBe('TO_DO')
})
it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => {
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1)
})
it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => {
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
useBacklogStore.getState().applyChange('task', 'I', { ...TASK })
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1)
})
it('UPDATE: changed_fields partial merges into existing task', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } })
useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' })
const task = useBacklogStore.getState().tasksByStory['story-1'][0]
expect(task.title).toBe('Updated task')
expect(task.status).toBe('IN_PROGRESS')
expect(task.sort_order).toBe(1) // unchanged field retained
})
it('DELETE: entity is removed from its story bucket', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } })
useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' })
expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0)
})
})

79
docs/realtime-smoke.md Normal file
View file

@ -0,0 +1,79 @@
# Realtime smoke-checklist — PBI / Story / Task
Manuele checklist voor story "End-to-end smoke-test: PBI/Story/Task verschijnen zonder refresh".
Uitvoeren na deployment van de realtime-feature (SSE-keten: DB-trigger → pg NOTIFY → SSE → store → render).
## Voorbereiding
1. Open twee browser-tabs op hetzelfde product:
- **Tab A**`/backlog?product=<product_id>` (read-only observatie)
- **Tab B** — zelfde URL; gebruik dit tabblad voor create/edit/delete-acties
2. Zorg dat je **niet** refresht in tab A tijdens het testen.
3. Controleer in de DevTools-console van tab A dat de SSE-verbinding actief is
(`EventSource` connected, geen foutmeldingen).
---
## Checklist
### PBI
- [ ] **1. PBI aanmaken in tab B**
Maak een nieuwe PBI aan (bijv. titel "Smoke PBI").
→ Tab A toont de nieuwe PBI **binnen 1 seconde** zonder refresh.
→ Geen dubbele entry (PBI verschijnt precies één keer).
- [ ] **2. PBI titel bewerken in tab B**
Pas de titel van de PBI aan naar "Smoke PBI — updated".
→ Tab A reflecteert de nieuwe titel **binnen 1 seconde**.
→ Geen dubbele entry, geen flickering.
- [ ] **3. PBI verwijderen in tab B**
Verwijder de PBI.
→ Tab A verwijdert de PBI **binnen 1 seconde**.
---
### Story
- [ ] **4. Story aanmaken in tab B**
Maak een story aan onder een bestaande PBI (bijv. "Smoke Story").
→ Tab A toont de story in de juiste PBI-rij **binnen 1 seconde**.
→ Geen dubbele entry.
- [ ] **5. Story titel bewerken in tab B**
Pas de titel aan naar "Smoke Story — updated".
→ Tab A reflecteert de nieuwe titel.
→ Geen dubbele entry, geen flickering.
- [ ] **6. Story verwijderen in tab B**
Verwijder de story.
→ Tab A verwijdert de story uit de lijst.
---
### Task (solo-paneel + backlog drie-paneel)
- [ ] **7. Task aanmaken via solo-paneel**
Maak een task aan onder een story in het solo-paneel.
→ De task verschijnt in het solo-paneel **en** in het backlog drie-paneel **binnen 1 seconde**.
→ Geen dubbele entry.
- [ ] **8. Task status bijwerken**
Verander de status van de task (bijv. TO_DO → IN_PROGRESS).
→ Beide panelen reflecteren de nieuwe status.
- [ ] **9. Task verwijderen**
Verwijder de task.
→ Verdwijnt uit beide panelen.
---
## Pass-criteria
Alle 9 items afgevinkt = smoke geslaagd.
Bij een mislukking: noteer welk item faalde en controleer:
1. De SSE-stream (`/api/realtime/backlog?product_id=...`) in de DevTools-netwerktab.
2. De Zustand-store via Redux DevTools of een debug-breakpoint in `applyChange`.
3. De DB-trigger op de relevante tabel (`pg_notify` op INSERT/UPDATE/DELETE).

View file

@ -69,7 +69,8 @@ export const useBacklogStore = create<BacklogStore>((set) => ({
), ),
} }
} }
// I // I — idempotent: skip if already present (optimistic update may have arrived first)
if (state.pbis.some((p) => p.id === id)) return {}
return { pbis: [...state.pbis, data as unknown as BacklogPbi] } return { pbis: [...state.pbis, data as unknown as BacklogPbi] }
} }
@ -95,8 +96,9 @@ export const useBacklogStore = create<BacklogStore>((set) => ({
} }
return { storiesByPbi } return { storiesByPbi }
} }
// I // I — idempotent: skip if already present
const pbiId = data.pbi_id as string const pbiId = data.pbi_id as string
if ((state.storiesByPbi[pbiId] ?? []).some((s) => s.id === id)) return {}
return { return {
storiesByPbi: { storiesByPbi: {
...state.storiesByPbi, ...state.storiesByPbi,
@ -127,8 +129,9 @@ export const useBacklogStore = create<BacklogStore>((set) => ({
} }
return { tasksByStory } return { tasksByStory }
} }
// I // I — idempotent: skip if already present
const storyId = data.story_id as string const storyId = data.story_id as string
if ((state.tasksByStory[storyId] ?? []).some((t) => t.id === id)) return {}
return { return {
tasksByStory: { tasksByStory: {
...state.tasksByStory, ...state.tasksByStory,