diff --git a/src/lib/server/stock-items-service.test.ts b/src/lib/server/stock-items-service.test.ts new file mode 100644 index 0000000..f198b25 --- /dev/null +++ b/src/lib/server/stock-items-service.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// --- PocketBase mock --------------------------------------------------------- +// +// Hoisted mock so the service module can call `pb.collection(...)`. +// We seed an in-memory map of `stock_items` rows. + +const items = new Map>(); + +const pb = { + collection: (id: string) => ({ + getFullList: async (opts: { filter?: string; sort?: string; expand?: string } = {}) => { + if (id !== "stock_items") throw new Error(`unexpected collection: ${id}`); + let rows = Array.from(items.values()); + if (opts.filter?.includes("is_active = true")) { + rows = rows.filter((r) => r.is_active === true); + } + if (opts.sort) { + const dir = opts.sort.startsWith("-") ? -1 : 1; + const key = opts.sort.replace(/^-/, ""); + rows = [...rows].sort((a, b) => { + const av = String(a[key] ?? ""); + const bv = String(b[key] ?? ""); + return dir * av.localeCompare(bv); + }); + } + return rows; + }, + }), +}; + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: async () => pb, +})); + +import { listStockItemsForStocktake } from "./stock-items-service"; + +function seed(id: string, fields: Record) { + items.set(id, { id, ...fields }); +} + +beforeEach(() => { + items.clear(); +}); + +// --- Tests ------------------------------------------------------------------- + +describe("listStockItemsForStocktake", () => { + it("returns active stock items sorted by name with supplier expanded", async () => { + seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: true, supplier: "sup-1" }); + + const result = await listStockItemsForStocktake(pb as never); + expect(result.map((r) => r.name)).toEqual(["Bracket", "Widget"]); + }); + + it("returns an empty array when there are no active stock items", async () => { + seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: false, supplier: "sup-1" }); + const result = await listStockItemsForStocktake(pb as never); + expect(result).toEqual([]); + }); + + it("excludes items with is_active = false", async () => { + seed("si-a", { name: "Widget", sku: "WID-1", location: "A1", unit: "ea", current_qty: 10, is_active: true, supplier: "sup-1" }); + seed("si-b", { name: "Bracket", sku: "BRK-1", location: "A2", unit: "ea", current_qty: 20, is_active: false, supplier: "sup-1" }); + const result = await listStockItemsForStocktake(pb as never); + expect(result.map((r) => r.name)).toEqual(["Widget"]); + }); +}); diff --git a/src/lib/server/stock-items-service.ts b/src/lib/server/stock-items-service.ts new file mode 100644 index 0000000..c3bbb5c --- /dev/null +++ b/src/lib/server/stock-items-service.ts @@ -0,0 +1,42 @@ +import type { StockItem, Supplier } from "@/lib/types"; + +/** + * PB client type kept loose so both the real `PocketBase` instance + * and the test stub satisfy the contract. The service only uses + * `pb.collection("stock_items").getFullList(...)`. + */ +export type StockItemsPb = { + collection: (id: string) => { + getFullList: (opts?: Record) => Promise; + }; +}; + +/** + * Fetch every active stock item sorted by name, with the supplier + * relation pre-expanded. The "Stocktake PDF" download endpoint + * uses this to render the printable stocktake list. + * + * Differs from the inline `stock_items` reads on the dashboard + * page (which filter by `is_active = true` AND sort by `current_qty`) + * because a stocktake wants all SKUs alphabetically, not the + * low-stock ones first. + * + * No pagination — the stocktake is always the whole list. If the + * catalogue grows past `getFullList`'s default page size, PB will + * fetch subsequent pages transparently. The PDF renderer downstream + * is the only consumer; if it becomes a bottleneck, this is the + * place to swap in a paginated stream. + */ +export async function listStockItemsForStocktake( + pb: StockItemsPb +): Promise { + return (await pb.collection("stock_items").getFullList({ + filter: "is_active = true", + sort: "name", + expand: "supplier", + })) as StockItem[]; +} + +// Re-export the type so the route can alias it without poking +// into `@/lib/types` directly. +export type { StockItem, Supplier };