/** * 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( 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); });