feat(api): GET /api/v1/stock-purchases/list-pdf + stocktake-pdf

Two list-report endpoints wired to the new PDF renderers. Both
auth via assertAgentAuth, ignore query params (always all rows),
and return application/pdf with attachment Content-Disposition.
The Stocktake PDF is sourced from the new listStockItemsForStocktake
service helper; the Orders PDF reuses the existing listStockPurchases
with perPage=200.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-03 16:29:57 +02:00
parent 907a7414b4
commit bbe3e1bd1f
4 changed files with 244 additions and 0 deletions

View file

@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
const renderStockItemStocktakePdf = vi.fn();
vi.mock("@/lib/pdf/render", () => ({
renderStockItemStocktakePdf: (...args: unknown[]) => renderStockItemStocktakePdf(...args),
}));
const listStockItemsForStocktake = vi.fn();
vi.mock("@/lib/server/stock-items-service", () => ({
listStockItemsForStocktake: (...args: unknown[]) => listStockItemsForStocktake(...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 `listStockItemsForStocktake(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) {
return new NextRequest(url, {
headers: { authorization: "Bearer test-token" },
});
}
import { GET } from "./route";
describe("/api/v1/stock-items/stocktake-pdf", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "test-token";
renderStockItemStocktakePdf.mockReset();
listStockItemsForStocktake.mockReset();
});
afterEach(() => {
if (ORIGINAL_KEY === undefined) delete process.env.AGENT_API_KEY;
else process.env.AGENT_API_KEY = ORIGINAL_KEY;
});
it("returns 200 with an application/pdf body", async () => {
listStockItemsForStocktake.mockResolvedValueOnce([
{ id: "si-1", name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" },
]);
renderStockItemStocktakePdf.mockResolvedValueOnce(Buffer.from("%PDF-1.4\nbinary stuff", "binary"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-items/stocktake-pdf"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/pdf");
const body = Buffer.from(await res.arrayBuffer());
expect(body.slice(0, 4).toString("binary")).toBe("%PDF");
});
it("returns 401 when auth is missing", async () => {
process.env.AGENT_API_KEY = "test-token";
const req = new NextRequest("http://localhost/api/v1/stock-items/stocktake-pdf");
const res = await GET(req);
expect(res.status).toBe(401);
expect(listStockItemsForStocktake).not.toHaveBeenCalled();
expect(renderStockItemStocktakePdf).not.toHaveBeenCalled();
});
it("returns 500 when the service throws", async () => {
listStockItemsForStocktake.mockRejectedValueOnce(new Error("PB is down"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-items/stocktake-pdf"));
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("PB is down");
});
});

View file

@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
import { listStockItemsForStocktake } from "@/lib/server/stock-items-service";
import { renderStockItemStocktakePdf } from "@/lib/pdf/render";
/**
* GET /api/v1/stock-items/stocktake-pdf
*
* Streams the printable stocktake PDF — every active stock item
* sorted by name, with a blank "Actual" column for the operator to
* fill in on the printed page during the walk-through.
*
* Auth: same `assertAgentAuth` as the rest of `/api/v1`.
* No query params — always all rows.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
assertAgentAuth(req);
const pb = await getAdminPocketBase();
const items = await listStockItemsForStocktake(pb);
const buffer = await renderStockItemStocktakePdf({
items,
generatedAtIso: new Date().toISOString(),
});
const filename = `stocktake-${new Date().toISOString().slice(0, 10)}.pdf`;
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
} catch (error) {
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json(
{ error: error instanceof Error ? error.message : "PDF render failed" },
{ status },
);
}
}

View file

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { NextRequest } from "next/server";
const renderStockPurchaseListPdf = vi.fn();
vi.mock("@/lib/pdf/render", () => ({
renderStockPurchaseListPdf: (...args: unknown[]) => renderStockPurchaseListPdf(...args),
}));
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) {
return new NextRequest(url, {
headers: { authorization: "Bearer test-token" },
});
}
import { GET } from "./route";
describe("/api/v1/stock-purchases/list-pdf", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "test-token";
renderStockPurchaseListPdf.mockReset();
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 an application/pdf body", async () => {
listStockPurchases.mockResolvedValueOnce({
items: [{ id: "sp-1", ordered_value: 100, received_value: 50, item_count: 2 }],
totalItems: 1,
page: 1,
perPage: 200,
totalPages: 1,
});
renderStockPurchaseListPdf.mockResolvedValueOnce(Buffer.from("%PDF-1.4\nbinary stuff", "binary"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list-pdf"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/pdf");
const body = Buffer.from(await res.arrayBuffer());
expect(body.slice(0, 4).toString("binary")).toBe("%PDF");
});
it("returns 401 when auth is missing", async () => {
process.env.AGENT_API_KEY = "test-token";
const req = new NextRequest("http://localhost/api/v1/stock-purchases/list-pdf");
const res = await GET(req);
expect(res.status).toBe(401);
expect(listStockPurchases).not.toHaveBeenCalled();
expect(renderStockPurchaseListPdf).not.toHaveBeenCalled();
});
it("returns 500 when the service throws", async () => {
listStockPurchases.mockRejectedValueOnce(new Error("PB is down"));
const res = await GET(authedRequest("http://localhost/api/v1/stock-purchases/list-pdf"));
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toBe("PB is down");
});
});

View file

@ -0,0 +1,51 @@
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 { renderStockPurchaseListPdf } from "@/lib/pdf/render";
/**
* GET /api/v1/stock-purchases/list-pdf
*
* Streams the stock-orders list as a PDF. Sibling of the single-PO
* PDF route at `/api/v1/stock-purchases/[id]/pdf` — that one is the
* supplier-facing stock PO; this one is the internal report (every
* PO in a table, no letterhead, no signing block).
*
* Auth: same `assertAgentAuth` as the rest of `/api/v1`.
* No query params — always all rows, sorted by created desc (the
* default service sort).
*
* perPage: 200 — the service's max. If a real org exceeds 200 POs
* the PDF will silently truncate. Acceptable for a reporting
* artefact; revisit if needed.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
assertAgentAuth(req);
const pb = await getAdminPocketBase();
const result = await listStockPurchases(pb, { status: "", page: 1, perPage: 200 });
const buffer = await renderStockPurchaseListPdf({
rows: result.items,
generatedAtIso: new Date().toISOString(),
});
const filename = `stock-orders-${new Date().toISOString().slice(0, 10)}.pdf`;
return new NextResponse(new Uint8Array(buffer), {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
} catch (error) {
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json(
{ error: error instanceof Error ? error.message : "PDF render failed" },
{ status },
);
}
}