WFDASH-WEBAPP/scripts/smoke/order-payments-hooks.mjs

243 lines
18 KiB
JavaScript
Raw Normal View History

/**
* 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 });
}