From 8cb4e9a60c2b736db0565e493145fdede47f150f Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Mon, 3 Aug 2026 14:31:22 +0200 Subject: [PATCH] feat(api): GET /api/v1/stock-purchases/list with value aggregation Thin wrapper around listStockPurchases that validates the status param against STOCK_PURCHASE_STATUS_VALUES (400 on bad input) and caps perPage at 200. Mirrors the agent-auth pattern used by the other v1 list routes. Co-authored-by: Orca --- .../api/v1/stock-purchases/list/route.test.ts | 96 +++++++++++++++++++ src/app/api/v1/stock-purchases/list/route.ts | 56 +++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/app/api/v1/stock-purchases/list/route.test.ts create mode 100644 src/app/api/v1/stock-purchases/list/route.ts diff --git a/src/app/api/v1/stock-purchases/list/route.test.ts b/src/app/api/v1/stock-purchases/list/route.test.ts new file mode 100644 index 0000000..d4382b5 --- /dev/null +++ b/src/app/api/v1/stock-purchases/list/route.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +// --- Mocks ---------------------------------------------------------------- + +const listStockPurchases = vi.fn(); +vi.mock("@/lib/server/stock-purchases-service", () => ({ + listStockPurchases: (...args: unknown[]) => listStockPurchases(...args), +})); + +// `getAdminPocketBase` requires PB_ADMIN_EMAIL/PB_ADMIN_PASSWORD env vars +// which aren't set in the test env. Mock it to a no-op admin client so the +// route's success path can call `listStockPurchases(pb, ...)` without +// trying to actually authenticate against PocketBase. +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({}), +})); + +const ORIGINAL_KEY = process.env.AGENT_API_KEY; +function authedRequest(url: string) { + process.env.AGENT_API_KEY = "test-token"; + return new NextRequest(url, { + headers: { authorization: "Bearer test-token" }, + }); +} + +import { GET } from "./route"; + +// --- Tests ---------------------------------------------------------------- + +describe("/api/v1/stock-purchases/list", () => { + beforeEach(() => { + process.env.AGENT_API_KEY = "test-token"; + listStockPurchases.mockReset(); + }); + + afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.AGENT_API_KEY; + else process.env.AGENT_API_KEY = ORIGINAL_KEY; + }); + + it("returns 200 with the service return shape", async () => { + const payload = { + items: [{ id: "sp-1", ordered_value: 100, received_value: 50, item_count: 2 }], + totalItems: 1, + page: 1, + perPage: 50, + totalPages: 1, + }; + listStockPurchases.mockResolvedValueOnce(payload); + + const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(payload); + }); + + it("passes status, page, perPage through to the service", async () => { + listStockPurchases.mockResolvedValueOnce({ + items: [], + totalItems: 0, + page: 2, + perPage: 25, + totalPages: 0, + }); + + await GET( + authedRequest( + "http://localhost/api/v1/stock-purchases/list?status=sent&page=2&perPage=25", + ), + ); + + expect(listStockPurchases).toHaveBeenCalledWith(expect.anything(), { + status: "sent", + page: 2, + perPage: 25, + }); + }); + + it("returns 400 for an unknown status value", async () => { + const res = await GET( + authedRequest("http://localhost/api/v1/stock-purchases/list?status=bogus"), + ); + expect(res.status).toBe(400); + expect(listStockPurchases).not.toHaveBeenCalled(); + const body = await res.json(); + expect(body.error).toMatch(/status/i); + }); + + it("returns 500 with { error } when the service throws", async () => { + listStockPurchases.mockRejectedValueOnce(new Error("PB is down")); + const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list")); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toBe("PB is down"); + }); +}); diff --git a/src/app/api/v1/stock-purchases/list/route.ts b/src/app/api/v1/stock-purchases/list/route.ts new file mode 100644 index 0000000..c20b6da --- /dev/null +++ b/src/app/api/v1/stock-purchases/list/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { listStockPurchases } from "@/lib/server/stock-purchases-service"; +import { STOCK_PURCHASE_STATUS_VALUES, type StockPurchaseStatus } from "@/lib/types"; + +/** + * GET /api/v1/stock-purchases/list + * + * List stock purchases with aggregated ordered/received value per row. + * + * Auth: same as the rest of `/api/v1` — `Authorization: Bearer `. + * + * Query params: + * status — optional, one of STOCK_PURCHASE_STATUS_VALUES or empty for "all" + * page — pagination, default 1, min 1 + * perPage — pagination, default 50, min 1, max 200 + * + * Distinct from the bare-collection `/api/v1/stock-purchases` route + * (which proxies arbitrary PB queries for the agent API). This route + * is the typed/validated list with value aggregation — it's what the + * `/stock-orders` page would hit if it ever calls over HTTP instead + * of importing the service directly. + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + assertAgentAuth(req); + + const rawStatus = req.nextUrl.searchParams.get("status") ?? ""; + if (rawStatus && !STOCK_PURCHASE_STATUS_VALUES.includes(rawStatus as StockPurchaseStatus)) { + return NextResponse.json( + { error: `unknown status: ${rawStatus}` }, + { status: 400 }, + ); + } + const status = rawStatus as StockPurchaseStatus | ""; + + const page = Math.max(1, Number(req.nextUrl.searchParams.get("page") || "1")); + const perPage = Math.min( + 200, + Math.max(1, Number(req.nextUrl.searchParams.get("perPage") || "50")), + ); + + const pb = await getAdminPocketBase(); + const result = await listStockPurchases(pb, { status, page, perPage }); + return NextResponse.json(result); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to fetch stock-purchases"; + return NextResponse.json({ error: message }, { status: 500 }); + } +}