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.
242 lines
18 KiB
JavaScript
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 });
|
|
}
|