WFDASH-WEBAPP/scripts/smoke/rest-management.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

306 lines
No EOL
12 KiB
TypeScript

/**
* End-to-end smoke test for the management REST endpoints.
*
* Drives the actual `/api/v1/{companies,customers,suppliers}` HTTP
* endpoints (requires a running Next.js dev server) and exercises
* every CRUD verb we added.
*
* GET/POST /api/v1/{companies,customers,suppliers}
* GET/PATCH/DELETE /api/v1/{companies,customers,suppliers}/[id]
*
* Test data is prefixed `REST-SMOKE-…` so orphans are obvious.
*
* Run with:
* # In one terminal: npm run dev
* # In another: npx tsx --env-file=.env.local scripts/smoke/rest-management.ts
*/
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 {
/* fine */
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const CO_NAME = `REST-SMOKE-CO-${RUN_ID}`;
const CU_NAME = `REST-SMOKE-CU-${RUN_ID}`;
const SU_NAME = `REST-SMOKE-SU-${RUN_ID}`;
const PORT = Number(process.env.PORT ?? 3000);
const BASE = `http://127.0.0.1:${PORT}`;
interface RestError {
error?: string;
// list response
items?: { id: string }[];
// single response
id?: string;
}
async function rest<T = unknown>(
method: string,
path: string,
body?: unknown
): Promise<{ status: number; data: T }> {
const init: RequestInit = {
method,
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
};
if (body !== undefined) init.body = JSON.stringify(body);
const res = await fetch(`${BASE}${path}`, init);
const text = await res.text();
const data = text ? (JSON.parse(text) as T) : (undefined as T);
return { status: res.status, data };
}
async function main() {
if (!process.env.AGENT_API_KEY) {
throw new Error("AGENT_API_KEY must be set in env (matches the dev server's).");
}
console.log(`[rest-mgmt] Target: ${BASE}`);
console.log(`[rest-mgmt] Run id: ${RUN_ID}`);
const createdIds = { company: "", customer: "", supplier: "" };
try {
// ── 1. POST /companies ───────────────────────────────────────
{
const { status, data } = await rest<{ id: string; company: string; coordinates: { lon: number; lat: number } }>(
"POST",
"/api/v1/companies",
{
company: CO_NAME,
address: "1 REST Test Road",
vendor: `REST-V-${RUN_ID}`,
coordinates: { lon: 28.0473, lat: -26.2041 },
}
);
if (status !== 201) {
throw new Error(`companies POST status ${status}: ${JSON.stringify(data)}`);
}
createdIds.company = data.id;
if (data.coordinates.lon !== 28.0473) {
throw new Error(`FAIL: POST coords wrong: ${JSON.stringify(data.coordinates)}`);
}
console.log(`[rest-mgmt] POST /companies → 201 ${data.id}`);
}
// ── 2. GET /companies?search=… finds our row ──────────────────
{
const { status, data } = await rest<{ items: { id: string }[] }>(
"GET",
`/api/v1/companies?search=${encodeURIComponent(CO_NAME)}&perPage=5`
);
if (status !== 200) throw new Error(`companies GET status ${status}`);
if (!data.items.some((i) => i.id === createdIds.company)) {
throw new Error(`FAIL: search missing our row`);
}
console.log(`[rest-mgmt] GET /companies?search=… → 200, found our row`);
}
// ── 3. GET /companies/[id] ────────────────────────────────────
{
const { status, data } = await rest<{ id: string; company: string }>(
"GET",
`/api/v1/companies/${createdIds.company}`
);
if (status !== 200 || data.id !== createdIds.company) {
throw new Error(`companies/[id] GET failed: ${status} ${JSON.stringify(data)}`);
}
console.log(`[rest-mgmt] GET /companies/[id] → 200`);
}
// ── 4. PATCH /companies/[id] ──────────────────────────────────
{
const renamed = `REST-SMOKE-CO-RENAMED-${RUN_ID}`;
const { status, data } = await rest<{ company: string }>(
"PATCH",
`/api/v1/companies/${createdIds.company}`,
{ company: renamed, coordinates: { lon: 18.4241, lat: -33.9249 } }
);
if (status !== 200 || data.company !== renamed) {
throw new Error(`companies PATCH failed: ${status} ${JSON.stringify(data)}`);
}
// Rename back so the rest of the script stays readable.
await rest("PATCH", `/api/v1/companies/${createdIds.company}`, {
company: CO_NAME,
});
console.log(`[rest-mgmt] PATCH /companies/[id] → 200`);
}
// ── 5. POST /customers (with name-resolution for company + rep) ─
{
// Resolve a rep via the same users/lookup the MCP smoke uses.
const usersRes = await fetch(`${BASE}/api/v1/users/lookup`, {
headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` },
});
const users = (await usersRes.json()) as { id: string; name: string; email: string }[];
const repNameOrId = users[0].name || users[0].email;
const { status, data } = await rest<{
id: string;
company: string;
rep: string;
coordinates: { lon: number; lat: number };
}>(
"POST",
"/api/v1/customers",
{
customer: CU_NAME,
email: `rest-cu-${RUN_ID}@example.com`,
number: "0123456789",
company: CO_NAME,
rep: repNameOrId,
coordinates: { lon: 18.4241, lat: -33.9249 },
}
);
if (status !== 201) throw new Error(`customers POST status ${status}: ${JSON.stringify(data)}`);
createdIds.customer = data.id;
if (data.company !== createdIds.company) {
throw new Error(`FAIL: customer.company not resolved: got ${data.company}, want ${createdIds.company}`);
}
if (!data.rep) throw new Error(`FAIL: customer.rep not resolved`);
console.log(`[rest-mgmt] POST /customers → 201 ${data.id} (company+rep resolved)`);
}
// ── 6. GET /customers/[id]?expand=company,rep ─────────────────
{
const { status, data } = await rest<{
expand?: { company?: { company: string }; rep?: { name: string; email: string } };
}>(
"GET",
`/api/v1/customers/${createdIds.customer}?expand=company,rep`
);
if (status !== 200) throw new Error(`customers GET status ${status}`);
if (data.expand?.company?.company !== CO_NAME) {
throw new Error(`FAIL: customer.expand.company.company mismatch`);
}
if (!data.expand?.rep) {
throw new Error(`FAIL: customer.expand.rep missing`);
}
console.log(
`[rest-mgmt] GET /customers/[id]?expand=company,rep → 200, company="${data.expand.company.company}", rep="${data.expand.rep.name || data.expand.rep.email}"`
);
}
// ── 7. PATCH /customers/[id] (change coords) ─────────────────
{
const { status } = await rest(
"PATCH",
`/api/v1/customers/${createdIds.customer}`,
{ coordinates: { lon: 0, lat: 0 } }
);
if (status !== 200) throw new Error(`customers PATCH status ${status}`);
console.log(`[rest-mgmt] PATCH /customers/[id] → 200`);
}
// ── 8. GET /customers?search=… ────────────────────────────────
{
const { status, data } = await rest<{ items: { id: string }[] }>(
"GET",
`/api/v1/customers?search=${encodeURIComponent(CU_NAME)}&perPage=5`
);
if (status !== 200) throw new Error(`customers GET status ${status}`);
if (!data.items.some((i) => i.id === createdIds.customer)) {
throw new Error(`FAIL: customers search missing our row`);
}
console.log(`[rest-mgmt] GET /customers?search=… → 200`);
}
// ── 9. POST /suppliers ────────────────────────────────────────
{
const { status, data } = await rest<{ id: string; suppliername: string; coordinates: string }>(
"POST",
"/api/v1/suppliers",
{
suppliername: SU_NAME,
email: `rest-su-${RUN_ID}@example.com`,
suppliernumber: `REST-S-${RUN_ID}`,
coordinates: "-33.9249, 18.4241",
}
);
if (status !== 201) throw new Error(`suppliers POST status ${status}`);
createdIds.supplier = data.id;
console.log(`[rest-mgmt] POST /suppliers → 201 ${data.id}`);
}
// ── 10. GET /suppliers/[id] + PATCH + GET ?search= ────────────
{
const getRes = await rest<{ suppliername: string }>(
"GET",
`/api/v1/suppliers/${createdIds.supplier}`
);
if (getRes.status !== 200 || getRes.data.suppliername !== SU_NAME) {
throw new Error(`suppliers GET failed`);
}
const patchRes = await rest(
"PATCH",
`/api/v1/suppliers/${createdIds.supplier}`,
{ closingtime: "16:30" }
);
if (patchRes.status !== 200) throw new Error(`suppliers PATCH status ${patchRes.status}`);
const listRes = await rest<{ items: { id: string }[] }>(
"GET",
`/api/v1/suppliers?search=${encodeURIComponent(SU_NAME)}&perPage=5`
);
if (!listRes.data.items.some((i) => i.id === createdIds.supplier)) {
throw new Error(`FAIL: suppliers search missing our row`);
}
console.log(`[rest-mgmt] suppliers GET/PATCH/list round-trip OK`);
}
// ── 11. 401 sanity check ─────────────────────────────────────
{
const res = await fetch(`${BASE}/api/v1/companies`, {
headers: { authorization: "Bearer wrong" },
});
if (res.status !== 401) throw new Error(`expected 401 for wrong token, got ${res.status}`);
console.log(`[rest-mgmt] 401 on missing/wrong token OK`);
}
// ── 12. Cleanup ──────────────────────────────────────────────
// Delete order: customer → supplier → company (cascade order).
await rest("DELETE", `/api/v1/customers/${createdIds.customer}`);
console.log(`[rest-mgmt] DELETE /customers/${createdIds.customer}`);
await rest("DELETE", `/api/v1/suppliers/${createdIds.supplier}`);
console.log(`[rest-mgmt] DELETE /suppliers/${createdIds.supplier}`);
await rest("DELETE", `/api/v1/companies/${createdIds.company}`);
console.log(`[rest-mgmt] DELETE /companies/${createdIds.company}`);
console.log("\n[rest-mgmt] All REST management endpoints exercised successfully.");
} catch (err) {
console.error("[rest-mgmt] FAILED, attempting cleanup...");
if (createdIds.customer) {
await rest("DELETE", `/api/v1/customers/${createdIds.customer}`).catch(() => {});
}
if (createdIds.supplier) {
await rest("DELETE", `/api/v1/suppliers/${createdIds.supplier}`).catch(() => {});
}
if (createdIds.company) {
await rest("DELETE", `/api/v1/companies/${createdIds.company}`).catch(() => {});
}
throw err;
}
}
main().catch((err) => {
console.error("[rest-mgmt] FAILED:", err);
process.exit(1);
});