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