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:
parent
907a7414b4
commit
bbe3e1bd1f
4 changed files with 244 additions and 0 deletions
72
src/app/api/v1/stock-items/stocktake-pdf/route.test.ts
Normal file
72
src/app/api/v1/stock-items/stocktake-pdf/route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
45
src/app/api/v1/stock-items/stocktake-pdf/route.ts
Normal file
45
src/app/api/v1/stock-items/stocktake-pdf/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
76
src/app/api/v1/stock-purchases/list-pdf/route.test.ts
Normal file
76
src/app/api/v1/stock-purchases/list-pdf/route.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
51
src/app/api/v1/stock-purchases/list-pdf/route.ts
Normal file
51
src/app/api/v1/stock-purchases/list-pdf/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue