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:
parent
311f413e24
commit
6375ed6949
3 changed files with 245 additions and 3 deletions
160
__tests__/realtime/payload-contract.test.ts
Normal file
160
__tests__/realtime/payload-contract.test.ts
Normal 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
79
docs/realtime-smoke.md
Normal 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).
|
||||
|
|
@ -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] }
|
||||
}
|
||||
|
||||
|
|
@ -95,8 +96,9 @@ export const useBacklogStore = create<BacklogStore>((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<BacklogStore>((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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue