From 9ab3a037aaf97c2913c171f0d8ee7925a7088d15 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:47:39 +0800 Subject: [PATCH] Fix API contract semantics and pagination metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/app/api/documents/[id]/labels/route.ts | 13 +- src/app/api/documents/[id]/reindex/route.ts | 2 +- src/app/api/documents/[id]/summarize/route.ts | 11 +- src/app/api/ingestion/batches/route.ts | 31 +++- .../api/ingestion/jobs/[id]/retry/route.ts | 11 +- src/app/api/ingestion/jobs/route.ts | 30 +++- src/app/api/jobs/route.ts | 36 ++++- src/app/api/search/interaction/route.ts | 10 +- src/app/api/upload/route.ts | 2 +- tests/api-route-coverage.test.ts | 16 +- tests/api-validation-contract.test.ts | 141 ++++++++++++++++++ tests/private-access-routes.test.ts | 40 +++-- tests/search-interaction-route.test.ts | 31 +++- 13 files changed, 325 insertions(+), 49 deletions(-) diff --git a/src/app/api/documents/[id]/labels/route.ts b/src/app/api/documents/[id]/labels/route.ts index 789363399..ffcb91cbe 100644 --- a/src/app/api/documents/[id]/labels/route.ts +++ b/src/app/api/documents/[id]/labels/route.ts @@ -8,6 +8,7 @@ import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; import type { DocumentLabel, DocumentLabelType } from "@/lib/types"; import { parseJsonBody } from "@/lib/validation/body"; +import { parseRouteParams } from "@/lib/validation/params"; export const runtime = "nodejs"; @@ -40,6 +41,9 @@ const manualLabelUpdateSchema = manualLabelSchema.extend({ const manualLabelDeleteSchema = z.object({ labelId: z.string().uuid(), }); +const labelsRouteParamsSchema = z.object({ + id: z.string().uuid(), +}); function parseManualLabel(input: z.infer) { const normalized = normalizeDocumentLabelForStorage({ @@ -84,7 +88,8 @@ async function selectLabels(supabase: ReturnType, docu export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id } = await params; + const { id: rawId } = await params; + const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id."); if (isDemoMode()) { return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 }); } @@ -141,7 +146,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id } = await params; + const { id: rawId } = await params; + const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id."); if (isDemoMode()) { return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 }); } @@ -206,7 +212,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id } = await params; + const { id: rawId } = await params; + const { id } = parseRouteParams({ id: rawId }, labelsRouteParamsSchema, "Invalid document id."); if (isDemoMode()) { return NextResponse.json({ error: "Demo documents cannot be curated." }, { status: 400 }); } diff --git a/src/app/api/documents/[id]/reindex/route.ts b/src/app/api/documents/[id]/reindex/route.ts index 12957ff40..736f41735 100644 --- a/src/app/api/documents/[id]/reindex/route.ts +++ b/src/app/api/documents/[id]/reindex/route.ts @@ -203,6 +203,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ job }, { status: 201 }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); - return jsonError(error, 400); + return jsonError(error); } } diff --git a/src/app/api/documents/[id]/summarize/route.ts b/src/app/api/documents/[id]/summarize/route.ts index f779aa8e6..0c6e2594b 100644 --- a/src/app/api/documents/[id]/summarize/route.ts +++ b/src/app/api/documents/[id]/summarize/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { demoSummary, getDemoDocument } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { summarizeDocument } from "@/lib/rag"; @@ -6,12 +7,18 @@ import { jsonError } from "@/lib/http"; import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseRouteParams } from "@/lib/validation/params"; export const runtime = "nodejs"; +const summarizeRouteParamsSchema = z.object({ + id: z.string().uuid(), +}); + export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id } = await params; + const { id: rawId } = await params; + const { id } = parseRouteParams({ id: rawId }, summarizeRouteParamsSchema, "Invalid document id."); if (isDemoMode()) { if (!getDemoDocument(id)) { return NextResponse.json({ error: "Demo document not found." }, { status: 404 }); @@ -32,6 +39,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: if (error instanceof Error && error.message === "Document not found.") { return NextResponse.json({ error: "Document not found." }, { status: 404 }); } - return jsonError(error, 400); + return jsonError(error); } } diff --git a/src/app/api/ingestion/batches/route.ts b/src/app/api/ingestion/batches/route.ts index a57c3386c..82fbfdfe8 100644 --- a/src/app/api/ingestion/batches/route.ts +++ b/src/app/api/ingestion/batches/route.ts @@ -1,13 +1,19 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; const ACTIVE_BATCH_STATUSES = new Set(["queued", "processing"]); const ACTIVE_INDEXING_POLL_MS = 5_000; +const ingestionBatchesQuerySchema = z.object({ + limit: queryInteger({ fallback: 20, min: 1, max: 200 }), + offset: queryInteger({ fallback: 0, min: 0, max: 10_000 }), +}); type BatchRow = Record & { status?: string | null }; @@ -40,19 +46,34 @@ function batchesResponse(batches: BatchRow[], extra: Record = { export async function GET(request: Request) { try { - if (isDemoMode()) return batchesResponse([], { demoMode: true }); + const { limit, offset } = parseRequestQuery(request, ingestionBatchesQuerySchema, "Invalid ingestion batches query."); + if (isDemoMode()) { + return batchesResponse([], { + demoMode: true, + pagination: { limit, offset, total: 0, nextOffset: offset, hasMore: false }, + }); + } const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - const { data, error } = await supabase + const { data, error, count } = await supabase .from("import_batches") - .select("*") + .select("*", { count: "exact" }) .eq("owner_id", user.id) .order("created_at", { ascending: false }) - .limit(20); + .range(offset, offset + limit - 1); if (error) throw new Error(error.message); - return batchesResponse((data ?? []) as unknown as BatchRow[]); + const batches = (data ?? []) as unknown as BatchRow[]; + return batchesResponse(batches, { + pagination: { + limit, + offset, + total: count ?? batches.length, + nextOffset: offset + batches.length, + hasMore: count === null ? batches.length === limit : offset + batches.length < count, + }, + }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); diff --git a/src/app/api/ingestion/jobs/[id]/retry/route.ts b/src/app/api/ingestion/jobs/[id]/retry/route.ts index 5510fd1b2..4e6b4285f 100644 --- a/src/app/api/ingestion/jobs/[id]/retry/route.ts +++ b/src/app/api/ingestion/jobs/[id]/retry/route.ts @@ -1,16 +1,23 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { env, isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseRouteParams } from "@/lib/validation/params"; export const runtime = "nodejs"; +const ingestionRetryRouteParamsSchema = z.object({ + id: z.string().uuid(), +}); + export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { if (isDemoMode()) return NextResponse.json({ error: "Retry is unavailable in demo mode." }, { status: 400 }); - const { id } = await params; + const { id: rawId } = await params; + const { id } = parseRouteParams({ id: rawId }, ingestionRetryRouteParamsSchema, "Invalid ingestion job id."); const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); @@ -79,6 +86,6 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ job: data }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); - return jsonError(error, 400); + return jsonError(error); } } diff --git a/src/app/api/ingestion/jobs/route.ts b/src/app/api/ingestion/jobs/route.ts index 8977d71fb..d71ef0fd2 100644 --- a/src/app/api/ingestion/jobs/route.ts +++ b/src/app/api/ingestion/jobs/route.ts @@ -4,7 +4,7 @@ import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; -import { optionalUuidQuery, parseRequestQuery } from "@/lib/validation/query"; +import { optionalUuidQuery, parseRequestQuery, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; @@ -13,6 +13,8 @@ const ACTIVE_INDEXING_POLL_MS = 5_000; const ingestionJobsQuerySchema = z.object({ batchId: optionalUuidQuery(), + limit: queryInteger({ fallback: 100, min: 1, max: 200 }), + offset: queryInteger({ fallback: 0, min: 0, max: 10_000 }), }); type JobRow = Record & { status?: string | null }; @@ -46,24 +48,38 @@ function jobsResponse(jobs: JobRow[], extra: Record = {}) { export async function GET(request: Request) { try { - if (isDemoMode()) return jobsResponse([], { demoMode: true }); + const { batchId, limit, offset } = parseRequestQuery(request, ingestionJobsQuerySchema, "Invalid ingestion jobs query."); + if (isDemoMode()) { + return jobsResponse([], { + demoMode: true, + pagination: { limit, offset, total: 0, nextOffset: offset, hasMore: false }, + }); + } const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - const { batchId } = parseRequestQuery(request, ingestionJobsQuerySchema, "Invalid ingestion jobs query."); let query = supabase .from("ingestion_jobs") - .select("*, documents!inner(title,file_name,status,owner_id)") + .select("*, documents!inner(title,file_name,status,owner_id)", { count: "exact" }) .eq("documents.owner_id", user.id) .order("created_at", { ascending: false }) - .limit(100); + .range(offset, offset + limit - 1); if (batchId) query = query.eq("batch_id", batchId); - const { data, error } = await query; + const { data, error, count } = await query; if (error) throw new Error(error.message); - return jobsResponse((data ?? []) as unknown as JobRow[]); + const jobs = (data ?? []) as unknown as JobRow[]; + return jobsResponse(jobs, { + pagination: { + limit, + offset, + total: count ?? jobs.length, + nextOffset: offset + jobs.length, + hasMore: count === null ? jobs.length === limit : offset + jobs.length < count, + }, + }); } catch (error) { if (error instanceof AuthenticationError) return unauthorizedResponse(); return jsonError(error); diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index e8f3ca6af..a1f39092f 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -1,15 +1,21 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { demoJobs } from "@/lib/demo-data"; import { isDemoMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, requireAuthenticatedUser, unauthorizedResponse } from "@/lib/supabase/auth"; +import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const ACTIVE_JOB_STATUSES = new Set(["pending", "processing"]); const ACTIVE_INDEXING_POLL_MS = 5_000; +const jobsQuerySchema = z.object({ + limit: queryInteger({ fallback: 30, min: 1, max: 200 }), + offset: queryInteger({ fallback: 0, min: 0, max: 10_000 }), +}); type JobRow = Record & { status?: string | null }; @@ -42,21 +48,41 @@ function jobsResponse(jobs: JobRow[], extra: Record = {}) { export async function GET(request: Request) { try { + const { limit, offset } = parseRequestQuery(request, jobsQuerySchema, "Invalid jobs query."); if (isDemoMode()) { - return jobsResponse(demoJobs, { demoMode: true }); + const jobs = demoJobs.slice(offset, offset + limit); + return jobsResponse(jobs, { + demoMode: true, + pagination: { + limit, + offset, + total: demoJobs.length, + nextOffset: offset + jobs.length, + hasMore: offset + jobs.length < demoJobs.length, + }, + }); } const supabase = createAdminClient(); const user = await requireAuthenticatedUser(request, supabase); - const { data, error } = await supabase + const { data, error, count } = await supabase .from("ingestion_jobs") - .select("*, documents!inner(title,file_name,status)") + .select("*, documents!inner(title,file_name,status)", { count: "exact" }) .eq("documents.owner_id", user.id) .order("created_at", { ascending: false }) - .limit(30); + .range(offset, offset + limit - 1); if (error) throw new Error(error.message); - return jobsResponse((data ?? []) as unknown as JobRow[]); + const jobs = (data ?? []) as unknown as JobRow[]; + return jobsResponse(jobs, { + pagination: { + limit, + offset, + total: count ?? jobs.length, + nextOffset: offset + jobs.length, + hasMore: count === null ? jobs.length === limit : offset + jobs.length < count, + }, + }); } catch (error) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); diff --git a/src/app/api/search/interaction/route.ts b/src/app/api/search/interaction/route.ts index 1cbf061d2..8b1d6db78 100644 --- a/src/app/api/search/interaction/route.ts +++ b/src/app/api/search/interaction/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { normalizedClinicalSearchTokens } from "@/lib/clinical-search"; import { isDemoMode } from "@/lib/env"; -import { PublicApiError } from "@/lib/http"; +import { jsonError } from "@/lib/http"; import { normalizedQueryTextForStorage, queryDerivedTokensForStorage, @@ -114,12 +114,6 @@ export async function POST(request: Request) { if (error instanceof serverAuth.AuthenticationError) { return serverAuth.unauthorizedResponse(error); } - if (error instanceof z.ZodError) { - return NextResponse.json({ ok: false }, { status: 400 }); - } - if (error instanceof PublicApiError) { - return NextResponse.json({ ok: false }, { status: error.status }); - } - return NextResponse.json({ ok: false }, { status: 500 }); + return jsonError(error); } } diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index 6b1e425fd..b118535cb 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -174,6 +174,6 @@ export async function POST(request: Request) { return unauthorizedResponse(); } - return jsonError(error, 400); + return jsonError(error); } } diff --git a/tests/api-route-coverage.test.ts b/tests/api-route-coverage.test.ts index 1b2490dcf..14ba98fba 100644 --- a/tests/api-route-coverage.test.ts +++ b/tests/api-route-coverage.test.ts @@ -21,15 +21,16 @@ type BatchRow = { status: string; }; -function createQueryMock(result: { data: T; error: { message: string } | null }) { +function createQueryMock(result: { data: T; error: { message: string } | null; count?: number | null }) { const chain = { select: null as unknown as ReturnType, eq: null as unknown as ReturnType, in: null as unknown as ReturnType, order: null as unknown as ReturnType, limit: null as unknown as ReturnType, + range: null as unknown as ReturnType, then: ( - resolve: (value: { data: T; error: { message: string } | null }) => void, + resolve: (value: { data: T; error: { message: string } | null; count?: number | null }) => void, reject?: (reason?: unknown) => void, ) => Promise.resolve(result).then(resolve, reject), } as { @@ -38,8 +39,9 @@ function createQueryMock(result: { data: T; error: { message: string } | null in: ReturnType; order: ReturnType; limit: ReturnType; + range: ReturnType; then: ( - resolve: (value: { data: T; error: { message: string } | null }) => void, + resolve: (value: { data: T; error: { message: string } | null; count?: number | null }) => void, reject?: (reason?: unknown) => void, ) => Promise; }; @@ -49,6 +51,7 @@ function createQueryMock(result: { data: T; error: { message: string } | null chain.in = vi.fn(() => chain); chain.order = vi.fn(() => chain); chain.limit = vi.fn(() => chain); + chain.range = vi.fn(() => chain); return chain; } @@ -143,6 +146,13 @@ describe("/api/ingestion/jobs", () => { hasActiveJobs: false, pollAfterMs: null, demoMode: true, + pagination: { + limit: 100, + offset: 0, + total: 0, + nextOffset: 0, + hasMore: false, + }, }); expect(response.headers.get("x-indexing-active")).toBe("false"); expect(createAdminClient).not.toHaveBeenCalled(); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index 00a107f10..b1b86e2ae 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -352,6 +352,147 @@ describe("API validation contracts", () => { expect(client.calls[0].filters).not.toContainEqual({ column: "batch_id", value: "" }); }); + it("returns pagination metadata for jobs feeds", async () => { + const client = createSupabaseMock((call) => { + if (call.table === "ingestion_jobs") return ok([{ id: "job-2", status: "pending" }], 5); + return ok([]); + }); + mockRuntime(client); + const jobsRoute = await import("../src/app/api/jobs/route"); + const ingestionJobsRoute = await import("../src/app/api/ingestion/jobs/route"); + const batchesRoute = await import("../src/app/api/ingestion/batches/route"); + + const jobsResponse = await jobsRoute.GET(authenticatedRequest("/api/jobs?limit=2&offset=1")); + const ingestionJobsResponse = await ingestionJobsRoute.GET(authenticatedRequest("/api/ingestion/jobs?limit=2&offset=1")); + const batchesResponse = await batchesRoute.GET(authenticatedRequest("/api/ingestion/batches?limit=2&offset=1")); + + expect(jobsResponse.status).toBe(200); + expect(await payload(jobsResponse)).toMatchObject({ + pagination: { limit: 2, offset: 1, total: 5, nextOffset: 2, hasMore: true }, + }); + expect(client.calls[0]).toMatchObject({ table: "ingestion_jobs", range: { from: 1, to: 2 } }); + + expect(ingestionJobsResponse.status).toBe(200); + expect(await payload(ingestionJobsResponse)).toMatchObject({ + pagination: { limit: 2, offset: 1, total: 5, nextOffset: 2, hasMore: true }, + }); + expect(client.calls[1]).toMatchObject({ table: "ingestion_jobs", range: { from: 1, to: 2 } }); + + expect(batchesResponse.status).toBe(200); + expect(await payload(batchesResponse)).toMatchObject({ + pagination: { limit: 2, offset: 1, total: 0, nextOffset: 1, hasMore: false }, + }); + expect(client.calls[2]).toMatchObject({ table: "import_batches", range: { from: 1, to: 2 } }); + }); + + it("validates UUID route params for retry, summarize, and labels endpoints", async () => { + const client = createSupabaseMock(); + mockRuntime(client); + const retryRoute = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); + const summarizeRoute = await import("../src/app/api/documents/[id]/summarize/route"); + const labelsRoute = await import("../src/app/api/documents/[id]/labels/route"); + + const retryResponse = await retryRoute.POST(authenticatedRequest("/api/ingestion/jobs/not-a-uuid/retry", { method: "POST" }), { + params: Promise.resolve({ id: "not-a-uuid" }), + }); + const summarizeResponse = await summarizeRoute.POST( + authenticatedRequest("/api/documents/not-a-uuid/summarize", { method: "POST" }), + { params: Promise.resolve({ id: "not-a-uuid" }) }, + ); + const labelsResponse = await labelsRoute.POST( + authenticatedRequest("/api/documents/not-a-uuid/labels", { + method: "POST", + body: JSON.stringify({ label: "Lithium", label_type: "medication" }), + }), + { params: Promise.resolve({ id: "not-a-uuid" }) }, + ); + + expect(retryResponse.status).toBe(400); + expect(await payload(retryResponse)).toEqual({ error: "Invalid ingestion job id." }); + expect(summarizeResponse.status).toBe(400); + expect(await payload(summarizeResponse)).toEqual({ error: "Invalid document id." }); + expect(labelsResponse.status).toBe(400); + expect(await payload(labelsResponse)).toEqual({ error: "Invalid document id." }); + expect(client.from).not.toHaveBeenCalled(); + }); + + it("returns 5xx envelopes for internal failures in upload, retry, reindex, and summarize routes", async () => { + const availableRateLimit = { + data: [ + { + limited: false, + limit_value: 100, + remaining: 99, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + ], + error: null, + }; + const uploadClient = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null); + if (call.table === "documents" && call.operation === "insert" && call.single) { + return { data: null, error: { message: "insert failed" } }; + } + return ok([]); + }); + mockRuntime(uploadClient); + const uploadRoute = await import("../src/app/api/upload/route"); + const formData = new FormData(); + formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" })); + const uploadResponse = await uploadRoute.POST(authenticatedRequest("/api/upload", { method: "POST", body: formData })); + expect(uploadResponse.status).toBe(500); + expect(await payload(uploadResponse)).toEqual({ error: "Request failed." }); + + const retryClient = createSupabaseMock((call) => { + if (call.table === "ingestion_jobs" && call.operation === "select" && call.maybeSingle) { + return { data: null, error: { message: "query failed" } }; + } + return ok([]); + }); + mockRuntime(retryClient); + const retryRoute = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); + const retryResponse = await retryRoute.POST(authenticatedRequest(`/api/ingestion/jobs/${documentId}/retry`, { method: "POST" }), { + params: Promise.resolve({ id: documentId }), + }); + expect(retryResponse.status).toBe(500); + expect(await payload(retryResponse)).toEqual({ error: "Request failed." }); + + const reindexClient = createSupabaseMock((call) => { + if (call.table === "documents" && call.operation === "select" && call.maybeSingle) { + return { data: null, error: { message: "query failed" } }; + } + return ok([]); + }); + reindexClient.rpc.mockImplementation(async () => availableRateLimit); + mockRuntime(reindexClient); + const reindexRoute = await import("../src/app/api/documents/[id]/reindex/route"); + const reindexResponse = await reindexRoute.POST( + authenticatedRequest(`/api/documents/${documentId}/reindex`, { + method: "POST", + body: JSON.stringify({ mode: "full" }), + }), + { params: Promise.resolve({ id: documentId }) }, + ); + expect(reindexResponse.status).toBe(500); + expect(await payload(reindexResponse)).toEqual({ error: "Request failed." }); + + const summarizeClient = createSupabaseMock(); + summarizeClient.rpc.mockImplementation(async () => availableRateLimit); + const summarizeDocument = vi.fn(async () => { + throw new Error("Upstream unavailable"); + }); + mockRuntime(summarizeClient); + vi.doMock("@/lib/rag", () => ({ summarizeDocument })); + const summarizeRoute = await import("../src/app/api/documents/[id]/summarize/route"); + const summarizeResponse = await summarizeRoute.POST( + authenticatedRequest(`/api/documents/${documentId}/summarize`, { method: "POST" }), + { params: Promise.resolve({ id: documentId }) }, + ); + expect(summarizeResponse.status).toBe(500); + expect(await payload(summarizeResponse)).toEqual({ error: "Request failed." }); + }); + it("rejects invalid upload metadata before storage upload or database writes", async () => { const client = createSupabaseMock(); mockRuntime(client); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index ce2291fc7..2cff6e9a7 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -996,7 +996,13 @@ describe("private document API access", () => { // update resolves with no row → refuse with 409. const client = createSupabaseMock((call) => { if (call.table === "ingestion_jobs" && call.operation === "select") { - return ok({ id: "job-1", document_id: documentId, batch_id: null, status: "processing", locked_at: null }); + return ok({ + id: "99999999-9999-4999-8999-999999999999", + document_id: documentId, + batch_id: null, + status: "processing", + locked_at: null, + }); } if (call.table === "ingestion_jobs" && call.operation === "update") { // Guard rejected the reset: no row affected. @@ -1007,9 +1013,12 @@ describe("private document API access", () => { mockRuntime(client); const { POST } = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); - const response = await POST(authenticatedRequest(`/api/ingestion/jobs/job-1/retry`, { method: "POST" }), { - params: Promise.resolve({ id: "job-1" }), - }); + const response = await POST( + authenticatedRequest("/api/ingestion/jobs/99999999-9999-4999-8999-999999999999/retry", { method: "POST" }), + { + params: Promise.resolve({ id: "99999999-9999-4999-8999-999999999999" }), + }, + ); expect(response.status).toBe(409); expect(String((await payload(response)).error)).toContain("still being processed"); @@ -1024,21 +1033,30 @@ describe("private document API access", () => { it("re-queues a stale/non-processing job without resetting the live index (IDX-C3, IDX-H1, B6)", async () => { const client = createSupabaseMock((call) => { if (call.table === "ingestion_jobs" && call.operation === "select") { - return ok({ id: "job-1", document_id: documentId, batch_id: null, status: "failed", locked_at: null }); + return ok({ + id: "99999999-9999-4999-8999-999999999999", + document_id: documentId, + batch_id: null, + status: "failed", + locked_at: null, + }); } if (call.table === "documents" && call.operation === "update") return ok([]); if (call.table === "ingestion_jobs" && call.operation === "update") { // Guard allowed the reset: one row affected. - return ok({ id: "job-1", document_id: documentId, status: "pending" }); + return ok({ id: "99999999-9999-4999-8999-999999999999", document_id: documentId, status: "pending" }); } return ok([]); }); mockRuntime(client); const { POST } = await import("../src/app/api/ingestion/jobs/[id]/retry/route"); - const response = await POST(authenticatedRequest(`/api/ingestion/jobs/job-1/retry`, { method: "POST" }), { - params: Promise.resolve({ id: "job-1" }), - }); + const response = await POST( + authenticatedRequest("/api/ingestion/jobs/99999999-9999-4999-8999-999999999999/retry", { method: "POST" }), + { + params: Promise.resolve({ id: "99999999-9999-4999-8999-999999999999" }), + }, + ); const documentUpdate = client.calls.find((call) => call.table === "documents" && call.operation === "update"); expect(response.status).toBe(200); @@ -1489,7 +1507,7 @@ describe("private document API access", () => { ); const uploadPath = client.storageMocks.upload.mock.calls[0]?.[0] as string; - expect(response.status).toBe(400); + expect(response.status).toBe(500); expect(client.storageMocks.remove).toHaveBeenCalledWith([uploadPath]); }); @@ -1516,7 +1534,7 @@ describe("private document API access", () => { ); const uploadPath = client.storageMocks.upload.mock.calls[0]?.[0] as string; - expect(response.status).toBe(400); + expect(response.status).toBe(500); expect(client.storageMocks.remove).toHaveBeenCalledWith([uploadPath]); }); diff --git a/tests/search-interaction-route.test.ts b/tests/search-interaction-route.test.ts index e4c5d3d43..0c34f0bad 100644 --- a/tests/search-interaction-route.test.ts +++ b/tests/search-interaction-route.test.ts @@ -63,7 +63,36 @@ describe("/api/search/interaction", () => { const response = await POST(request({ query: "", documentId: "not-a-document-id" })); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ ok: false }); + await expect(response.json()).resolves.toEqual({ error: "Invalid interaction request." }); + }); + + it("returns the shared server-error envelope when persistence fails", async () => { + const documentLookup = { + select: vi.fn(() => documentLookup), + eq: vi.fn(() => documentLookup), + maybeSingle: vi.fn(async () => ({ data: null, error: { message: "db unavailable" } })), + }; + const client = { + from: vi.fn(() => documentLookup), + }; + vi.doMock("@/lib/env", () => ({ env: { RAG_PERSIST_RAW_QUERY_TEXT: false }, isDemoMode: () => false })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: class AuthenticationError extends Error {}, + requireAuthenticatedUser: vi.fn(async () => ({ id: userId })), + unauthorizedResponse: () => Response.json({ error: "Authentication required." }, { status: 401 }), + })); + const { POST } = await import("../src/app/api/search/interaction/route"); + + const response = await POST( + request({ + query: "clozapine monitoring", + documentId, + }), + ); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ error: "Request failed." }); }); it("stores owned clicked document and chunk ids with sanitized labels", async () => {