diff --git a/__tests__/app/api/jobs/job-by-id-route.test.ts b/__tests__/app/api/jobs/job-by-id-route.test.ts new file mode 100644 index 0000000..a9d2c1a --- /dev/null +++ b/__tests__/app/api/jobs/job-by-id-route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstJob: vi.fn(), + mockFindManyPrice: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeJob: { findFirst: mockFindFirstJob }, + modelPrice: { findMany: mockFindManyPrice }, + }, +})) + +import { GET } from '@/app/api/jobs/[id]/route' + +function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(id = 'job-1'): Request { + return new Request(`http://localhost/api/jobs/${id}`) +} + +const RAW_JOB = { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION' as const, + status: 'DONE' as const, + model_id: 'claude-sonnet-4-6', + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 0, + cache_write_tokens: 0, + branch: 'feat/test', + pr_url: null, + error: null, + summary: 'Done', + verify_result: 'ALIGNED' as const, + started_at: new Date('2026-01-01T10:00:00Z'), + finished_at: new Date('2026-01-01T10:05:00Z'), + created_at: new Date('2026-01-01T09:59:00Z'), + sprint_run_id: null, + task: { + code: 'T-42', + title: 'Some task', + description: null, + implementation_plan: 'Do the thing', + story: { code: 'S-10', pbi: { code: 'PBI-5' } }, + }, + idea: null, + product: { name: 'Scrum4Me', code: 'SCR' }, + sprint_run: null, +} + +describe('GET /api/jobs/:id', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ userId: 'user-1' }) + mockFindFirstJob.mockResolvedValue(RAW_JOB) + mockFindManyPrice.mockResolvedValue([]) + }) + + it('returns 401 when not logged in', async () => { + mockGetSession.mockResolvedValue({ userId: undefined }) + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBeTruthy() + }) + + it('returns 404 when job not found', async () => { + mockFindFirstJob.mockResolvedValue(null) + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBeTruthy() + }) + + it('queries with user_id filter to prevent cross-user access', async () => { + await GET(makeRequest() as never, makeParams()) + expect(mockFindFirstJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'job-1', user_id: 'user-1' }, + }) + ) + }) + + it('returns 200 with mapped job shape including breadcrumb codes', async () => { + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toMatchObject({ + id: 'job-1', + kind: 'TASK_IMPLEMENTATION', + status: 'DONE', + taskCode: 'T-42', + taskTitle: 'Some task', + productCode: 'SCR', + storyCode: 'S-10', + pbiCode: 'PBI-5', + branch: 'feat/test', + }) + }) +}) diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts new file mode 100644 index 0000000..fd11b89 --- /dev/null +++ b/app/api/jobs/[id]/route.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from 'next/server' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { JOB_INCLUDE, buildPriceMap, mapJob } from '@/lib/jobs-mapper' +import type { PriceRow, RawJob } from '@/lib/jobs-mapper' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + + const { id } = await params + + const job = await prisma.claudeJob.findFirst({ + where: { id, user_id: session.userId }, + include: JOB_INCLUDE, + }) + + if (!job) { + return Response.json({ error: 'Job niet gevonden' }, { status: 404 }) + } + + const prices = await prisma.modelPrice.findMany() + const priceMap = buildPriceMap(prices as PriceRow[]) + return Response.json(mapJob(job as RawJob, priceMap)) +}