WFDASH-WEBAPP/scripts/smoke/order-payments-hooks.mjs
Dave 97603dce71 refactor: simplify supplier payments to document checklist
Remove payment amount, reference, notes and manual combining forms. Link supplier POs automatically and record paid from supplier evidence plus proof, keeping readiness and attachment safeguards.

Keep active supplier/company views current and retire stale supplier evidence on routing changes. Verified 5167 tests with only the two existing script-import errors.
2026-11-03 07:18:56 +02:00

242 lines
18 KiB
JavaScript

/**
* Real PocketBase hook smoke test. Never connects to the live instance.
* Usage: POCKETBASE_TEST_BINARY=/absolute/path/pocketbase node scripts/smoke/order-payments-hooks.mjs
* Downloads/installations are deliberately left to the caller. Uses the pinned
* deployment version (0.39.8), an ephemeral port, and a temporary data folder.
*/
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { spawn, spawnSync } from "node:child_process";
import PocketBase from "pocketbase";
import { createHash } from "node:crypto";
const binary = process.env.POCKETBASE_TEST_BINARY;
if (!binary || !path.isAbsolute(binary)) throw new Error("Set POCKETBASE_TEST_BINARY to an absolute path to PocketBase 0.39.8.");
const version = spawnSync(binary, ["--version"], { encoding: "utf8" });
assert.match(version.stdout || "", /0\.39\.8/, "Use the deployed PocketBase version, 0.39.8.");
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "wfdash-payment-hooks-"));
const port = await new Promise((resolve, reject) => {
const socket = net.createServer();
socket.once("error", reject);
socket.listen(0, "127.0.0.1", () => {
const address = socket.address();
socket.close(() => resolve(address.port));
});
});
const origin = `http://127.0.0.1:${port}`;
const data = path.join(directory, "data");
const hooks = path.join(directory, "hooks");
await fs.mkdir(hooks);
for (const name of ["order_payment_utils.js", "order_payments.pb.js", "audit_log_utils.js"]) {
await fs.copyFile(path.resolve("pocketbase/pb_hooks", name), path.join(hooks, name));
}
const email = "payment-test@example.test";
const password = "LocalPaymentTestPassword123";
const createAdmin = spawnSync(binary, ["superuser", "create", email, password, `--dir=${data}`], { encoding: "utf8" });
assert.equal(createAdmin.status, 0, createAdmin.stderr);
const server = spawn(binary, ["serve", `--http=127.0.0.1:${port}`, `--dir=${data}`, `--hooksDir=${hooks}`, "--hooksWatch=false"], { stdio: ["ignore", "pipe", "pipe"] });
let logs = "";
server.stdout.on("data", (chunk) => { logs += chunk; });
server.stderr.on("data", (chunk) => { logs += chunk; });
try {
let healthy = false;
for (let attempt = 0; attempt < 100; attempt++) {
try { healthy = (await fetch(`${origin}/api/health`)).ok; } catch { /* server is starting */ }
if (healthy) break;
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.ok(healthy, `Local PocketBase failed to start: ${logs}`);
const pb = new PocketBase(origin);
pb.autoCancellation(false);
await pb.collection("_superusers").authWithPassword(email, password);
for (const name of ["orders", "suppliers", "purchase_orders"]) {
await pb.collections.create({ name, type: "base", fields: [
{ name: "label", type: "text" }, { name: "documents", type: "file", maxSelect: 10 }, { name: "documents_history", type: "file", maxSelect: 10 },
] });
}
const order = await pb.collection("orders").create({ label: "Local order" });
const supplier = await pb.collection("suppliers").create({ label: "Local supplier" });
await pb.collections.create({ name: "order_items", type: "base", fields: [
...["order", "supplier", "purchase_order", "item_status", "drv_status", "drv_id", "description"].map((name) => ({ name, type: "text" })),
...["quantity", "order_qty", "cost_price"].map((name) => ({ name, type: "number" })),
{ name: "order_item_details", type: "json", maxSize: 100000 },
{ name: "ready_for_collection", type: "bool" },
{ name: "order_item_attachements", type: "file", maxSelect: 10, maxSize: 10485760 },
] });
await pb.collections.create({ name: "audit_log", type: "base", fields: [
...["collection_name", "record_id", "parent_type", "parent_id", "action", "label", "actor_id", "actor_name", "actor_email", "source", "refs"].map((name) => ({ name, type: "text" })),
{ name: "changes", type: "json", maxSize: 5000000 },
] });
const migrate = spawnSync("npx", ["tsx", "scripts/migrations/2026-11-02-order-payments.ts"], {
cwd: process.cwd(), encoding: "utf8",
env: { ...process.env, NEXT_PUBLIC_POCKETBASE_URL: origin, PB_URL: origin, PB_ADMIN_EMAIL: email, PB_ADMIN_PASSWORD: password },
});
assert.equal(migrate.status, 0, migrate.stderr || migrate.stdout);
const migrateAgain = spawnSync("npx", ["tsx", "scripts/migrations/2026-11-02-order-payments.ts"], {
cwd: process.cwd(), encoding: "utf8",
env: { ...process.env, NEXT_PUBLIC_POCKETBASE_URL: origin, PB_URL: origin, PB_ADMIN_EMAIL: email, PB_ADMIN_PASSWORD: password },
});
assert.equal(migrateAgain.status, 0, "Migration must be idempotent.");
const actor = { id: "localtest", name: "Local Test" };
const command = async (body) => (await pb.send("/api/wfdash/order-payments", { method: "POST", body: { actor, ...body } })).request;
const rejects = async (fn, pattern) => {
try { await fn(); assert.fail("Unexpected success"); }
catch (error) { if (error.code === "ERR_ASSERTION") throw error; assert.match(error.response?.message || error.message, pattern); }
};
assert.equal((await pb.send("/api/wfdash/order-payments-capability", {})).version, 3);
const base = { order: order.id, supplier: supplier.id, item_status: "processed", quantity: 4, order_qty: 10, cost_price: 3 };
let item = await pb.collection("order_items").create({ ...base, item_status: "approval_needed", description: "Local item" });
const request = await command({ op: "require", item_id: item.id });
assert.equal(request.status, "draft");
assert.equal((await command({ op: "require", item_id: item.id })).id, request.id, "Require must be idempotent.");
item = await pb.collection("order_items").getOne(item.id);
assert.equal(item.payment_status, "required");
assert.equal(item.item_status, "approval_needed");
await rejects(() => pb.collection("order_items").update(item.id, { ready_for_collection: true }), /payment/i);
await rejects(() => pb.collection("order_items").update(item.id, { payment_status: "paid" }), /payment/i);
await rejects(() => pb.collection("order_items").update(item.id, { item_status: "assigned", drv_id: "driver1" }), /payment/i);
await pb.collection("order_items").update(item.id, { item_status: "po_generated", purchase_order: "localpo" });
await pb.collection("order_items").update(item.id, { item_status: "collection_pool", ready_for_collection: false });
const files = new FormData();
files.append("order_item_attachements+", new Blob(["invoice"]), "invoice.pdf");
files.append("order_item_attachements+", new Blob(["proof"]), "proof.pdf");
item = await pb.collection("order_items").update(item.id, files);
const documents = item.order_item_attachements.map((filename) => ({ collection: "order_items", recordId: item.id, filename, role: filename.startsWith("invoice") ? "support" : "proof" }));
const pending = await command({ op: "save", request_id: request.id, revision: request.revision, documents: documents.filter((doc) => doc.role === "support") });
assert.equal(pending.status, "pending");
await rejects(() => command({ op: "paid", request_id: pending.id, revision: pending.revision, documents: [] }), /proof/i);
const paid = await command({ op: "paid", request_id: pending.id, revision: pending.revision, documents: documents.filter((doc) => doc.role === "proof") });
assert.equal(paid.status, "paid");
item = await pb.collection("order_items").getOne(item.id);
assert.equal(item.payment_status, "paid");
assert.equal(item.ready_for_collection, false, "Paying must not mark ready.");
await rejects(() => pb.collection("order_items").update(item.id, { cost_price: 8 }), /changed|payment/i);
await rejects(() => pb.collection("order_items").update(item.id, { supplier: "supplier2" }), /changed|payment/i);
await pb.collection("order_items").update(item.id, { ready_for_collection: true });
await rejects(() => pb.collection("order_items").update(item.id, { "order_item_attachements-": documents.find((doc) => doc.role === "proof").filename }), /proof|document/i);
const source = await pb.collection("order_items").create(base);
const sourceRequest = await command({ op: "require", item_id: source.id });
let remainder = await pb.collection("order_items").create({ order: order.id, item_status: "pending", quantity: 1, order_qty: 1, cost_price: 3, order_item_details: { split: { sourceItemId: source.id } } });
remainder = await pb.collection("order_items").getOne(remainder.id);
assert.equal(remainder.payment_status, "required", "JSON-field split marker must inherit a hold.");
const changed = await pb.collection("supplier_payment_requests").getOne(sourceRequest.id);
await command({ op: "cancel", request_id: changed.id, revision: changed.revision, reason: "Entered in error" });
await rejects(() => pb.collection("order_items").delete(source.id), /history|payment/i);
// Regression: storage and deduplication are serialized with settlement by PB.
const uploadDocument = async (r, role, content) => {
const hash = createHash("sha256").update(content).digest("hex").slice(0, 16);
const form = new FormData();
form.append("request_id", r.id);
form.append("revision", String(r.revision));
form.append("role", role);
form.append("actor_id", actor.id);
form.append("actor_name", actor.name);
form.append("file", new Blob([content]), `pay_${r.id}_${role}_${hash}.pdf`);
return (await pb.send("/api/wfdash/order-payments-document", { method: "POST", body: form })).documents;
};
const payItem = async (row) => {
const r = await command({ op: "require", item_id: row.id });
const support = await uploadDocument(r, "support", "Atomic supplier invoice");
const proof = await uploadDocument(r, "proof", "Atomic payment proof");
return command({ op: "paid", request_id: r.id, revision: r.revision, documents: [...support, ...proof] });
};
// A supplier PO linked after the toggle is automatic supporting evidence.
const poForm = new FormData();
poForm.append("label", "Automatically linked supplier PO");
poForm.append("documents", new Blob(["Existing supplier purchase-order PDF"]), "supplier_po.pdf");
const supplierPo = await pb.collection("purchase_orders").create(poForm);
const poItem = await pb.collection("order_items").create(base);
let poRequest = await command({ op: "require", item_id: poItem.id });
await pb.collection("order_items").update(poItem.id, { purchase_order: supplierPo.id, item_status: "po_generated" });
poRequest = await pb.collection("supplier_payment_requests").getOne(poRequest.id);
assert.equal(poRequest.status, "draft", "Linked PO payment needs no operator save form.");
await rejects(() => command({ op: "paid", request_id: poRequest.id, revision: poRequest.revision, documents: [] }), /proof/i);
const poProof = await uploadDocument(poRequest, "proof", "Proof for automatically linked PO");
const poPaid = await command({ op: "paid", request_id: poRequest.id, revision: poRequest.revision, documents: poProof });
assert.equal(poPaid.status, "paid");
assert.ok(poPaid.documents.some((doc) => doc.collection === "purchase_orders" && doc.recordId === supplierPo.id && doc.role === "support"));
assert.equal((await pb.collection("order_items").getOne(poItem.id)).item_status, "po_generated");
// Procurement edits invalidate stale payer tabs, without a reconfirmation form.
const changedItem = await pb.collection("order_items").create({ ...base, purchase_order: supplierPo.id });
const staleRequest = await command({ op: "require", item_id: changedItem.id });
await pb.collection("order_items").update(changedItem.id, { cost_price: 4 });
await rejects(() => command({ op: "paid", request_id: staleRequest.id, revision: staleRequest.revision, documents: [] }), /changed|refresh/i);
const refreshedRequest = await pb.collection("supplier_payment_requests").getOne(staleRequest.id);
const changedProof = await uploadDocument(refreshedRequest, "proof", "Proof after refreshed procurement details");
const refreshedPaid = await command({ op: "paid", request_id: refreshedRequest.id, revision: refreshedRequest.revision, documents: changedProof });
assert.equal(refreshedPaid.item_snapshot[changedItem.id].cost_price, 4);
// Supplier changes update request routing and retire the previous invoice.
const routedItem = await pb.collection("order_items").create(base);
let routed = await command({ op: "require", item_id: routedItem.id });
const oldInvoice = await uploadDocument(routed, "support", "Invoice for the original supplier");
routed = await command({ op: "save", request_id: routed.id, revision: routed.revision, documents: oldInvoice });
const newSupplier = await pb.collection("suppliers").create({ label: "New supplier" });
await pb.collection("order_items").update(routedItem.id, { supplier: newSupplier.id });
routed = await pb.collection("supplier_payment_requests").getOne(routed.id);
assert.equal(routed.supplier, newSupplier.id, "Payment metadata must follow the current single-item supplier.");
assert.equal(routed.documents.some((doc) => doc.filename === oldInvoice[0].filename), false);
assert.ok(JSON.stringify(routed.history).includes(oldInvoice[0].filename), "Retired evidence must remain in history.");
assert.ok((await pb.collection("order_items").getOne(routedItem.id)).order_item_attachements.includes(oldInvoice[0].filename), "Retiring evidence must not remove its file.");
const newSupplierProof = await uploadDocument(routed, "proof", "Proof for the new supplier");
await rejects(() => command({ op: "paid", request_id: routed.id, revision: routed.revision, documents: newSupplierProof }), /supplier invoice|support/i);
const newInvoice = await uploadDocument(routed, "support", "Invoice from the new supplier");
routed = await command({ op: "save", request_id: routed.id, revision: routed.revision, documents: newInvoice });
assert.equal((await command({ op: "paid", request_id: routed.id, revision: routed.revision, documents: newSupplierProof })).status, "paid");
// Match splitOrderItemRemainder's create-then-shrink ordering exactly.
const splitSource = await pb.collection("order_items").create({ ...base, quantity: 20, order_qty: 10 });
const splitPayment = await payItem(splitSource);
const paidRemainder = await pb.collection("order_items").create({ order: order.id, item_status: "pending", quantity: 10, order_qty: 10, cost_price: 3, order_item_details: { split: { sourceItemId: splitSource.id } } });
const shrunk = await pb.collection("order_items").update(splitSource.id, { quantity: 10, order_item_details: { split: { remainderItemId: paidRemainder.id } } });
assert.equal(shrunk.quantity + paidRemainder.quantity, 20, "Paid split must conserve the original customer quantity.");
assert.equal(paidRemainder.payment_status, "", "A paid source must not automatically require or settle its new remainder.");
await rejects(() => pb.collection("order_items").update(splitSource.id, { order_qty: 20 }), /changed|payment/i);
const oldProof = splitPayment.documents.find((doc) => doc.role === "proof");
const withoutOldProof = () => pb.collection("order_items").update(splitSource.id, { "order_item_attachements-": oldProof.filename });
let freshRequirement = await command({ op: "require", item_id: splitSource.id });
await rejects(withoutOldProof, /proof/i);
await command({ op: "cancel", request_id: freshRequirement.id, revision: freshRequirement.revision, reason: "Payment correction cancelled" });
await rejects(withoutOldProof, /proof/i);
const secondPayment = await payItem(await pb.collection("order_items").getOne(splitSource.id));
assert.notEqual(secondPayment.id, splitPayment.id);
await rejects(withoutOldProof, /proof/i);
assert.equal((await fetch(`${origin}/api/files/order_items/${splitSource.id}/${oldProof.filename}`)).status, 200);
const groupedA = await pb.collection("order_items").create(base);
const groupedB = await pb.collection("order_items").create(base);
const groupedReqA = await command({ op: "require", item_id: groupedA.id });
const groupedReqB = await command({ op: "require", item_id: groupedB.id });
let grouped = await command({ op: "combine", request_ids: [groupedReqA.id, groupedReqB.id], revisions: { [groupedReqA.id]: groupedReqA.revision, [groupedReqB.id]: groupedReqB.revision } });
const groupedSupport = await uploadDocument(grouped, "support", "One invoice for both selected items");
grouped = await command({ op: "save", request_id: grouped.id, revision: grouped.revision, documents: groupedSupport });
const [overlapA, overlapB] = await Promise.all([
uploadDocument(grouped, "proof", "The same overlapping proof"),
uploadDocument(grouped, "proof", "The same overlapping proof"),
]);
assert.deepEqual(overlapA, overlapB, "Overlapping uploads must return exactly the same stored names.");
for (const member of [groupedA, groupedB]) {
const files = (await pb.collection("order_items").getOne(member.id)).order_item_attachements;
assert.equal(files.length, 2, "Grouped concurrent uploads must append one support and one proof, without duplicates.");
assert.equal(files.filter((name) => name.startsWith(`pay_${grouped.id}_proof_`)).length, 1);
}
await command({ op: "paid", request_id: grouped.id, revision: grouped.revision, documents: overlapA });
await rejects(() => uploadDocument(grouped, "proof", "The same overlapping proof"), /paid|changed|refresh/i);
for (const member of [groupedA, groupedB]) {
assert.equal((await pb.collection("order_items").getOne(member.id)).order_item_attachements.length, 2, "An upload attempted after settlement must append nothing.");
}
const audits = await pb.collection("audit_log").getFullList();
assert.ok(audits.some((row) => row.actor_name === "Local Test" && row.collection_name === "supplier_payment_requests"));
console.log("PASS: real PB 0.39.8 payment commands, readiness/dispatch guards, proof, split inheritance, immutable historical proof, paid split conservation, overlapping upload deduplication and migration idempotence.");
} catch (error) {
console.error(logs.slice(-8000));
throw error;
} finally {
server.kill("SIGTERM");
if (server.exitCode === null && server.signalCode === null) await new Promise((resolve) => server.once("exit", resolve));
await fs.rm(directory, { recursive: true, force: true });
}