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:
parent
92febfb51c
commit
8cb4e9a60c
2 changed files with 152 additions and 0 deletions
96
src/app/api/v1/stock-purchases/list/route.test.ts
Normal file
96
src/app/api/v1/stock-purchases/list/route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
56
src/app/api/v1/stock-purchases/list/route.ts
Normal file
56
src/app/api/v1/stock-purchases/list/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue