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 <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 14:31:22 +02:00
parent 92febfb51c
commit 8cb4e9a60c
2 changed files with 152 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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 <AGENT_API_KEY>`.
*
* 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 });
}
}