115 lines
5 KiB
TypeScript
115 lines
5 KiB
TypeScript
|
|
// Run the real PB hook in a JS sandbox; no live records are changed.
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import { runInNewContext } from "node:vm";
|
||
|
|
import { describe, expect, it, vi } from "vitest";
|
||
|
|
|
||
|
|
function setup(stored: Record<string, unknown> = {}, collection = "order_items") {
|
||
|
|
let hook!: (event: unknown) => void;
|
||
|
|
let capability!: (event: unknown) => void;
|
||
|
|
let createHook!: (event: unknown) => void;
|
||
|
|
const next = vi.fn();
|
||
|
|
const body = { _collection_return_guard: { updated: "v1", parent_updated: "p1" } };
|
||
|
|
const parent = { id: "po1", updated: "p1", status: "received", booked_at: "" };
|
||
|
|
const app = {
|
||
|
|
findRecordById: vi.fn((name: string) => name === "stock_purchases" ? parent : original),
|
||
|
|
findRecordsByFilter: vi.fn((): unknown[] => []),
|
||
|
|
runInTransaction: (fn: (tx: unknown) => void) => fn(app),
|
||
|
|
};
|
||
|
|
const info = { body, context: "batch" };
|
||
|
|
const original = { id: "row1", updated: "v1", item_status: "collected", status: "collected", purchase: "po1", ...stored };
|
||
|
|
const e = {
|
||
|
|
requestInfo: () => info, auth: { isSuperuser: () => true }, app, next,
|
||
|
|
record: { original: () => original, collection: () => ({ name: collection }), getString: (field: string) => String((original as Record<string, unknown>)[field] ?? "") },
|
||
|
|
};
|
||
|
|
const source = readFileSync("pocketbase/pb_hooks/collection_return.pb.js", "utf8");
|
||
|
|
runInNewContext(source, {
|
||
|
|
onRecordUpdateRequest: (callback: typeof hook) => { hook = callback; },
|
||
|
|
onRecordCreateRequest: (callback: typeof hook) => { createHook = callback; },
|
||
|
|
routerAdd: (_method: string, _path: string, callback: typeof capability) => { capability = callback; },
|
||
|
|
$apis: { requireSuperuserAuth: () => ({}) },
|
||
|
|
BadRequestError: Error, ForbiddenError: Error,
|
||
|
|
});
|
||
|
|
return { call: () => hook(e), create: () => createHook(e), e, next, app, info, parent, capability, original };
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("PB collection return transaction guard", () => {
|
||
|
|
it("allows a matching collected revision inside a batch", () => {
|
||
|
|
const test = setup();
|
||
|
|
test.call();
|
||
|
|
expect(test.next).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
it("leaves ordinary updates alone", () => {
|
||
|
|
const test = setup();
|
||
|
|
test.info.body = {} as typeof test.info.body;
|
||
|
|
test.call();
|
||
|
|
expect(test.next).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
it.each([
|
||
|
|
{ updated: "v2" }, { item_status: "ready_invoice" }, { qty_delivered: 1 },
|
||
|
|
{ order_item_details: { invoice: { number: "INV1", qbInvoiceId: "qb1" } } },
|
||
|
|
{ order_item_details: JSON.stringify({ invoice: { number: "INV1", billingCompany: "CNL" } }) },
|
||
|
|
])("refuses a concurrent advance: %j", stored => {
|
||
|
|
const test = setup(stored);
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
expect(test.next).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
it("refuses non-batch resets", () => {
|
||
|
|
const test = setup();
|
||
|
|
test.info.context = "default";
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
});
|
||
|
|
it("refuses non-superusers", () => {
|
||
|
|
const test = setup();
|
||
|
|
test.e.auth.isSuperuser = () => false;
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
});
|
||
|
|
it("accepts a supplier-invoice blob on a still-collected customer item", () => {
|
||
|
|
const test = setup({ order_item_details: { invoice: { number: "SUP1" } } });
|
||
|
|
test.call();
|
||
|
|
expect(test.next).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
it("refuses a stock parent changed after the app's check", () => {
|
||
|
|
const test = setup({}, "stock_purchase_items");
|
||
|
|
test.parent.updated = "p2";
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
});
|
||
|
|
it("refuses a stock booking that started after the app's check", () => {
|
||
|
|
const test = setup({}, "stock_purchase_items");
|
||
|
|
test.app.findRecordsByFilter.mockReturnValue([{ id: "new-receipt" }]);
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
});
|
||
|
|
it("allows an unbooked received header to reopen", () => {
|
||
|
|
const test = setup({ status: "received" }, "stock_purchases");
|
||
|
|
test.call();
|
||
|
|
expect(test.next).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
it("refuses a stock header booked between checking and saving", () => {
|
||
|
|
const test = setup({ status: "received", booked_at: "today" }, "stock_purchases");
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("refuses stale confirmation after a reset has committed", () => {
|
||
|
|
const test = setup({ item_status: "collection_pool" });
|
||
|
|
test.info.body = { item_status: "ready_invoice" } as unknown as typeof test.info.body;
|
||
|
|
expect(test.call).toThrow();
|
||
|
|
expect(test.next).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
it("allows normal collection confirmation", () => {
|
||
|
|
const test = setup();
|
||
|
|
test.info.body = { item_status: "ready_invoice" } as unknown as typeof test.info.body;
|
||
|
|
test.call();
|
||
|
|
expect(test.next).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
it("refuses a stale stock booking ledger insert after a reset reopens the PO", () => {
|
||
|
|
const test = setup({ reference_type: "stock_purchase", reference_id: "po1" });
|
||
|
|
test.parent.status = "sent";
|
||
|
|
expect(test.create).toThrow();
|
||
|
|
expect(test.next).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
it("allows a stock booking ledger insert while the PO is received", () => {
|
||
|
|
const test = setup({ reference_type: "stock_purchase", reference_id: "po1" });
|
||
|
|
test.create();
|
||
|
|
expect(test.next).toHaveBeenCalledOnce();
|
||
|
|
});
|
||
|
|
});
|