diff --git a/src/app/api/v1/stock-items/stocktake-pdf/route.test.ts b/src/app/api/v1/stock-items/stocktake-pdf/route.test.ts new file mode 100644 index 0000000..0989f6e --- /dev/null +++ b/src/app/api/v1/stock-items/stocktake-pdf/route.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/v1/stock-items/stocktake-pdf/route.ts b/src/app/api/v1/stock-items/stocktake-pdf/route.ts new file mode 100644 index 0000000..fb0319f --- /dev/null +++ b/src/app/api/v1/stock-items/stocktake-pdf/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/api/v1/stock-purchases/list-pdf/route.test.ts b/src/app/api/v1/stock-purchases/list-pdf/route.test.ts new file mode 100644 index 0000000..c96e1bf --- /dev/null +++ b/src/app/api/v1/stock-purchases/list-pdf/route.test.ts @@ -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"); + }); +}); diff --git a/src/app/api/v1/stock-purchases/list-pdf/route.ts b/src/app/api/v1/stock-purchases/list-pdf/route.ts new file mode 100644 index 0000000..69935f8 --- /dev/null +++ b/src/app/api/v1/stock-purchases/list-pdf/route.ts @@ -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 }, + ); + } +}