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
238 lines
No EOL
10 KiB
TypeScript
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);
|
|
}); |