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 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') }) })