747 lines
24 KiB
TypeScript
747 lines
24 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
|
|
const { store, pbMock, clearStore } = vi.hoisted(() => {
|
|
type StoreMap = Map<string, Record<string, unknown>>;
|
|
const store: Record<string, StoreMap> = {};
|
|
let seq = 0;
|
|
const nextId = () => `mock-${++seq}`;
|
|
|
|
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;
|
|
|
|
// Split on &&, then for each conjunct split on ||. Parens are
|
|
// grouping-only and can be stripped — none of the filters used
|
|
// here have nested groups mixing ANDs and ORs.
|
|
const conjuncts = filter.split("&&").map((c) => c.trim());
|
|
return items.filter((item) =>
|
|
conjuncts.every((conjunct) => {
|
|
const disjuncts = conjunct.split("||").map((d) => d.trim());
|
|
return disjuncts.some((d) => {
|
|
const clause = d.replace(/^\(/, "").replace(/\)$/, "").trim();
|
|
const m = clause.match(/^(\w+)\s*(=|!=|~|>=?|<=?)\s*(.+)$/);
|
|
if (!m) return true;
|
|
const [, field, op, rawValue] = m;
|
|
let value: string | number | boolean;
|
|
if (rawValue.startsWith('"') && rawValue.endsWith('"')) {
|
|
value = rawValue.slice(1, -1);
|
|
} else if (rawValue === "true") {
|
|
value = true;
|
|
} else if (rawValue === "false") {
|
|
value = false;
|
|
} else {
|
|
value = Number(rawValue);
|
|
}
|
|
const actual = item[field];
|
|
if (op === "=") return actual == value;
|
|
if (op === "!=") return actual != value;
|
|
if (op === "~") {
|
|
return (
|
|
typeof actual === "string" &&
|
|
typeof value === "string" &&
|
|
actual.toLowerCase().includes(value.toLowerCase())
|
|
);
|
|
}
|
|
if (op === ">") return Number(actual) > Number(value);
|
|
if (op === "<") return Number(actual) < Number(value);
|
|
return true;
|
|
});
|
|
}),
|
|
);
|
|
};
|
|
|
|
const pbMock = {
|
|
collection: (name: string) => ({
|
|
async getList(
|
|
_page = 1,
|
|
perPage = 25,
|
|
opts: { filter?: string; sort?: string; expand?: string } = {},
|
|
) {
|
|
let items = where(name, opts.filter || "");
|
|
if (opts.sort) {
|
|
const desc = opts.sort.startsWith("-");
|
|
const field = desc ? opts.sort.slice(1) : opts.sort;
|
|
items = [...items].sort((a, b) => {
|
|
const av = a[field] as number | string | undefined;
|
|
const bv = b[field] as number | string | undefined;
|
|
if (av === bv) return 0;
|
|
if (av === undefined) return 1;
|
|
if (bv === undefined) return -1;
|
|
const cmp = av < bv ? -1 : 1;
|
|
return desc ? -cmp : cmp;
|
|
});
|
|
}
|
|
const sliced = items.slice(0, perPage);
|
|
if (opts.expand) {
|
|
const expandFields = opts.expand.split(",").map((f) => f.trim());
|
|
for (const item of sliced) {
|
|
const expandObj: Record<string, unknown> = {};
|
|
for (const field of expandFields) {
|
|
const relatedId = item[field];
|
|
if (relatedId === undefined || relatedId === null) continue;
|
|
for (const collName of Object.keys(store)) {
|
|
const related = store[collName].get(String(relatedId));
|
|
if (related) {
|
|
expandObj[field] = related;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
item.expand = expandObj;
|
|
}
|
|
}
|
|
return { items: sliced, totalItems: items.length, page: 1, perPage };
|
|
},
|
|
async getFullList(opts: { filter?: string } = {}) {
|
|
return where(name, opts.filter || "");
|
|
},
|
|
async getOne(id: 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 item = {
|
|
id: nextId(),
|
|
created: new Date().toISOString(),
|
|
updated: new Date().toISOString(),
|
|
...data,
|
|
};
|
|
getOrCreateMap(name).set(item.id, item);
|
|
return item;
|
|
},
|
|
async update(id: string, data: Record<string, unknown>) {
|
|
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;
|
|
}
|
|
for (const [key, value] of Object.entries(data)) {
|
|
existing[key] = value;
|
|
}
|
|
existing.updated = new Date().toISOString();
|
|
return { ...existing };
|
|
},
|
|
async delete(id: string) {
|
|
const map = getOrCreateMap(name);
|
|
if (!map.has(id)) {
|
|
const err = new Error("not found") as Error & { status: number };
|
|
err.status = 404;
|
|
throw err;
|
|
}
|
|
map.delete(id);
|
|
return { id };
|
|
},
|
|
}),
|
|
};
|
|
|
|
const clearStore = () => {
|
|
for (const key of Object.keys(store)) delete store[key];
|
|
seq = 0;
|
|
};
|
|
|
|
return { store, pbMock, clearStore };
|
|
});
|
|
|
|
vi.mock("pocketbase", () => ({ default: vi.fn().mockImplementation(() => pbMock) }));
|
|
vi.mock("./pocketbase-admin", () => ({
|
|
getAdminPocketBase: vi.fn().mockResolvedValue(pbMock),
|
|
}));
|
|
|
|
import {
|
|
addQuickDraftLine,
|
|
getStockItemSupplierHistory,
|
|
listOpenDrafts,
|
|
removeQuickDraftLine,
|
|
searchStockItemsForQuickDraft,
|
|
} from "./stock-quick-draft-service";
|
|
|
|
beforeEach(() => clearStore());
|
|
|
|
describe("searchStockItemsForQuickDraft", () => {
|
|
it("returns active stock_items whose sku or name matches the query", async () => {
|
|
await pbMock.collection("stock_items").create({
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
is_active: true,
|
|
});
|
|
await pbMock.collection("stock_items").create({
|
|
sku: "STK-WR-6",
|
|
name: "Wire rope 6mm",
|
|
is_active: true,
|
|
});
|
|
await pbMock.collection("stock_items").create({
|
|
sku: "STK-CD-230",
|
|
name: "230mm Cutting Disc",
|
|
is_active: true,
|
|
});
|
|
|
|
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results.map((r) => r.sku).sort()).toEqual(["STK-CD-115", "STK-CD-230"]);
|
|
});
|
|
|
|
it("excludes inactive items", async () => {
|
|
await pbMock.collection("stock_items").create({
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
is_active: false,
|
|
});
|
|
|
|
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
|
|
|
|
expect(results).toEqual([]);
|
|
});
|
|
|
|
it("clamps limit to a max of 25", async () => {
|
|
for (let i = 0; i < 30; i++) {
|
|
await pbMock.collection("stock_items").create({
|
|
sku: `STK-X-${i}`,
|
|
name: `Cutting disc variant ${i}`,
|
|
is_active: true,
|
|
});
|
|
}
|
|
|
|
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 100);
|
|
|
|
expect(results).toHaveLength(25);
|
|
});
|
|
|
|
it("returns narrow projection fields", async () => {
|
|
const created = await pbMock.collection("stock_items").create({
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
description: "For angle grinder",
|
|
unit: "ea",
|
|
is_active: true,
|
|
});
|
|
|
|
const results = await searchStockItemsForQuickDraft(pbMock as never, "cutting", 10);
|
|
|
|
expect(results[0]).toEqual({
|
|
id: created.id,
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
description: "For angle grinder",
|
|
unit: "ea",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("getStockItemSupplierHistory", () => {
|
|
it("returns empty when the item has no purchase history", async () => {
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
is_active: true,
|
|
});
|
|
|
|
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
|
|
|
|
expect(history).toEqual([]);
|
|
});
|
|
|
|
it("aggregates by supplier: lastCost, lastDate, timesBought", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
is_active: true,
|
|
});
|
|
const purchase1 = await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierId,
|
|
status: "received",
|
|
purchase_number: "PO26070101STK",
|
|
created: "2026-06-10T08:00:00.000Z",
|
|
});
|
|
const purchase2 = await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierId,
|
|
status: "received",
|
|
purchase_number: "PO26071401STK",
|
|
created: "2026-07-14T08:00:00.000Z",
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchase1.id,
|
|
stock_item: "item-1",
|
|
cost_price: 18.5,
|
|
quantity: 10,
|
|
quantity_received: 10,
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchase2.id,
|
|
stock_item: "item-1",
|
|
cost_price: 19.0,
|
|
quantity: 20,
|
|
quantity_received: 20,
|
|
});
|
|
|
|
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
|
|
|
|
expect(history).toHaveLength(1);
|
|
expect(history[0]).toEqual({
|
|
supplierId,
|
|
supplierName: "Steel & Pipe Co",
|
|
lastCost: 19.0,
|
|
lastDate: "2026-07-14T08:00:00.000Z",
|
|
timesBought: 2,
|
|
});
|
|
});
|
|
|
|
it("returns one entry per supplier, sorted by lastDate desc", async () => {
|
|
const supA = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
|
|
).id;
|
|
const supB = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Supplier B" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
is_active: true,
|
|
});
|
|
const purchaseA = await pbMock.collection("stock_purchases").create({
|
|
supplier: supA,
|
|
status: "received",
|
|
purchase_number: "PO26070101STK",
|
|
created: "2026-06-10T08:00:00.000Z",
|
|
});
|
|
const purchaseB = await pbMock.collection("stock_purchases").create({
|
|
supplier: supB,
|
|
status: "received",
|
|
purchase_number: "PO26071501STK",
|
|
created: "2026-07-15T08:00:00.000Z",
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchaseA.id,
|
|
stock_item: "item-1",
|
|
cost_price: 18.5,
|
|
quantity_received: 10,
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchaseB.id,
|
|
stock_item: "item-1",
|
|
cost_price: 21.0,
|
|
quantity_received: 5,
|
|
});
|
|
|
|
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
|
|
|
|
expect(history).toHaveLength(2);
|
|
expect(history[0].supplierId).toBe(supB);
|
|
expect(history[1].supplierId).toBe(supA);
|
|
});
|
|
|
|
it("only counts lines with quantity_received > 0", async () => {
|
|
const supA = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Supplier A" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
is_active: true,
|
|
});
|
|
const purchase = await pbMock.collection("stock_purchases").create({
|
|
supplier: supA,
|
|
status: "draft",
|
|
purchase_number: "PO26070101STK",
|
|
created: "2026-07-01T08:00:00.000Z",
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchase.id,
|
|
stock_item: "item-1",
|
|
cost_price: 18.5,
|
|
quantity_received: 0,
|
|
});
|
|
|
|
const history = await getStockItemSupplierHistory(pbMock as never, "item-1");
|
|
|
|
expect(history).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("addQuickDraftLine", () => {
|
|
it("creates a new draft PO when none exists for the supplier", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
});
|
|
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
stockItemId: "item-1",
|
|
quantity: 50,
|
|
costPrice: 18.5,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.createdDraft).toBe(true);
|
|
expect(result.purchaseNumber).toMatch(/^PO\d{6}\d{2}STK$/);
|
|
|
|
const purchases = await pbMock.collection("stock_purchases").getFullList();
|
|
expect(purchases).toHaveLength(1);
|
|
expect(purchases[0].supplier).toBe(supplierId);
|
|
expect(purchases[0].status).toBe("draft");
|
|
|
|
const lines = await pbMock.collection("stock_purchase_items").getFullList();
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0].stock_item).toBe("item-1");
|
|
expect(lines[0].quantity).toBe(50);
|
|
expect(lines[0].cost_price).toBe(18.5);
|
|
expect(lines[0].description).toBe("115mm Cutting Disc");
|
|
});
|
|
|
|
it("appends to an existing open draft for the supplier", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
});
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-2",
|
|
sku: "STK-WR-6",
|
|
name: "Wire rope 6mm",
|
|
});
|
|
// Existing draft for this supplier.
|
|
await pbMock.collection("stock_purchases").create({
|
|
id: "purchase-existing",
|
|
supplier: supplierId,
|
|
status: "draft",
|
|
purchase_number: "PO26070101STK",
|
|
});
|
|
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
stockItemId: "item-2",
|
|
quantity: 20,
|
|
costPrice: 145,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.createdDraft).toBe(false);
|
|
expect(result.purchaseId).toBe("purchase-existing");
|
|
|
|
const lines = await pbMock.collection("stock_purchase_items").getFullList();
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0].stock_item).toBe("item-2");
|
|
});
|
|
|
|
it("ignores drafts that are not in 'draft' status when finding an open one", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
});
|
|
// Sent/received drafts must NOT be appended to — should create a new one.
|
|
await pbMock.collection("stock_purchases").create({
|
|
id: "purchase-sent",
|
|
supplier: supplierId,
|
|
status: "sent",
|
|
purchase_number: "PO26070101STK",
|
|
});
|
|
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
stockItemId: "item-1",
|
|
quantity: 50,
|
|
costPrice: 18.5,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
expect(result.createdDraft).toBe(true);
|
|
expect(result.purchaseId).not.toBe("purchase-sent");
|
|
|
|
const purchases = await pbMock.collection("stock_purchases").getFullList();
|
|
expect(purchases).toHaveLength(2);
|
|
});
|
|
|
|
it("creates a new stock_item when newStockItem is supplied, then adds the line", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "Steel & Pipe Co" })
|
|
).id;
|
|
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
quantity: 10,
|
|
costPrice: 5.5,
|
|
newStockItem: { name: "Brand-new Widget", sku: "STK-BNW-1" },
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) return;
|
|
|
|
const items = await pbMock.collection("stock_items").getFullList();
|
|
expect(items).toHaveLength(1);
|
|
expect(items[0].name).toBe("Brand-new Widget");
|
|
expect(items[0].sku).toBe("STK-BNW-1");
|
|
expect(items[0].is_active).toBe(true);
|
|
expect(items[0].current_qty).toBe(0);
|
|
expect(items[0].supplier).toBe(supplierId);
|
|
|
|
const lines = await pbMock.collection("stock_purchase_items").getFullList();
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0].stock_item).toBe(items[0].id);
|
|
expect(lines[0].description).toBe("Brand-new Widget");
|
|
});
|
|
|
|
it("rejects non-positive quantities", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "S" })
|
|
).id;
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
stockItemId: "item-1",
|
|
quantity: 0,
|
|
costPrice: 10,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("validation");
|
|
});
|
|
|
|
it("rejects negative cost", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "S" })
|
|
).id;
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
stockItemId: "item-1",
|
|
quantity: 1,
|
|
costPrice: -5,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("validation");
|
|
});
|
|
|
|
it("rejects when both stockItemId and newStockItem are missing", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "S" })
|
|
).id;
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
quantity: 1,
|
|
costPrice: 1,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("validation");
|
|
});
|
|
|
|
it("returns supplier_not_found when the supplier id doesn't exist", async () => {
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId: "does-not-exist",
|
|
stockItemId: "item-1",
|
|
quantity: 1,
|
|
costPrice: 1,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("supplier_not_found");
|
|
});
|
|
|
|
it("returns stock_item_not_found when the stock_item id doesn't exist", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "S" })
|
|
).id;
|
|
const result = await addQuickDraftLine(pbMock as never, {
|
|
supplierId,
|
|
stockItemId: "does-not-exist",
|
|
quantity: 1,
|
|
costPrice: 1,
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("stock_item_not_found");
|
|
});
|
|
});
|
|
|
|
describe("removeQuickDraftLine", () => {
|
|
it("deletes the line and returns ok", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ suppliername: "S" })
|
|
).id;
|
|
await pbMock.collection("stock_items").create({
|
|
id: "item-1",
|
|
sku: "STK-CD-115",
|
|
name: "115mm Cutting Disc",
|
|
});
|
|
const purchase = await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierId,
|
|
status: "draft",
|
|
purchase_number: "PO26070101STK",
|
|
});
|
|
const line = await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchase.id,
|
|
stock_item: "item-1",
|
|
description: "115mm Cutting Disc",
|
|
quantity: 10,
|
|
});
|
|
|
|
const result = await removeQuickDraftLine(pbMock as never, line.id);
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
const remaining = await pbMock.collection("stock_purchase_items").getFullList();
|
|
expect(remaining).toEqual([]);
|
|
});
|
|
|
|
it("returns line_not_found when the line id doesn't exist", async () => {
|
|
const result = await removeQuickDraftLine(pbMock as never, "does-not-exist");
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.reason).toBe("line_not_found");
|
|
});
|
|
});
|
|
|
|
describe("listOpenDrafts", () => {
|
|
it("returns an empty array when there are no draft stock_purchases", async () => {
|
|
const drafts = await listOpenDrafts(pbMock as never);
|
|
expect(drafts).toEqual([]);
|
|
});
|
|
|
|
it("returns draft purchases with supplier name + lines denormalised, sorted by created desc", async () => {
|
|
const supplierA = (
|
|
await pbMock.collection("suppliers").create({ name: "Steel & Pipe Co" })
|
|
).id;
|
|
const supplierB = (
|
|
await pbMock.collection("suppliers").create({ name: "Hardware Hub" })
|
|
).id;
|
|
|
|
// First (older) draft.
|
|
const purchaseA = await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierA,
|
|
status: "draft",
|
|
purchase_number: "PO26072801STK",
|
|
created: "2026-07-28T08:00:00.000Z",
|
|
});
|
|
// Second (newer) draft.
|
|
const purchaseB = await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierB,
|
|
status: "draft",
|
|
purchase_number: "PO26073001STK",
|
|
created: "2026-07-30T09:00:00.000Z",
|
|
});
|
|
|
|
// Two lines on purchaseA, one line on purchaseB.
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchaseA.id,
|
|
description: "115mm Cutting Disc",
|
|
quantity: 50,
|
|
cost_price: 18.5,
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchaseA.id,
|
|
description: "Wire rope 6mm",
|
|
quantity: 20,
|
|
cost_price: 145,
|
|
});
|
|
await pbMock.collection("stock_purchase_items").create({
|
|
purchase: purchaseB.id,
|
|
description: "Angle grinder",
|
|
quantity: 1,
|
|
cost_price: 1500,
|
|
});
|
|
|
|
const drafts = await listOpenDrafts(pbMock as never);
|
|
|
|
// Newest first.
|
|
expect(drafts).toHaveLength(2);
|
|
expect(drafts[0]).toMatchObject({
|
|
id: purchaseB.id,
|
|
purchase_number: "PO26073001STK",
|
|
supplier: supplierB,
|
|
supplier_name: "Hardware Hub",
|
|
created: "2026-07-30T09:00:00.000Z",
|
|
});
|
|
expect(drafts[0].lines).toHaveLength(1);
|
|
expect(drafts[0].lines[0]).toMatchObject({
|
|
description: "Angle grinder",
|
|
quantity: 1,
|
|
cost_price: 1500,
|
|
});
|
|
|
|
expect(drafts[1]).toMatchObject({
|
|
id: purchaseA.id,
|
|
purchase_number: "PO26072801STK",
|
|
supplier: supplierA,
|
|
supplier_name: "Steel & Pipe Co",
|
|
created: "2026-07-28T08:00:00.000Z",
|
|
});
|
|
expect(drafts[1].lines).toHaveLength(2);
|
|
expect(
|
|
drafts[1].lines.map((l) => l.description).sort(),
|
|
).toEqual(["115mm Cutting Disc", "Wire rope 6mm"]);
|
|
});
|
|
|
|
it("ignores non-draft stock_purchases in the result", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ name: "S" })
|
|
).id;
|
|
// Sent and received rows must NOT appear in the drafts list.
|
|
await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierId,
|
|
status: "sent",
|
|
purchase_number: "PO26072801STK",
|
|
created: "2026-07-28T08:00:00.000Z",
|
|
});
|
|
await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierId,
|
|
status: "received",
|
|
purchase_number: "PO26072901STK",
|
|
created: "2026-07-29T08:00:00.000Z",
|
|
});
|
|
|
|
const drafts = await listOpenDrafts(pbMock as never);
|
|
|
|
expect(drafts).toEqual([]);
|
|
});
|
|
|
|
it("returns an empty lines array for a draft with no child items", async () => {
|
|
const supplierId = (
|
|
await pbMock.collection("suppliers").create({ name: "Hardware Hub" })
|
|
).id;
|
|
// A brand-new draft with no lines yet (possible if the row was
|
|
// created by the agent, not by the modal — or after a mass delete).
|
|
const purchase = await pbMock.collection("stock_purchases").create({
|
|
supplier: supplierId,
|
|
status: "draft",
|
|
purchase_number: "PO26073001STK",
|
|
created: "2026-07-30T09:00:00.000Z",
|
|
});
|
|
|
|
const drafts = await listOpenDrafts(pbMock as never);
|
|
|
|
expect(drafts).toHaveLength(1);
|
|
expect(drafts[0].id).toBe(purchase.id);
|
|
expect(drafts[0].supplier_name).toBe("Hardware Hub");
|
|
expect(drafts[0].lines).toEqual([]);
|
|
});
|
|
});
|