From 6375ed6949fc31f7e4813e2e70136e5334e6392a Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 21:09:37 +0200 Subject: [PATCH] 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> --- __tests__/realtime/payload-contract.test.ts | 160 ++++++++++++++++++++ docs/realtime-smoke.md | 79 ++++++++++ stores/backlog-store.ts | 9 +- 3 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 __tests__/realtime/payload-contract.test.ts create mode 100644 docs/realtime-smoke.md diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts new file mode 100644 index 0000000..b36bc09 --- /dev/null +++ b/__tests__/realtime/payload-contract.test.ts @@ -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) + }) +}) diff --git a/docs/realtime-smoke.md b/docs/realtime-smoke.md new file mode 100644 index 0000000..28f87cd --- /dev/null +++ b/docs/realtime-smoke.md @@ -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=` (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). diff --git a/stores/backlog-store.ts b/stores/backlog-store.ts index 67daa62..16a584b 100644 --- a/stores/backlog-store.ts +++ b/stores/backlog-store.ts @@ -69,7 +69,8 @@ export const useBacklogStore = create((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] } } @@ -95,8 +96,9 @@ export const useBacklogStore = create((set) => ({ } return { storiesByPbi } } - // I + // I — idempotent: skip if already present const pbiId = data.pbi_id as string + if ((state.storiesByPbi[pbiId] ?? []).some((s) => s.id === id)) return {} return { storiesByPbi: { ...state.storiesByPbi, @@ -127,8 +129,9 @@ export const useBacklogStore = create((set) => ({ } return { tasksByStory } } - // I + // I — idempotent: skip if already present const storyId = data.story_id as string + if ((state.tasksByStory[storyId] ?? []).some((t) => t.id === id)) return {} return { tasksByStory: { ...state.tasksByStory,