WFDASH-WEBAPP/scripts/smoke/management-flow.ts
SJCoetz 6c07a91693 refactor: drop order-level status, shift to item-driven workflow
This commit overhauls the order management system to use item-level status tracking instead of a single order-level status:
- Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields
- Add `isOrderComplete` utility with tests to validate fully delivered orders
- Add purchase order PDF rendering support and tooling
- Add geo coordinate fields for companies/customers with map link UI
- Add REST management APIs for customers, companies, and suppliers
- Update customer/company services to use relation fields instead of free text
- Add status pill and tab filter components for item status visualization
- Add quote-to-order matching logic and unit tests
- Update all pages, components, and tests to match the new schema
- Include all required PocketBase migrations and schema updates
2026-06-29 13:06:01 +02:00

238 lines
No EOL
10 KiB
TypeScript

/**
* End-to-end smoke test for the Management section schema.
*
* Exercises the same write paths the UI does (direct PocketBase admin
* API), so a green run here proves the new field shapes are accepted
* by the live PB and the pb-helpers read paths still return what
* they used to.
*
* Flow:
* 1. Create a Company with geoPoint coordinates.
* 2. Read it back and verify the geoPoint round-trips.
* 3. Create a Customer linked to that Company (relation), with a
* sales rep (relation to users), and geoPoint coordinates.
* 4. Read it back with `expand=company,rep` and verify both
* relations resolve to real names.
* 5. Create a Supplier (text coordinates — unchanged).
* 6. Read each collection via the pb-helpers getters to confirm
* the client-side reads still return the expected shape.
* 7. Clean up — delete the three test rows.
*
* Run with:
* npx tsx scripts/smoke/management-flow.ts
*
* Why direct PB admin and not the Next.js helpers:
* The Next.js pb-helpers are unauth (they reuse the browser
* pocketbase instance and need the user's session). The smoke
* script is server-side with admin creds, so it goes via
* `getAdminPocketBase` — the same path the migrations use.
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
/* missing file — fine */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
// Distinct prefixes so the rows are obvious in the PB admin and easy
// to bulk-delete if a run leaves orphans behind.
const COMPANY_NAME = `SMOKE-CO-${RUN_ID}`;
const CUSTOMER_NAME = `SMOKE-CU-${RUN_ID}`;
const SUPPLIER_NAME = `SMOKE-SU-${RUN_ID}`;
const JOHANNESBURG = { lon: 28.0473, lat: -26.2041 };
const CAPE_TOWN = { lon: 18.4241, lat: -33.9249 };
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error("Set NEXT_PUBLIC_POCKETBASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.");
}
const pb = new PocketBase(url);
await pb.collection("_superusers").authWithPassword(email, password);
console.log(`[mgmt-smoke] Authenticated against ${url}`);
console.log(`[mgmt-smoke] Run id: ${RUN_ID}`);
// Look up a real user to use as the customer's rep — relations
// must point to a valid record id.
const users = await pb.collection("users").getList(1, 1, { fields: "id,name,email" });
if (users.items.length === 0) {
throw new Error("No users in the auth collection — can't back the rep relation.");
}
const rep = users.items[0];
console.log(`[mgmt-smoke] Using rep: ${rep.name || rep.email} (${rep.id})`);
const createdIds = { company: "", customer: "", supplier: "" };
try {
// ── 1. Create Company (geoPoint) ─────────────────────────────
const company = await pb.collection("companies").create({
company: COMPANY_NAME,
address: "123 Smoke Test Lane",
vendor: `SMOKE-V-${RUN_ID}`,
coordinates: JOHANNESBURG,
});
createdIds.company = company.id;
console.log(`[mgmt-smoke] Company created: ${company.company} (${company.id})`);
// ── 2. Verify company round-trip ─────────────────────────────
const companyBack = await pb.collection("companies").getOne(company.id);
assertEqual(companyBack.company, COMPANY_NAME, "company.company round-trip");
if (
!companyBack.coordinates ||
companyBack.coordinates.lon !== JOHANNESBURG.lon ||
companyBack.coordinates.lat !== JOHANNESBURG.lat
) {
throw new Error(
`FAIL — company.coordinates round-trip: expected ${JSON.stringify(JOHANNESBURG)}, got ${JSON.stringify(companyBack.coordinates)}`
);
}
console.log(`[mgmt-smoke] Company coordinates round-trip OK: ${JSON.stringify(companyBack.coordinates)}`);
// ── 3. Create Customer (relations + geoPoint) ────────────────
const customer = await pb.collection("customers").create({
customer: CUSTOMER_NAME,
email: `smoke-cu-${RUN_ID}@example.com`,
number: "0123456789",
company: company.id,
rep: rep.id,
coordinates: CAPE_TOWN,
description: "Auto-created by mgmt-flow smoke test.",
dateadded: new Date().toISOString().slice(0, 10),
lastvisiteddate: new Date().toISOString(),
});
createdIds.customer = customer.id;
console.log(
`[mgmt-smoke] Customer created: ${customer.customer} (${customer.id}) → company=${company.id}, rep=${rep.id}`
);
// ── 4. Verify customer with expand chain ─────────────────────
const customerBack = await pb.collection("customers").getOne(customer.id, {
expand: "company,rep",
});
if (customerBack.company !== company.id) {
throw new Error(`FAIL — customer.company should be ${company.id}, got ${customerBack.company}`);
}
if (customerBack.rep !== rep.id) {
throw new Error(`FAIL — customer.rep should be ${rep.id}, got ${customerBack.rep}`);
}
if (!customerBack.expand?.company || customerBack.expand.company.id !== company.id) {
throw new Error(
`FAIL — customer.expand.company should resolve to ${company.id}, got ${JSON.stringify(customerBack.expand?.company)}`
);
}
if (!customerBack.expand?.rep || customerBack.expand.rep.id !== rep.id) {
throw new Error(
`FAIL — customer.expand.rep should resolve to ${rep.id}, got ${JSON.stringify(customerBack.expand?.rep)}`
);
}
if (
!customerBack.coordinates ||
customerBack.coordinates.lon !== CAPE_TOWN.lon ||
customerBack.coordinates.lat !== CAPE_TOWN.lat
) {
throw new Error(
`FAIL — customer.coordinates round-trip: expected ${JSON.stringify(CAPE_TOWN)}, got ${JSON.stringify(customerBack.coordinates)}`
);
}
console.log(
`[mgmt-smoke] Customer expand OK: company="${customerBack.expand.company.company}", rep="${customerBack.expand.rep.name || customerBack.expand.rep.email}", coords=${JSON.stringify(customerBack.coordinates)}`
);
// ── 5. Create Supplier (text coordinates — unchanged) ───────
const supplier = await pb.collection("suppliers").create({
suppliername: SUPPLIER_NAME,
email: `smoke-su-${RUN_ID}@example.com`,
suppliernumber: `SMOKE-S-${RUN_ID}`,
supplieraddress: "1 Test Road",
supply_tags: "smoke,test",
coordinates: `${CAPE_TOWN.lat}, ${CAPE_TOWN.lon}`,
closingtime: "17:00",
});
createdIds.supplier = supplier.id;
console.log(`[mgmt-smoke] Supplier created: ${supplier.suppliername} (${supplier.id})`);
const supplierBack = await pb.collection("suppliers").getOne(supplier.id);
assertEqual(supplierBack.suppliername, SUPPLIER_NAME, "supplier.suppliername round-trip");
if (supplierBack.coordinates !== `${CAPE_TOWN.lat}, ${CAPE_TOWN.lon}`) {
throw new Error(`FAIL — supplier.coordinates text round-trip: got "${supplierBack.coordinates}"`);
}
console.log(`[mgmt-smoke] Supplier coordinates text round-trip OK: "${supplierBack.coordinates}"`);
// ── 6. pb-helpers read paths ─────────────────────────────────
// These run on the live PB and use the same shape the UI
// expects. We can't import the helpers here (they pull in the
// client-side pb instance), so we mirror their queries below.
const allCompanies = await pb.collection("companies").getList(1, 200, {
filter: `company = "${COMPANY_NAME}"`,
});
if (allCompanies.items.length !== 1) {
throw new Error(`FAIL — expected 1 company matching ${COMPANY_NAME}, got ${allCompanies.items.length}`);
}
const allCustomers = await pb.collection("customers").getList(1, 200, {
filter: `customer = "${CUSTOMER_NAME}"`,
expand: "company,rep",
});
if (allCustomers.items.length !== 1) {
throw new Error(`FAIL — expected 1 customer matching ${CUSTOMER_NAME}, got ${allCustomers.items.length}`);
}
const expanded = allCustomers.items[0];
if (!expanded.expand?.company?.company) {
throw new Error("FAIL — expanded customer missing expand.company.company");
}
console.log(
`[mgmt-smoke] pb-helpers shape: customers.getList with expand returned ${expanded.expand.company.company} / ${expanded.expand.rep?.name || expanded.expand.rep?.email}`
);
console.log("\n[mgmt-smoke] All checks passed.");
} finally {
// ── 7. Cleanup ────────────────────────────────────────────────
// Always attempt cleanup so a mid-run failure doesn't leave
// smoke rows in production. Each delete is best-effort.
console.log("\n[mgmt-smoke] Cleanup...");
for (const [coll, id] of [
["customers", createdIds.customer],
["companies", createdIds.company],
["suppliers", createdIds.supplier],
] as const) {
if (!id) continue;
try {
await pb.collection(coll).delete(id);
console.log(`[mgmt-smoke] deleted ${coll}/${id}`);
} catch (err) {
console.warn(`[mgmt-smoke] failed to delete ${coll}/${id}: ${err}`);
}
}
}
}
function assertEqual(actual: unknown, expected: unknown, label: string) {
if (actual !== expected) {
throw new Error(`FAIL — ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
main().catch((err) => {
console.error("[mgmt-smoke] FAILED:", err);
process.exit(1);
});