Scrum4Me/__tests__/api/backlog-realtime.test.ts
Janpeter Visser 71319e629d
feat(PBI-71): UX-fix 'lege sprint' + sprint-switch data-refresh (#175)
- StartSprintButton dialog toont 3-state banner: info met accurate vrije-
  stories count + PBI-context, of waarschuwing als geen PBI geselecteerd
  is, of waarschuwing als de geselecteerde PBI 0 vrije stories heeft
- Voeg sprint_id toe aan BacklogStory/Story/SprintStory + select in PB-
  pagina's en sprint-board mappings, zodat de banner accuraat kan tellen
- createSprintAction: revalidatePath met 'layout' flag voor consistency
  met createSprintWithPbisAction (top-nav 'Sprint' link ververst direct)

Sprint-switch data-refresh op alle relevante pagina's:

- BacklogHydrationWrapper: fingerprint-based re-hydratie zodat PB-data
  na router.refresh opnieuw uit nieuwe initialData komt (was: useEffect
  met lege deps draaide alleen 1x)
- SprintBoardClient: key={sprint.id} forceert remount bij sprint-switch
  zodat lokale sprintStories/sprintStoryIds-state vers ge-init wordt
- Solo (desktop + mobile): gebruik resolveActiveSprint(id) ipv eerste
  OPEN-sprint, plus key={sprint.id} op SoloBoard voor remount

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:27:24 +02:00

131 lines
6.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
vi.mock('@/lib/product-access', () => ({
getAccessibleProduct: vi.fn(),
}))
import { getAccessibleProduct } from '@/lib/product-access'
import type { NextRequest } from 'next/server'
import { GET } from '@/app/api/realtime/backlog/route'
import { useBacklogStore } from '@/stores/backlog-store'
const mockGetAccessibleProduct = getAccessibleProduct as ReturnType<typeof vi.fn>
function makeReq(productId?: string): NextRequest {
const url = productId
? `http://localhost/api/realtime/backlog?product_id=${productId}`
: 'http://localhost/api/realtime/backlog'
return {
signal: new AbortController().signal,
nextUrl: new URL(url),
} as unknown as NextRequest
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/realtime/backlog', () => {
it('401 when not authenticated', async () => {
mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false })
const res = await GET(makeReq('prod-1'))
expect(res.status).toBe(401)
expect(mockGetAccessibleProduct).not.toHaveBeenCalled()
})
it('400 when product_id is missing', async () => {
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
const res = await GET(makeReq())
expect(res.status).toBe(400)
})
it('403 when user has no access to the product', async () => {
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockGetAccessibleProduct.mockResolvedValue(null)
const res = await GET(makeReq('prod-1'))
expect(res.status).toBe(403)
expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1')
})
it('500 when DIRECT_URL and DATABASE_URL are absent', async () => {
mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' })
const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL }
delete process.env.DIRECT_URL
delete process.env.DATABASE_URL
try {
const res = await GET(makeReq('prod-1'))
expect(res.status).toBe(500)
} finally {
if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL
if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL
}
})
it('demo user is allowed (no 403) when product is accessible', async () => {
mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true })
mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' })
const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL }
delete process.env.DIRECT_URL
delete process.env.DATABASE_URL
try {
const res = await GET(makeReq('prod-1'))
// Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked
expect(res.status).toBe(500)
} finally {
if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL
if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL
}
})
})
// shouldEmit scope filter — white-box unit tests
describe('shouldEmit scope filter (via backlog-store reducer)', () => {
it('applyChange: pbi INSERT adds to pbis array', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} })
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const }
useBacklogStore.getState().applyChange('pbi', 'I', pbi)
expect(useBacklogStore.getState().pbis).toHaveLength(1)
expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1')
})
it('applyChange: pbi UPDATE patches existing pbi', () => {
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const }
useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' })
expect(useBacklogStore.getState().pbis[0].title).toBe('New')
})
it('applyChange: pbi DELETE removes pbi', () => {
const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const }
useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} })
useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' })
expect(useBacklogStore.getState().pbis).toHaveLength(0)
})
it('applyChange: story INSERT adds to storiesByPbi', () => {
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() }
useBacklogStore.getState().applyChange('story', 'I', story)
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
})
it('applyChange: story DELETE removes from correct pbi bucket', () => {
const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() }
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} })
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
})
it('applyChange: task UPDATE patches task across story buckets', () => {
const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() }
useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } })
useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' })
expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS')
})
})