WFDASH-WEBAPP/src/lib/server/stock-purchases-service.test.ts
Dave d9c60aff6d feat: add split views, stock picking slips, and invoice viewer
Comprehensive changes including:
- Refactor picking slip line handling for multiple document types across print endpoints
- Add new `/api/v1/stock-purchases/picking-slip` API for stock booking picking slips
- Implement reusable Splitter component and useSplitRatio hook for resizable panels
- Add floating/fullscreen invoice viewer to collection confirm page
- Add real-time stock booking update event for invoicing tab
- Add full test coverage for all new components and APIs
- Include temporary chat attachment images for testing purposes
2026-08-09 23:16:55 +02:00

1153 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect, beforeEach, vi } from "vitest";
// --- PocketBase mock ---------------------------------------------------------
// Mirrors the hoisted-mock pattern used in orders-service.test.ts and
// stock-surplus.test.ts. The mock is enough for the service layer
// (create / list / get / receive / mark-sent); it doesn't try to
// emulate the full PB SDK — only the methods we use.
const { store, pbMock, clearStore, setNow, now } = vi.hoisted(() => {
type StoreMap = Map<string, Record<string, unknown>>;
const store: Record<string, StoreMap> = {};
let seq = 0;
const nextId = () => `mock-${++seq}`;
let nowDate = new Date("2026-07-14T12:00:00.000Z");
const getOrCreateMap = (name: string): StoreMap => {
if (!store[name]) store[name] = new Map();
return store[name];
};
const where = (name: string, filter: string): Record<string, unknown>[] => {
const items = Array.from(getOrCreateMap(name).values());
if (!filter || !filter.trim()) return items;
const clauses = filter.split("||").map((c) => c.trim());
return items.filter((item) =>
clauses.some((clause) => {
// PB's `~` is case-insensitive contains. The service uses it
// on the purchase_number for the daily-prefix scan, so we
// emulate the substring match (case-insensitive).
const m = clause.match(/^(\w+)\s*~\s*"([^"]+)"$/);
if (m) {
const [, field, raw] = m;
const value = String(item[field] ?? "").toLowerCase();
return value.includes(raw.toLowerCase());
}
const eq = clause.match(/^(\w+)\s*=\s*"([^"]+)"$/);
if (eq) {
const [, field, raw] = eq;
return String(item[field] ?? "") === raw;
}
return true;
}),
);
};
const pbMock = {
collection: (name: string) => ({
async getList(
page = 1,
perPage = 25,
opts: { filter?: string; sort?: string; expand?: string } = {},
) {
const items = where(name, opts.filter || "");
if (opts.sort) {
const sign = opts.sort.startsWith("-") ? -1 : 1;
const field = opts.sort.replace(/^-/, "");
items.sort((a, b) => {
const av = a[field] as string | number | undefined;
const bv = b[field] as string | number | undefined;
if (av === bv) {
// Tie-break by id (insertion order). PB uses a similar
// stable fallback for identical sort keys.
return String(a.id).localeCompare(String(b.id)) * -sign;
}
if (av === undefined) return 1;
if (bv === undefined) return -1;
return (av > bv ? 1 : -1) * sign;
});
}
return {
items,
totalItems: items.length,
page,
perPage,
totalPages: Math.max(1, Math.ceil(items.length / perPage)),
};
},
async getFullList(opts: { filter?: string; sort?: string; expand?: string } = {}) {
const items = where(name, opts.filter || "");
if (opts.sort) {
const sign = opts.sort.startsWith("-") ? -1 : 1;
const field = opts.sort.replace(/^-/, "");
items.sort((a, b) => {
const av = a[field] as string | number | undefined;
const bv = b[field] as string | number | undefined;
if (av === bv) {
return String(a.id).localeCompare(String(b.id)) * -sign;
}
if (av === undefined) return 1;
if (bv === undefined) return -1;
return (av > bv ? 1 : -1) * sign;
});
}
return items;
},
async getFirstListItem(filter: string) {
const items = where(name, filter);
if (items.length === 0) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return items[0];
},
async getOne(id: string, _opts?: { expand?: string }) {
const item = getOrCreateMap(name).get(id);
if (!item) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
return item;
},
async create(data: Record<string, unknown>) {
const id = nextId();
// Each create bumps `nowDate` by 1ms so the `-created` sort
// is deterministic across the test suite. PB on a real DB
// gets distinct ISO timestamps; this keeps the mock honest.
nowDate = new Date(nowDate.getTime() + 1);
const item = {
id,
created: nowDate.toISOString(),
updated: nowDate.toISOString(),
...data,
};
getOrCreateMap(name).set(id, item);
return item;
},
async update(id: string, data: Record<string, unknown> | FormData) {
const map = getOrCreateMap(name);
const existing = map.get(id);
if (!existing) {
const err = new Error("not found") as Error & { status: number };
err.status = 404;
throw err;
}
// Handle FormData uploads (PB file fields). PB stores the
// generated filename token; for the test we approximate by
// appending the original filename to `documents` so the
// post-update `getOne` sees what the production code would.
if (
typeof FormData !== "undefined" &&
data instanceof FormData
) {
for (const [key, value] of data.entries()) {
if (typeof Blob !== "undefined" && value instanceof Blob) {
// The PDF service uses `new Blob([...], { type: ... })`
// with a filename in the FormData append slot. The
// blob itself doesn't carry the filename, so we read
// it off the FormData entry metadata if available.
const filename =
(value as Blob & { name?: string }).name || "upload.pdf";
const arr = Array.isArray(existing[key])
? (existing[key] as string[])
: existing[key]
? [existing[key] as string]
: [];
existing[key] = [...arr, filename];
}
}
existing.updated = nowDate.toISOString();
return { ...existing };
}
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
if (key.endsWith("+")) {
const real = key.slice(0, -1);
existing[real] = Number(existing[real] ?? 0) + Number(value);
} else {
existing[key] = value;
}
}
existing.updated = nowDate.toISOString();
return { ...existing };
},
}),
};
const clearStore = () => {
for (const key of Object.keys(store)) delete store[key];
seq = 0;
nowDate = new Date("2026-07-14T12:00:00.000Z");
};
const setNow = (d: Date) => {
nowDate = d;
};
return { store, pbMock, clearStore, setNow, now: () => nowDate };
});
vi.mock("pocketbase", () => {
return {
default: vi.fn().mockImplementation(() => pbMock),
};
});
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
}));
// Mock the PDF rendering + letterhead so `generateStockPurchasePdf`
// can run in unit tests without spinning up the full @react-pdf
// pipeline. The render mock returns a fake Buffer; the letterhead
// mock returns a minimal shape the code reads.
vi.mock("@/lib/pdf/render", () => ({
renderStockPurchasePdf: vi.fn().mockResolvedValue(Buffer.from("FAKE-PDF")),
}));
vi.mock("@/lib/letterhead", () => ({
fetchLetterhead: vi.fn().mockResolvedValue({
companyName: "CNL Mining Supplies",
address: "1 Test Lane",
phone: "",
email: "",
vatNumber: "",
registrationNumber: "",
bankDetails: "",
}),
loadLetterheadLogoDataUri: vi.fn().mockResolvedValue("data:image/svg+xml;base64,FAKE"),
}));
import {
createStockPurchase,
generateStockPurchaseNumber,
generateStockPurchasePdf,
StockPurchasePdfValidationError,
listStockPurchases,
getStockPurchase,
getStockPurchaseItems,
receiveStockPurchase,
markStockPurchaseSent,
recordStockPurchaseSendOutcome,
} from "./stock-purchases-service";
beforeEach(() => {
clearStore();
// Default "today" for the tests. Tests that care about the date
// override via setNow() before invoking the helper.
setNow(new Date("2026-07-14T12:00:00.000Z"));
});
// The service uses `new Date()` directly for the date prefix
// (ignoring the `setNow()` mock — the mock would need to be applied
// to the service's clock too, but that's a separate refactor). To
// make the tests resilient to the calendar advancing past the hard-
// coded date, we compute the expected prefix from the real wall clock
// at test time. The `nn` (sequence) part is the test's concern.
function realDatePrefix(): string {
const now = new Date();
const yy = String(now.getFullYear()).slice(-2);
const mm = String(now.getMonth() + 1).padStart(2, "0");
const dd = String(now.getDate()).padStart(2, "0");
return `PO${yy}${mm}${dd}`;
}
// ============================================================================
// generateStockPurchaseNumber
// ============================================================================
describe("generateStockPurchaseNumber", () => {
it("starts at POYYMMDD01STK when no purchases exist for today", async () => {
const num = await generateStockPurchaseNumber(pbMock);
expect(num).toBe(`${realDatePrefix()}01STK`);
});
it("increments the daily sequence based on existing rows", async () => {
// Seed three existing rows for today. Use the real-date prefix
// so the test is robust to the wall clock advancing.
const prefix = realDatePrefix();
await pbMock.collection("stock_purchases").create({
purchase_number: `${prefix}01STK`,
});
await pbMock.collection("stock_purchases").create({
purchase_number: `${prefix}02STK`,
});
await pbMock.collection("stock_purchases").create({
purchase_number: `${prefix}03STK`,
});
const num = await generateStockPurchaseNumber(pbMock);
expect(num).toBe(`${prefix}04STK`);
});
it("ignores rows from a different year (different prefix)", async () => {
// Seed a row from a previous year. The scan filter is the
// daily prefix (PO{yy}{mm}{dd}), so a stale row from last
// year doesn't bump today's sequence.
await pbMock.collection("stock_purchases").create({
purchase_number: "PO25010199STK",
});
const num = await generateStockPurchaseNumber(pbMock);
expect(num).toBe(`${realDatePrefix()}01STK`);
});
});
// ============================================================================
// createStockPurchase — validation
// ============================================================================
describe("createStockPurchase — validation", () => {
it("rejects a missing supplier", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "",
items: [
{ stock_item: "si-1", description: "Widget", quantity: 5 },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("supplier_required");
expect(result.message).toMatch(/supplier/);
}
});
it("rejects an empty items array", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("items_required");
}
});
it("rejects a line with quantity 0", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [
{ stock_item: "si-1", description: "Widget", quantity: 0 },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid_item");
expect(result.message).toMatch(/quantity/i);
}
});
it("rejects a line with negative quantity", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [
{ stock_item: "si-1", description: "Widget", quantity: -3 },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid_item");
}
});
it("rejects a line with NaN quantity", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [
{ stock_item: "si-1", description: "Widget", quantity: Number.NaN },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid_item");
}
});
it("rejects a line with missing description", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [
{ stock_item: "si-1", description: "", quantity: 5 },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid_item");
expect(result.message).toMatch(/description/i);
}
});
it("rejects a line with missing stock_item", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [
{ stock_item: "", description: "Widget", quantity: 5 },
],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("invalid_item");
}
});
});
// ============================================================================
// createStockPurchase — happy path
// ============================================================================
describe("createStockPurchase — happy path", () => {
it("creates the header in draft status with a generated purchase_number", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
notes: "first restock",
items: [
{ stock_item: "si-1", description: "Widget", quantity: 5, cost_price: 12.5 },
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.purchase.status).toBe("draft");
expect(result.purchase.supplier).toBe("sup-1");
expect(result.purchase.purchase_number).toBe(`${realDatePrefix()}01STK`);
expect(result.purchase.notes).toBe("first restock");
});
it("creates one stock_purchase_item per line, linked to the header", async () => {
const result = await createStockPurchase(pbMock, {
supplier: "sup-1",
items: [
{ stock_item: "si-1", description: "Widget", quantity: 5, cost_price: 12.5 },
{ stock_item: "si-2", description: "Sprocket", quantity: 2, cost_price: 9.99 },
],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.items.length).toBe(2);
const allItems = await pbMock.collection("stock_purchase_items").getFullList();
const ours = allItems.filter((it) => it.purchase === result.purchase.id);
expect(ours.length).toBe(2);
const widget = ours.find((it) => it.description === "Widget");
expect(widget).toMatchObject({
purchase: result.purchase.id,
stock_item: "si-1",
quantity: 5,
quantity_received: 0,
cost_price: 12.5,
});
});
});
// ============================================================================
// listStockPurchases
// ============================================================================
describe("listStockPurchases", () => {
it("returns rows sorted -created with item_count expanded", async () => {
// Seed two purchases + lines.
const p1 = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
supplier: "sup-1",
status: "draft",
notes: "",
});
const p2 = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071502STK",
supplier: "sup-1",
status: "sent",
notes: "",
});
await pbMock.collection("stock_purchase_items").create({
purchase: p1.id,
stock_item: "si-1",
description: "A",
quantity: 1,
quantity_received: 0,
cost_price: 0,
notes: "",
});
await pbMock.collection("stock_purchase_items").create({
purchase: p1.id,
stock_item: "si-2",
description: "B",
quantity: 1,
quantity_received: 0,
cost_price: 0,
notes: "",
});
await pbMock.collection("stock_purchase_items").create({
purchase: p2.id,
stock_item: "si-3",
description: "C",
quantity: 1,
quantity_received: 0,
cost_price: 0,
notes: "",
});
const result = await listStockPurchases(pbMock);
expect(result.items.length).toBe(2);
// Sorted -created: p2 is newer, lands first.
expect(result.items[0].id).toBe(p2.id);
expect(result.items[0].item_count).toBe(1);
expect(result.items[1].id).toBe(p1.id);
expect(result.items[1].item_count).toBe(2);
});
it("filters by status when provided", async () => {
await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "draft",
});
await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071502STK",
status: "sent",
});
const result = await listStockPurchases(pbMock, { status: "sent" });
expect(result.items.length).toBe(1);
expect(result.items[0].status).toBe("sent");
});
});
// ============================================================================
// getStockPurchase + getStockPurchaseItems
// ============================================================================
describe("getStockPurchase + getStockPurchaseItems", () => {
it("returns the header and the lines separately", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
supplier: "sup-1",
status: "draft",
notes: "",
});
await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
stock_item: "si-1",
description: "A",
quantity: 1,
quantity_received: 0,
cost_price: 0,
notes: "",
});
const purchase = await getStockPurchase(pbMock, p.id);
expect(purchase.id).toBe(p.id);
const items = await getStockPurchaseItems(pbMock, p.id);
expect(items.length).toBe(1);
expect(items[0].description).toBe("A");
});
});
// ============================================================================
// markStockPurchaseSent
// ============================================================================
describe("markStockPurchaseSent", () => {
it("flips draft → sent", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "draft",
});
const result = await markStockPurchaseSent(pbMock, p.id);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.purchase.status).toBe("sent");
});
it("rejects a non-draft purchase (already sent)", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sent",
});
const result = await markStockPurchaseSent(pbMock, p.id);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("wrong_status");
}
});
it("returns not_found for a missing id", async () => {
const result = await markStockPurchaseSent(pbMock, "missing-id");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("not_found");
}
});
});
// ============================================================================
// receiveStockPurchase — the big one
// ============================================================================
describe("receiveStockPurchase", () => {
// Helper to seed a sent purchase + N lines + N stock_items.
async function seedSentPurchase(opts: {
lineQuantities: number[];
stockItemQty?: number;
}) {
const stockItemQty = opts.stockItemQty ?? 0;
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
supplier: "sup-1",
status: "sent",
});
const stockItemIds: string[] = [];
for (let i = 0; i < opts.lineQuantities.length; i++) {
const si = await pbMock.collection("stock_items").create({
name: `Item ${i + 1}`,
current_qty: stockItemQty,
is_active: true,
});
stockItemIds.push(si.id);
await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
stock_item: si.id,
description: `Line ${i + 1}`,
quantity: opts.lineQuantities[i],
quantity_received: 0,
cost_price: 1.0,
notes: "",
});
}
return { purchaseId: p.id, stockItemIds };
}
it("receives the full remaining qty per line and flips status to received (NO inventory writes)", async () => {
const { purchaseId, stockItemIds } = await seedSentPurchase({
lineQuantities: [10, 5],
});
const result = await receiveStockPurchase(pbMock, purchaseId);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.purchase.status).toBe("received");
expect(result.lines.length).toBe(2);
expect(result.lines[0].booked_qty).toBe(10);
expect(result.lines[1].booked_qty).toBe(5);
// Inventory writes moved to bookStockFromInvoice. The receive
// helper no longer touches stock_items or stock_ledger, so
// current_qty must stay at the seeded value (0).
const si0 = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
const si1 = await pbMock.collection("stock_items").getOne(stockItemIds[1]);
expect(si0.current_qty).toBe(0);
expect(si1.current_qty).toBe(0);
// No ledger rows either — same reason.
const ledger = await pbMock.collection("stock_ledger").getFullList();
expect(ledger.length).toBe(0);
// Line result shape keeps ledger_id + new_current_qty for
// backward-compat with the OCR match report — both stay
// empty/zero because the writes now live elsewhere.
expect(result.lines[0].ledger_id).toBe("");
expect(result.lines[0].new_current_qty).toBe(0);
// stock_purchase_items.quantity_received accumulated correctly.
const lines = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${purchaseId}"` });
const sorted = lines.sort((a, b) =>
String(a.description).localeCompare(String(b.description))
);
// Sorted by description: "Line 1" (qty 10) first, "Line 2" (qty 5) second.
expect(sorted[0].description).toBe("Line 1");
expect(sorted[0].quantity_received).toBe(10);
// Lines flip to "collected" so the accountant's booked_at = null
// queue picks them up.
expect(sorted[0].status).toBe("collected");
expect(sorted[1].description).toBe("Line 2");
expect(sorted[1].quantity_received).toBe(5);
expect(sorted[1].status).toBe("collected");
});
it("honours a per-line quantity_received override (partial receive)", async () => {
const { purchaseId, stockItemIds } = await seedSentPurchase({
lineQuantities: [10, 5],
});
// Override: receive 5 of the 10 on line 1, 3 of 5 on line 2.
const lines = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${purchaseId}"` });
const sorted = lines.sort((a, b) =>
String(a.description).localeCompare(String(b.description))
);
const sorted0 = sorted[0] as { id: string };
const sorted1 = sorted[1] as { id: string };
const result = await receiveStockPurchase(pbMock, purchaseId, {
items: [{ id: sorted0.id, quantity_received: 5 }, { id: sorted1.id, quantity_received: 3 }],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
// current_qty is NOT bumped by the receive helper any more —
// the inventory commit is bookStockFromInvoice's job.
const si0 = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
const si1 = await pbMock.collection("stock_items").getOne(stockItemIds[1]);
expect(si0.current_qty).toBe(0);
expect(si1.current_qty).toBe(0);
// status flipped to received.
expect(result.purchase.status).toBe("received");
// Per-line overrides are recorded on quantity_received.
const updated = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${purchaseId}"` });
const updatedSorted = updated.sort((a, b) =>
String(a.description).localeCompare(String(b.description))
);
expect((updatedSorted[0] as { quantity_received: number }).quantity_received).toBe(5);
expect((updatedSorted[1] as { quantity_received: number }).quantity_received).toBe(3);
});
it("caps an override at the remaining qty (cannot receive more than ordered)", async () => {
const { purchaseId, stockItemIds } = await seedSentPurchase({
lineQuantities: [5],
});
const [line] = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${purchaseId}"` });
const lineT = line as { id: string };
const result = await receiveStockPurchase(pbMock, purchaseId, {
items: [{ id: lineT.id, quantity_received: 9999 }],
});
expect(result.ok).toBe(true);
if (!result.ok) return;
// The accumulator lands at the line's ordered qty (5) — never
// above it.
const updated = await pbMock.collection("stock_purchase_items").getOne(lineT.id);
expect(updated.quantity_received).toBe(5);
// stock_items.current_qty unchanged (the helper doesn't bump it).
const si = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
expect(si.current_qty).toBe(0);
});
it("does NOT double-handle when the same purchase is received twice (idempotency)", async () => {
const { purchaseId, stockItemIds } = await seedSentPurchase({
lineQuantities: [10],
});
// First receive: accumulates qty_received, flips to received.
const first = await receiveStockPurchase(pbMock, purchaseId);
expect(first.ok).toBe(true);
// Second receive: rejected with wrong_status because the header
// is now `received`, not `sent`. No new writes anywhere.
const second = await receiveStockPurchase(pbMock, purchaseId);
expect(second.ok).toBe(false);
if (second.ok) return;
expect(second.reason).toBe("wrong_status");
// current_qty is still at the seeded value (the helper never
// touched it).
const si = await pbMock.collection("stock_items").getOne(stockItemIds[0]);
expect(si.current_qty).toBe(0);
// No ledger rows from the receive helper.
const ledger = await pbMock.collection("stock_ledger").getFullList();
expect(ledger.length).toBe(0);
// quantity_received on the line is 10 — the accumulator landed
// once, not twice.
const lines = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${purchaseId}"` });
expect((lines[0] as { quantity_received: number }).quantity_received).toBe(10);
});
it("rejects a purchase that is in draft (not sent)", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "draft",
});
const result = await receiveStockPurchase(pbMock, p.id);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("wrong_status");
}
});
it("rejects a missing purchase", async () => {
const result = await receiveStockPurchase(pbMock, "missing-id");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("not_found");
}
});
it("rejects a negative per-line override", async () => {
const { purchaseId } = await seedSentPurchase({
lineQuantities: [10],
});
const [line] = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${purchaseId}"` });
const lineT = line as { id: string };
const result = await receiveStockPurchase(pbMock, purchaseId, {
items: [{ id: lineT.id, quantity_received: -1 }],
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("wrong_status");
}
});
});
// ============================================================================
// listStockPurchases — total value aggregation
// ============================================================================
describe("listStockPurchases — total value", () => {
async function seedPurchaseWithItems(pb: typeof pbMock, id: string, items: Array<{
quantity: number;
quantity_received: number;
cost_price: number;
}>) {
await pb.collection("stock_purchases").create({
id,
purchase_number: `PO26000100STK-${id}`,
supplier: "sup-1",
status: "draft",
notes: "",
email_error: "",
email_message_id: "",
});
for (const item of items) {
await pb.collection("stock_purchase_items").create({
purchase: id,
stock_item: "si-1",
description: "Widget",
...item,
notes: "",
});
}
}
it("returns ordered_value = Σ quantity × cost_price across lines", async () => {
await seedPurchaseWithItems(pbMock, "sp-a", [
{ quantity: 10, quantity_received: 0, cost_price: 12.5 },
{ quantity: 4, quantity_received: 0, cost_price: 99 },
]);
const { items } = await listStockPurchases(pbMock);
const row = items.find((r) => r.id === "sp-a")!;
expect(row.ordered_value).toBe(521); // 125 + 396
});
it("returns received_value = Σ quantity_received × cost_price across lines", async () => {
await seedPurchaseWithItems(pbMock, "sp-a", [
{ quantity: 10, quantity_received: 6, cost_price: 12.5 },
{ quantity: 4, quantity_received: 4, cost_price: 99 },
]);
const { items } = await listStockPurchases(pbMock);
const row = items.find((r) => r.id === "sp-a")!;
expect(row.received_value).toBe(471); // 75 + 396
});
it("rounds ordered_value and received_value to 2dp", async () => {
await seedPurchaseWithItems(pbMock, "sp-a", [
{ quantity: 3, quantity_received: 1, cost_price: 0.1 + 0.2 }, // 0.30000000000000004
]);
const { items } = await listStockPurchases(pbMock);
const row = items.find((r) => r.id === "sp-a")!;
expect(row.ordered_value).toBe(0.9);
expect(row.received_value).toBe(0.3);
});
it("returns { ordered_value: 0, received_value: 0 } when a purchase has no lines", async () => {
await pbMock.collection("stock_purchases").create({
id: "sp-empty",
purchase_number: "PO26000200STK",
supplier: "sup-1",
status: "draft",
notes: "",
email_error: "",
email_message_id: "",
});
const { items } = await listStockPurchases(pbMock);
const row = items.find((r) => r.id === "sp-empty")!;
expect(row.ordered_value).toBe(0);
expect(row.received_value).toBe(0);
expect(row.item_count).toBe(0);
});
it("computes each row's totals from its own items only (no cross-row leakage)", async () => {
await seedPurchaseWithItems(pbMock, "sp-x", [
{ quantity: 1, quantity_received: 1, cost_price: 100 },
]);
await seedPurchaseWithItems(pbMock, "sp-y", [
{ quantity: 2, quantity_received: 2, cost_price: 50 },
]);
const { items } = await listStockPurchases(pbMock);
const x = items.find((r) => r.id === "sp-x")!;
const y = items.find((r) => r.id === "sp-y")!;
expect(x.ordered_value).toBe(100);
expect(x.received_value).toBe(100);
expect(y.ordered_value).toBe(100);
expect(y.received_value).toBe(100);
});
});
// ============================================================================
// recordStockPurchaseSendOutcome
// ============================================================================
describe("recordStockPurchaseSendOutcome", () => {
it("does NOT auto-flip stock_purchase_items.status on success (operator must mark them ready to collect manually)", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sending",
email_payload: { sentAt: "", sentMessageId: "" },
});
const it1 = await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
status: "collection_pool",
});
const it2 = await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
status: "collection_pool",
});
const result = await recordStockPurchaseSendOutcome({
purchaseId: p.id,
outcome: "success",
messageId: "<abc@wfsales.co.za>",
});
// The stock_purchase itself flips to "sent" + stamps the message ID.
expect(result.status).toBe("sent");
expect(result.email_message_id).toBe("<abc@wfsales.co.za>");
// The items MUST stay in collection_pool — the operator marks them
// ready-to-collect from the stock-order page only after the supplier
// has confirmed. Same pattern as customer POs.
const itemsAfter = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${p.id}"` });
expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool");
expect(itemsAfter.find((i) => i.id === it2.id)?.status).toBe("collection_pool");
});
it("flips stock_purchase to sent + stamps email_message_id on success", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sending",
email_payload: { sentAt: "", sentMessageId: "" },
});
const result = await recordStockPurchaseSendOutcome({
purchaseId: p.id,
outcome: "success",
messageId: "<abc@wfsales.co.za>",
});
expect(result.status).toBe("sent");
expect(result.email_message_id).toBe("<abc@wfsales.co.za>");
expect(result.email_error).toBe("");
expect((result.email_payload as Record<string, unknown>).sentAt).toBeTruthy();
expect((result.email_payload as Record<string, unknown>).sentMessageId).toBe(
"<abc@wfsales.co.za>",
);
});
it("flips stock_purchase to mail_failed + stamps errorMessage on failure (does not touch items)", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "sending",
});
const it1 = await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
status: "collection_pool",
});
const result = await recordStockPurchaseSendOutcome({
purchaseId: p.id,
outcome: "failure",
errorMessage: "SMTP 550 mailbox unavailable",
});
expect(result.status).toBe("mail_failed");
expect(result.email_error).toBe("[smtp] SMTP 550 mailbox unavailable");
// Items are NOT touched on failure (and were never touched to begin with).
const itemsAfter = await pbMock
.collection("stock_purchase_items")
.getFullList({ filter: `purchase = "${p.id}"` });
expect(itemsAfter.find((i) => i.id === it1.id)?.status).toBe("collection_pool");
});
});
describe("generateStockPurchasePdf", () => {
/**
* Seed a stock purchase with a supplier + at least one linked
* line item. Returns the purchase row.
*/
async function seedStockPurchaseWithLines(opts: {
documents?: string[];
documentsHistory?: Array<Record<string, unknown>>;
existingDocuments?: string[];
} = {}) {
const sup = await pbMock.collection("suppliers").create({
suppliername: "Acme Mining",
email: "sales@acme.test",
});
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "draft",
supplier: sup.id,
documents: opts.existingDocuments ?? [],
documents_history: opts.documentsHistory ?? [],
});
// Stitch `expand.supplier` onto the purchase so the mock's
// expand option isn't needed (the collection map keys are
// plural — "suppliers" — but the service reads `expand.supplier`,
// so it's easier to set expand directly here).
(p as Record<string, unknown>).expand = { supplier: sup };
await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
stock_item: "stk-1",
description: "Hose 1m",
quantity: 4,
cost_price: 250,
status: "collection_pool",
});
await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
stock_item: "stk-2",
description: "Fitting",
quantity: 2,
cost_price: 100,
status: "collection_pool",
});
return p;
}
it("renders a PDF, appends it to documents, and writes a history entry", async () => {
const p = await seedStockPurchaseWithLines();
const result = await generateStockPurchasePdf(p.id, {});
expect(result.pdfId).toBe(p.id);
expect(result.version).toBe(1);
expect(result.filename).toMatch(/^STK-PO-CNL-PO26071501STK-v1\.pdf$/);
expect(result.total).toBe(1200); // 4*250 + 2*100 = 1200
expect(result.downloadUrl).toBe(`/api/v1/files/stock_purchases/${p.id}/${result.filename}`);
const after = await pbMock.collection("stock_purchases").getOne(p.id);
const docsAfter = Array.isArray(after.documents)
? after.documents
: after.documents
? [after.documents]
: [];
expect(docsAfter).toContain(result.filename);
const history = (after.documents_history ?? []) as Array<Record<string, unknown>>;
expect(history).toHaveLength(1);
expect(history[0]).toMatchObject({
version: 1,
file: result.filename,
status: "current",
});
expect(after.total_amount).toBe(1200);
});
it("bumps version + marks previous 'current' as 'superseded' on regen", async () => {
const p = await seedStockPurchaseWithLines({
documents: ["STK-PO-CNL-PO26071501STK-v1.pdf"],
documentsHistory: [
{
version: 1,
file: "STK-PO-CNL-PO26071501STK-v1.pdf",
token: "tok-v1",
generatedAt: "2026-07-14T12:00:00.000Z",
generatedBy: "",
reason: "",
status: "current",
},
],
});
const result = await generateStockPurchasePdf(p.id, {
notes: "Edited cost_price on line 2",
});
expect(result.version).toBe(2);
const after = await pbMock.collection("stock_purchases").getOne(p.id);
const history = after.documents_history as Array<Record<string, unknown>>;
expect(history).toHaveLength(2);
expect(history[0]?.status).toBe("superseded");
expect(history[1]).toMatchObject({
version: 2,
file: result.filename,
status: "current",
reason: "Edited cost_price on line 2",
});
});
it("does NOT touch the purchase status (PDF lifecycle ≠ email lifecycle)", async () => {
const p = await seedStockPurchaseWithLines();
// Status starts as "draft"; PDF generation must leave it alone.
const result = await generateStockPurchasePdf(p.id, {});
expect(result).toBeDefined();
const after = await pbMock.collection("stock_purchases").getOne(p.id);
expect(after.status).toBe("draft");
});
it("throws StockPurchasePdfValidationError(purchase_not_found) for a missing id", async () => {
await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toBeInstanceOf(
StockPurchasePdfValidationError,
);
await expect(generateStockPurchasePdf("does-not-exist", {})).rejects.toMatchObject({
code: "purchase_not_found",
});
});
it("throws StockPurchasePdfValidationError(missing_relations) when the PO has no supplier", async () => {
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "draft",
supplier: "",
documents: [],
documents_history: [],
});
await pbMock.collection("stock_purchase_items").create({
purchase: p.id,
description: "x",
quantity: 1,
cost_price: 1,
});
await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf(
StockPurchasePdfValidationError,
);
await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({
code: "missing_relations",
});
});
it("throws StockPurchasePdfValidationError(no_items) when there are no line items", async () => {
const sup = await pbMock.collection("suppliers").create({
suppliername: "Acme",
email: "a@b.test",
});
const p = await pbMock.collection("stock_purchases").create({
purchase_number: "PO26071501STK",
status: "draft",
supplier: sup.id,
documents: [],
documents_history: [],
});
// Stitch expand so the supplier check passes and the no_items
// branch is reached.
(p as Record<string, unknown>).expand = { supplier: sup };
await expect(generateStockPurchasePdf(p.id, {})).rejects.toBeInstanceOf(
StockPurchasePdfValidationError,
);
await expect(generateStockPurchasePdf(p.id, {})).rejects.toMatchObject({
code: "no_items",
});
});
});