- 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>
131 lines
6.1 KiB
TypeScript
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')
|
|
})
|
|
})
|