* feat(split-pane): refactor to generic n-pane SplitPane with cookie persistence New API: panes[], defaultSplit[], cookieKey, tabLabels. Supports arbitrary number of panes with n-1 draggable dividers and JSON cookie persistence. Replaces TriplePane; mobile renders tabs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(split-pane): migrate callers to new panes[] API Backlog page and sprint board now use generic SplitPane. TriplePane removed; sprint board uses 3-pane with defaultSplit=[28,35,37]. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(split-pane): add unit tests for 2/3-pane, cookie-restore, mobile tabs Added jsdom + @testing-library/react devDeps for component testing. 7 cases: render, divider count, cookie restore, invalid cookie fallback, mobile tab render/switch, and no-dividers-on-mobile. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add BacklogStore Zustand store with applyChange reducer State: pbis, storiesByPbi, tasksByStory. setInitialData for server hydration; applyChange(entity, op, data) handles I/U/D for SSE events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): server-fetch tasks + hydrate BacklogStore on page load Page now fetches tasks parallel to stories and groups by story_id. BacklogHydrationWrapper calls setInitialData on mount so the store is ready for downstream SSE consumers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add EmptyPanel shared component, replace inline empty states EmptyPanel takes title?, message, and optional action with DemoTooltip. Replaces duplicate inline empty-state markup in pbi-list and story-panel. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): add TaskPanel with sortable rows and TaskDialog wiring Reads selectedStoryId + tasksByStory from stores. DnD reorder via reorderTasksAction. Row click → ?editTask, + button → ?newTask&storyId. DemoTooltip on drag handles and + button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): wire TaskPanel + TaskDialog into backlog page 3-pane SplitPane [20,45,35]. searchParams for newTask/editTask. TaskDialog and EditTaskLoader render on ?newTask and ?editTask. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(backlog): add TaskPanel tests for render states and click handlers 7 cases: no-story empty, no-tasks empty+action, tasks render, + button router.push, row click router.push, demo disabled button, demo disabled handles. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): migrate PbiList to store-driven via useBacklogStore Removes pbis prop; reads from useBacklogStore(s => s.pbis) so SSE updates reflect in real-time without prop drilling. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(backlog): migrate StoryPanel to store-driven + selectStory on click Removes storiesByPbi prop; reads from useBacklogStore. Card click now dispatches selectStory(id) + shows isSelected highlight. Edit moved to inline pencil button. page.tsx drops pbis/storiesByPbi props. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(backlog): add 3-pane integration tests for click-cascade flow Covers: empty states, PBI→stories, story→tasks, cascade-reset, isSelected highlight. localStorage mocked for sort-mode persistence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1115): SSE backlog realtime — endpoint, hook, hydration mount, tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(ST-1116): update functional-spec (3-pane backlog + mobile) and architecture (backlog SSE + backlog-store) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ST-1117): TaskPanel card-grid — BacklogCard + rectSortingStrategy, tests updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(tests): correct PbiStatusApi type and remove duplicate mock keys Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
6 KiB
TypeScript
131 lines
6 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', 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', 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')
|
|
})
|
|
})
|