feat(PBI-79/ST-1335): sprint-membership-summary + cross-sprint-blocks endpoints
Twee nieuwe GET-route handlers, beide verplicht gescoped op pbiIds (geen
product-brede aanroepen).
- app/api/products/[id]/sprint-membership-summary/route.ts
Response: { [pbiId]: { total, inSprint } } via twee prisma.groupBy calls
(totaal + binnen actieve sprint). Voor state-B tri-state.
- app/api/products/[id]/cross-sprint-blocks/route.ts
Response: { [storyId]: { sprintId, sprintName } } voor stories in andere
OPEN sprints. UX-hint voor disabled-vinkjes; commit-acties blijven
autoritatief.
Tests: 13 cases dekken happy path, 400 zonder pbiIds, 400 zonder sprintId,
404 zonder product-access, auth-fail, en NOT-clause voor excludeSprintId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4a515e86e
commit
e89fb7149f
4 changed files with 402 additions and 0 deletions
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
|
|
@ -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<typeof vi.fn> }
|
||||||
|
story: { findMany: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
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<string, unknown>
|
||||||
|
}
|
||||||
|
expect(callArg.where).toMatchObject({
|
||||||
|
pbi_id: { in: ['pbiA'] },
|
||||||
|
product_id: 'p1',
|
||||||
|
sprint_id: { not: null },
|
||||||
|
NOT: { sprint_id: 'sp-active' },
|
||||||
|
sprint: { status: 'OPEN' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
|
|
@ -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<typeof vi.fn> }
|
||||||
|
story: { groupBy: ReturnType<typeof vi.fn> }
|
||||||
|
}
|
||||||
|
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
74
app/api/products/[id]/cross-sprint-blocks/route.ts
Normal file
74
app/api/products/[id]/cross-sprint-blocks/route.ts
Normal file
|
|
@ -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<string, { sprintId: string; sprintName: string }> = {}
|
||||||
|
for (const story of stories) {
|
||||||
|
if (!story.sprint) continue
|
||||||
|
result[story.id] = {
|
||||||
|
sprintId: story.sprint.id,
|
||||||
|
sprintName: story.sprint.code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(result)
|
||||||
|
}
|
||||||
87
app/api/products/[id]/sprint-membership-summary/route.ts
Normal file
87
app/api/products/[id]/sprint-membership-summary/route.ts
Normal file
|
|
@ -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<string, number>()
|
||||||
|
for (const row of inSprint) {
|
||||||
|
inSprintByPbi.set(row.pbi_id, row._count._all)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, { total: number; inSprint: number }> = {}
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue