import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn(), findFirst: vi.fn(), }, sprint: { findFirst: vi.fn(), }, story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), update: vi.fn(), }, task: { findFirst: vi.fn(), update: vi.fn(), findMany: vi.fn(), }, storyLog: { create: vi.fn(), }, todo: { create: vi.fn(), }, $transaction: vi.fn(), }, })) vi.mock('@/lib/api-auth', () => ({ authenticateApiRequest: vi.fn(), })) import { prisma } from '@/lib/prisma' import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getProducts } from '@/app/api/products/route' import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } sprint: { findFirst: ReturnType } story: { findFirst: ReturnType findUniqueOrThrow: ReturnType update: ReturnType } task: { findFirst: ReturnType update: ReturnType findMany: ReturnType } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType const UNAUTHORIZED = { error: 'Unauthorized', status: 401 } const DEMO_AUTH = { userId: 'demo-user', isDemo: true } const USER_1_AUTH = { userId: 'user-1', isDemo: false } const USER_2_AUTH = { userId: 'user-2', isDemo: false } function makeGet(url: string): Request { return new Request(url, { method: 'GET', headers: { Authorization: 'Bearer test-token' }, }) } function makePost(url: string, body: unknown): Request { return new Request(url, { method: 'POST', headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } function makePatch(url: string, body: unknown): Request { return new Request(url, { method: 'PATCH', headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } function routeCtx(id: string) { return { params: Promise.resolve({ id }) } } beforeEach(() => { vi.clearAllMocks() // Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes. mockPrisma.$transaction.mockImplementation(async (run: unknown) => { if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise)(prisma) return run }) }) // ─── GET /api/products ──────────────────────────────────────────────────────── describe('GET /api/products', () => { // TC-P-01 it('returns 401 when no valid token provided', async () => { mockAuth.mockResolvedValue(UNAUTHORIZED) const res = await getProducts(makeGet('http://localhost/api/products')) expect(res.status).toBe(401) }) // TC-P-08 it('returns only the authenticated user\'s products (cross-user isolation)', async () => { mockAuth.mockResolvedValue(USER_1_AUTH) mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1', name: 'Product A', repo_url: null }]) const res = await getProducts(makeGet('http://localhost/api/products')) const data = await res.json() expect(res.status).toBe(200) expect(mockPrisma.product.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ archived: false, OR: expect.arrayContaining([ { user_id: 'user-1' }, { members: { some: { user_id: 'user-1' } } }, ]), }), }) ) expect(data).toHaveLength(1) }) }) // ─── GET /api/products/:id/next-story ──────────────────────────────────────── describe('GET /api/products/:id/next-story', () => { // TC-NS-01 it('returns 401 when no valid token provided', async () => { mockAuth.mockResolvedValue(UNAUTHORIZED) const res = await getNextStory( makeGet('http://localhost/api/products/prod-1/next-story'), routeCtx('prod-1') ) expect(res.status).toBe(401) }) // TC-NS-03 / TC-NS-07: product not accessible covers both "not found" and cross-user it('returns 404 when product is not accessible to the authenticated user', async () => { mockAuth.mockResolvedValue(USER_1_AUTH) mockPrisma.sprint.findFirst.mockResolvedValue(null) const res = await getNextStory( makeGet('http://localhost/api/products/prod-other/next-story'), routeCtx('prod-other') ) expect(res.status).toBe(404) expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ product_id: 'prod-other', status: 'ACTIVE', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-1' }]), }), }), }) ) }) // TC-NS-07 explicit cross-user it('returns 404 for another user\'s product', async () => { mockAuth.mockResolvedValue(USER_2_AUTH) mockPrisma.sprint.findFirst.mockResolvedValue(null) const res = await getNextStory( makeGet('http://localhost/api/products/prod-1/next-story'), routeCtx('prod-1') ) expect(res.status).toBe(404) expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-2' }]), }), }), }) ) }) }) // ─── GET /api/sprints/:id/tasks ─────────────────────────────────────────────── describe('GET /api/sprints/:id/tasks', () => { // TC-ST-01 it('returns 401 when no valid token provided', async () => { mockAuth.mockResolvedValue(UNAUTHORIZED) const res = await getSprintTasks( makeGet('http://localhost/api/sprints/sprint-1/tasks'), routeCtx('sprint-1') ) expect(res.status).toBe(401) }) // TC-ST-03 it('returns 404 when sprint is not found', async () => { mockAuth.mockResolvedValue(USER_1_AUTH) mockPrisma.sprint.findFirst.mockResolvedValue(null) const res = await getSprintTasks( makeGet('http://localhost/api/sprints/nonexistent/tasks'), routeCtx('nonexistent') ) expect(res.status).toBe(404) }) // TC-ST-04 it('returns 404 for another user\'s sprint', async () => { mockAuth.mockResolvedValue(USER_2_AUTH) mockPrisma.sprint.findFirst.mockResolvedValue(null) const res = await getSprintTasks( makeGet('http://localhost/api/sprints/sprint-1/tasks'), routeCtx('sprint-1') ) expect(res.status).toBe(404) expect(mockPrisma.sprint.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: 'sprint-1', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-2' }]), }), }), }) ) }) }) // ─── PATCH /api/stories/:id/tasks/reorder ──────────────────────────────────── describe('PATCH /api/stories/:id/tasks/reorder', () => { const VALID_BODY = { task_ids: ['task-x'] } // TC-RO-01 it('returns 401 when no valid token provided', async () => { mockAuth.mockResolvedValue(UNAUTHORIZED) const res = await patchReorder( makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), routeCtx('story-1') ) expect(res.status).toBe(401) }) // TC-RO-03 it('returns 403 for demo users', async () => { mockAuth.mockResolvedValue(DEMO_AUTH) const res = await patchReorder( makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), routeCtx('story-1') ) expect(res.status).toBe(403) const data = await res.json() expect(data.error).toBe('Niet beschikbaar in demo-modus') }) // TC-RO-04 / TC-RO-05 it('returns 404 when story is not accessible to the authenticated user', async () => { mockAuth.mockResolvedValue(USER_2_AUTH) mockPrisma.story.findFirst.mockResolvedValue(null) const res = await patchReorder( makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), routeCtx('story-1') ) expect(res.status).toBe(404) expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: 'story-1', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-2' }]), }), }), }) ) }) }) // ─── POST /api/stories/:id/log ──────────────────────────────────────────────── describe('POST /api/stories/:id/log', () => { const VALID_BODY = { type: 'IMPLEMENTATION_PLAN', content: 'Plan: step 1' } // TC-L-01 it('returns 401 when no valid token provided', async () => { mockAuth.mockResolvedValue(UNAUTHORIZED) const res = await postStoryLog( makePost('http://localhost/api/stories/story-1/log', VALID_BODY), routeCtx('story-1') ) expect(res.status).toBe(401) }) // TC-L-03 it('returns 403 for demo users', async () => { mockAuth.mockResolvedValue(DEMO_AUTH) const res = await postStoryLog( makePost('http://localhost/api/stories/story-1/log', VALID_BODY), routeCtx('story-1') ) expect(res.status).toBe(403) const data = await res.json() expect(data.error).toBe('Niet beschikbaar in demo-modus') }) // TC-L-04 / TC-L-05 it('returns 404 when story is not accessible to the authenticated user', async () => { mockAuth.mockResolvedValue(USER_2_AUTH) mockPrisma.story.findFirst.mockResolvedValue(null) const res = await postStoryLog( makePost('http://localhost/api/stories/story-1/log', VALID_BODY), routeCtx('story-1') ) expect(res.status).toBe(404) expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: 'story-1', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-2' }]), }), }), }) ) }) }) // ─── PATCH /api/tasks/:id ───────────────────────────────────────────────────── describe('PATCH /api/tasks/:id', () => { // TC-T-01 it('returns 401 when no valid token provided', async () => { mockAuth.mockResolvedValue(UNAUTHORIZED) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), routeCtx('task-1') ) expect(res.status).toBe(401) }) // TC-T-03 it('returns 403 for demo users', async () => { mockAuth.mockResolvedValue(DEMO_AUTH) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), routeCtx('task-1') ) expect(res.status).toBe(403) const data = await res.json() expect(data.error).toBeTruthy() }) // TC-T-04 it('returns 404 when task does not exist', async () => { mockAuth.mockResolvedValue(USER_1_AUTH) mockPrisma.task.findFirst.mockResolvedValue(null) const res = await patchTask( makePatch('http://localhost/api/tasks/nonexistent', { status: 'DONE' }), routeCtx('nonexistent') ) expect(res.status).toBe(404) }) // TC-T-05 it('returns 403 when task belongs to a different user', async () => { mockAuth.mockResolvedValue(USER_2_AUTH) mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', story: { product: { user_id: 'user-1' } }, }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'DONE' }), routeCtx('task-1') ) expect(res.status).toBe(403) }) // TC-T-08 (happy path, sanity check) it('returns 200 when task belongs to the authenticated user', async () => { mockAuth.mockResolvedValue(USER_1_AUTH) mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', story: { product: { user_id: 'user-1' } }, }) mockPrisma.task.update.mockResolvedValue({ id: 'task-1', title: 'Task', status: 'DONE', story_id: 'story-1', implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), routeCtx('task-1') ) expect(res.status).toBe(200) }) })