diff --git a/__tests__/api/cross-sprint-blocks.test.ts b/__tests__/api/cross-sprint-blocks.test.ts new file mode 100644 index 0000000..5447900 --- /dev/null +++ b/__tests__/api/cross-sprint-blocks.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + story: { findMany: vi.fn() }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + story: { findMany: ReturnType } +} +const mockAuth = authenticateApiRequest as unknown as ReturnType + +function makeRequest(url: string) { + return new Request(url) +} + +describe('GET /api/products/[id]/cross-sprint-blocks', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.story.findMany.mockReset() + mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) + }) + + it('returns blocking sprint info per story for happy path', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.findMany.mockResolvedValue([ + { + id: 'story-1', + sprint: { id: 'sprint-x', code: 'SP-X' }, + }, + { + id: 'story-2', + sprint: { id: 'sprint-y', code: 'SP-Y' }, + }, + ]) + + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + 'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, + 'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' }, + }) + }) + + it('rejects when pbiIds is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when pbiIds is empty', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(404) + }) + + it('returns auth error when authenticate fails', async () => { + mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(401) + }) + + it('passes NOT excludeSprintId to prisma when provided', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.findMany.mockResolvedValue([]) + + const req = makeRequest( + 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA', + ) + await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + const callArg = mockPrisma.story.findMany.mock.calls[0][0] as { + where: Record + } + expect(callArg.where).toMatchObject({ + pbi_id: { in: ['pbiA'] }, + product_id: 'p1', + sprint_id: { not: null }, + NOT: { sprint_id: 'sp-active' }, + sprint: { status: 'OPEN' }, + }) + }) +}) diff --git a/__tests__/api/sprint-membership-summary.test.ts b/__tests__/api/sprint-membership-summary.test.ts new file mode 100644 index 0000000..c526210 --- /dev/null +++ b/__tests__/api/sprint-membership-summary.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { findFirst: vi.fn() }, + story: { groupBy: vi.fn() }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + story: { groupBy: ReturnType } +} +const mockAuth = authenticateApiRequest as unknown as ReturnType + +function makeRequest(url: string) { + return new Request(url) +} + +describe('GET /api/products/[id]/sprint-membership-summary', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPrisma.product.findFirst.mockReset() + mockPrisma.story.groupBy.mockReset() + mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) + }) + + it('returns counts per PBI for happy path', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.groupBy + .mockResolvedValueOnce([ + { pbi_id: 'pbiA', _count: { _all: 5 } }, + { pbi_id: 'pbiB', _count: { _all: 3 } }, + ]) + .mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }]) + + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + pbiA: { total: 5, inSprint: 2 }, + pbiB: { total: 3, inSprint: 0 }, + }) + }) + + it('rejects when pbiIds is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when pbiIds is empty', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('rejects when sprintId is missing', async () => { + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(400) + }) + + it('returns 404 when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(404) + }) + + it('returns auth error when authenticate fails', async () => { + mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + expect(res.status).toBe(401) + }) + + it('returns zero counts for PBIs without stories', async () => { + mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) + mockPrisma.story.groupBy + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + + const req = makeRequest( + 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', + ) + const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) + + const body = await res.json() + expect(body).toEqual({ + pbiA: { total: 0, inSprint: 0 }, + pbiB: { total: 0, inSprint: 0 }, + }) + }) +}) diff --git a/app/api/products/[id]/cross-sprint-blocks/route.ts b/app/api/products/[id]/cross-sprint-blocks/route.ts new file mode 100644 index 0000000..ba10da2 --- /dev/null +++ b/app/api/products/[id]/cross-sprint-blocks/route.ts @@ -0,0 +1,74 @@ +// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks +// +// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in +// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side +// commit-actions blijven autoritatief — dit endpoint is alleen voor UI. +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +export const dynamic = 'force-dynamic' + +function parsePbiIds(raw: string | null): string[] | null { + if (!raw) return null + const ids = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return ids.length === 0 ? null : ids +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id: productId } = await params + const url = new URL(request.url) + const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined + const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) + + if (!pbiIds) { + return Response.json( + { error: 'pbiIds is verplicht (comma-separated)' }, + { status: 400 }, + ) + } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(auth.userId) }, + select: { id: true }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + + const stories = await prisma.story.findMany({ + where: { + pbi_id: { in: pbiIds }, + product_id: productId, + sprint_id: { not: null }, + ...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}), + sprint: { status: 'OPEN' }, + }, + select: { + id: true, + sprint: { select: { id: true, code: true } }, + }, + }) + + const result: Record = {} + for (const story of stories) { + if (!story.sprint) continue + result[story.id] = { + sprintId: story.sprint.id, + sprintName: story.sprint.code, + } + } + + return Response.json(result) +} diff --git a/app/api/products/[id]/sprint-membership-summary/route.ts b/app/api/products/[id]/sprint-membership-summary/route.ts new file mode 100644 index 0000000..16f6b6d --- /dev/null +++ b/app/api/products/[id]/sprint-membership-summary/route.ts @@ -0,0 +1,87 @@ +// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary +// +// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds. +// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy +// + één count-by-sprint waar pbi_id IN (pbiIds). +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +export const dynamic = 'force-dynamic' + +function parsePbiIds(raw: string | null): string[] | null { + if (!raw) return null + const ids = raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + return ids.length === 0 ? null : ids +} + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id: productId } = await params + const url = new URL(request.url) + const sprintId = url.searchParams.get('sprintId') + const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) + + if (!sprintId) { + return Response.json({ error: 'sprintId is verplicht' }, { status: 400 }) + } + if (!pbiIds) { + return Response.json( + { error: 'pbiIds is verplicht (comma-separated)' }, + { status: 400 }, + ) + } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(auth.userId) }, + select: { id: true }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + + const [totals, inSprint] = await Promise.all([ + prisma.story.groupBy({ + by: ['pbi_id'], + where: { pbi_id: { in: pbiIds }, product_id: productId }, + _count: { _all: true }, + }), + prisma.story.groupBy({ + by: ['pbi_id'], + where: { + pbi_id: { in: pbiIds }, + product_id: productId, + sprint_id: sprintId, + }, + _count: { _all: true }, + }), + ]) + + const inSprintByPbi = new Map() + for (const row of inSprint) { + inSprintByPbi.set(row.pbi_id, row._count._all) + } + + const result: Record = {} + for (const pbiId of pbiIds) { + result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 } + } + for (const row of totals) { + result[row.pbi_id] = { + total: row._count._all, + inSprint: inSprintByPbi.get(row.pbi_id) ?? 0, + } + } + + return Response.json(result) +}