WFDASH-WEBAPP/scripts/smoke/mcp-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

340 lines
No EOL
14 KiB
TypeScript

/**
* End-to-end smoke test for the management MCP tools.
*
* Drives the actual `/api/mcp` HTTP endpoint (requires a running
* Next.js dev server with the same env this script reads) and
* exercises every CRUD tool we added for the Hermes agent support:
*
* companies.list / get / create / update / delete
* customers.list / get / create / update / delete
* suppliers.list / get / create / update / delete
*
* The test creates rows via `*.create`, mutates them via `*.update`,
* reads them back via `*.get` (asserting the relations resolve), and
* cleans up via `*.delete`. All names are prefixed with `MCP-SMOKE-…`
* so any orphans are obvious in the PB admin.
*
* Run with:
* # In one terminal: npm run dev
* # In another: npx tsx --env-file=.env.local scripts/smoke/mcp-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 = `MCP-SMOKE-CO-${RUN_ID}`;
const CU_NAME = `MCP-SMOKE-CU-${RUN_ID}`;
const SU_NAME = `MCP-SMOKE-SU-${RUN_ID}`;
const PORT = Number(process.env.PORT ?? 3000);
const BASE = `http://127.0.0.1:${PORT}`;
interface McpResponse<T = unknown> {
jsonrpc: "2.0";
id: number | null;
result?: { content: { type: string; text: string }[] };
error?: { code: number; message: string };
}
async function mcp<T = unknown>(
id: number,
method: "tools/list" | "tools/call",
params?: { name: string; arguments: Record<string, unknown> }
): Promise<T> {
const body: Record<string, unknown> = { jsonrpc: "2.0", id, method };
if (params) body.params = params;
const res = await fetch(`${BASE}/api/mcp`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}`,
},
body: JSON.stringify(body),
});
const json = (await res.json()) as McpResponse<T>;
if (json.error) {
throw new Error(`MCP ${method} ${params?.name ?? ""} failed: ${json.error.message}`);
}
if (!json.result) {
throw new Error(`MCP ${method} ${params?.name ?? ""} returned no result`);
}
// All our tools wrap the payload as content[0].text = JSON.stringify(payload).
const text = json.result.content[0].text;
return JSON.parse(text) as T;
}
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(`[mcp-mgmt] Target: ${BASE}`);
console.log(`[mcp-mgmt] Run id: ${RUN_ID}`);
const createdIds = { company: "", customer: "", supplier: "" };
try {
// ── 1. companies.create (with geoPoint) ─────────────────────
const createdCompany = await mcp<{ company: { id: string; company: string; coordinates: { lon: number; lat: number } } }>(
1,
"tools/call",
{
name: "companies.create",
arguments: {
company: CO_NAME,
address: "1 MCP Test Road",
vendor: `MCP-V-${RUN_ID}`,
coordinates: { lon: 28.0473, lat: -26.2041 },
},
}
);
createdIds.company = createdCompany.company.id;
console.log(
`[mcp-mgmt] companies.create → ${createdCompany.company.company} (${createdCompany.company.id})`
);
// ── 2. companies.get (round-trip + coords) ──────────────────
const fetchedCompany = await mcp<{ company: { id: string; company: string; coordinates: { lon: number; lat: number } } }>(
2,
"tools/call",
{ name: "companies.get", arguments: { id: createdIds.company } }
);
if (fetchedCompany.company.id !== createdIds.company) {
throw new Error(`FAIL: company.get id mismatch: ${fetchedCompany.company.id}`);
}
if (fetchedCompany.company.coordinates.lon !== 28.0473 || fetchedCompany.company.coordinates.lat !== -26.2041) {
throw new Error(
`FAIL: company coords round-trip wrong: ${JSON.stringify(fetchedCompany.company.coordinates)}`
);
}
console.log(
`[mcp-mgmt] companies.get round-trip OK, coords=${JSON.stringify(fetchedCompany.company.coordinates)}`
);
// ── 3. companies.update (rename + new coords) ───────────────
const renamed = `MCP-SMOKE-CO-RENAMED-${RUN_ID}`;
const updatedCompany = await mcp<{ company: { company: string } }>(
3,
"tools/call",
{
name: "companies.update",
arguments: {
id: createdIds.company,
patch: { company: renamed, coordinates: { lon: 18.4241, lat: -33.9249 } },
},
}
);
if (updatedCompany.company.company !== renamed) {
throw new Error(`FAIL: companies.update didn't rename: got ${updatedCompany.company.company}`);
}
console.log(`[mcp-mgmt] companies.update → ${updatedCompany.company.company}`);
// Rename back so other assertions stay readable.
await mcp(4, "tools/call", {
name: "companies.update",
arguments: { id: createdIds.company, patch: { company: CO_NAME } },
});
// ── 4. companies.list (search filter finds our row) ─────────
const companyList = await mcp<{ items: { id: string }[]; totalItems: number }>(
5,
"tools/call",
{ name: "companies.list", arguments: { search: CO_NAME, perPage: 5 } }
);
if (!companyList.items.some((i) => i.id === createdIds.company)) {
throw new Error(
`FAIL: companies.list missing our row: ${JSON.stringify(companyList.items.map((i) => i.id))}`
);
}
console.log(`[mcp-mgmt] companies.list found our row via search`);
// ── 5. Look up a real user for the rep relation ─────────────
// The agents-list lives in the (currently bootstrap-only)
// /api/v1/users/lookup endpoint. We can also resolve via the
// admin PB directly to keep this script independent of whether
// the user lookup endpoint is wired up.
const adminLookupRes = await fetch(`${BASE}/api/v1/users/lookup`, {
headers: { authorization: `Bearer ${process.env.AGENT_API_KEY ?? ""}` },
});
if (!adminLookupRes.ok) {
throw new Error(`users/lookup not OK: ${adminLookupRes.status}`);
}
const users = (await adminLookupRes.json()) as { id: string; name: string; email: string }[];
if (users.length === 0) {
throw new Error("No users in auth collection — can't back the rep relation.");
}
const repNameOrId = users[0].name || users[0].email;
console.log(`[mcp-mgmt] Resolved rep via name: ${repNameOrId}`);
// ── 6. customers.create (relations + geoPoint via name) ──────
const createdCustomer = await mcp<{ customer: { id: string; company: string; rep: string; coordinates: { lon: number; lat: number } } }>(
6,
"tools/call",
{
name: "customers.create",
arguments: {
customer: CU_NAME,
email: `mcp-cu-${RUN_ID}@example.com`,
number: "0123456789",
company: CO_NAME, // resolved to id by the service
rep: repNameOrId, // resolved to id by the service
coordinates: { lon: 18.4241, lat: -33.9249 },
},
}
);
createdIds.customer = createdCustomer.customer.id;
if (createdCustomer.customer.company !== createdIds.company) {
throw new Error(
`FAIL: customer.company not resolved: got ${createdCustomer.customer.company}, want ${createdIds.company}`
);
}
if (!createdCustomer.customer.rep) {
throw new Error("FAIL: customer.rep not resolved (empty string)");
}
console.log(
`[mcp-mgmt] customers.create → ${CU_NAME} (${createdIds.customer}) with company+rep relations resolved`
);
// ── 7. customers.get with expand (relation names inline) ───
const fetchedCustomer = await mcp<{
customer: { id: string; expand?: { company?: { company: string }; rep?: { name: string; email: string } } };
}>(
7,
"tools/call",
{ name: "customers.get", arguments: { id: createdIds.customer, expand: "company,rep" } }
);
if (fetchedCustomer.customer.expand?.company?.company !== CO_NAME) {
throw new Error(
`FAIL: customer.expand.company.company mismatch: ${fetchedCustomer.customer.expand?.company?.company}`
);
}
if (!fetchedCustomer.customer.expand?.rep) {
throw new Error("FAIL: customer.expand.rep missing");
}
console.log(
`[mcp-mgmt] customers.get expand OK: company="${fetchedCustomer.customer.expand.company.company}", rep="${fetchedCustomer.customer.expand.rep.name || fetchedCustomer.customer.expand.rep.email}"`
);
// ── 8. customers.update (change coords, keep relations) ────
const updatedCustomer = await mcp<{ customer: { coordinates: { lon: number; lat: number } } }>(
8,
"tools/call",
{
name: "customers.update",
arguments: {
id: createdIds.customer,
patch: { coordinates: { lon: 0, lat: 0 } },
},
}
);
// The service treats {0,0} as the default (PB geoPoint has no
// null), but the round-trip still has to succeed.
if (
updatedCustomer.customer.coordinates.lon !== 0 ||
updatedCustomer.customer.coordinates.lat !== 0
) {
throw new Error(
`FAIL: customers.update coords: ${JSON.stringify(updatedCustomer.customer.coordinates)}`
);
}
console.log("[mcp-mgmt] customers.update coords round-trip OK");
// ── 9. customers.list (search finds our row) ───────────────
const customerList = await mcp<{ items: { id: string }[] }>(
9,
"tools/call",
{ name: "customers.list", arguments: { search: CU_NAME, perPage: 5 } }
);
if (!customerList.items.some((i) => i.id === createdIds.customer)) {
throw new Error("FAIL: customers.list missing our row");
}
console.log("[mcp-mgmt] customers.list found our row via search");
// ── 10. suppliers.create (text coords — unchanged) ───────────
const createdSupplier = await mcp<{ supplier: { id: string; suppliername: string; coordinates: string } }>(
10,
"tools/call",
{
name: "suppliers.create",
arguments: {
suppliername: SU_NAME,
email: `mcp-su-${RUN_ID}@example.com`,
suppliernumber: `MCP-S-${RUN_ID}`,
coordinates: "-33.9249, 18.4241",
},
}
);
createdIds.supplier = createdSupplier.supplier.id;
console.log(`[mcp-mgmt] suppliers.create → ${SU_NAME} (${createdIds.supplier})`);
// ── 11. suppliers.get + update + list ───────────────────────
const fetchedSupplier = await mcp<{ supplier: { suppliername: string } }>(
11,
"tools/call",
{ name: "suppliers.get", arguments: { id: createdIds.supplier } }
);
if (fetchedSupplier.supplier.suppliername !== SU_NAME) {
throw new Error(`FAIL: suppliers.get name mismatch`);
}
await mcp(12, "tools/call", {
name: "suppliers.update",
arguments: { id: createdIds.supplier, patch: { closingtime: "16:30" } },
});
const supplierList = await mcp<{ items: { id: string }[] }>(
13,
"tools/call",
{ name: "suppliers.list", arguments: { search: SU_NAME, perPage: 5 } }
);
if (!supplierList.items.some((i) => i.id === createdIds.supplier)) {
throw new Error("FAIL: suppliers.list missing our row");
}
console.log("[mcp-mgmt] suppliers CRUD round-trip OK");
// ── 12. Cleanup via delete tools ────────────────────────────
// Delete order matters for cascade rules: customer before company.
await mcp(20, "tools/call", { name: "customers.delete", arguments: { id: createdIds.customer } });
console.log(`[mcp-mgmt] customers.delete → ${createdIds.customer}`);
await mcp(21, "tools/call", { name: "suppliers.delete", arguments: { id: createdIds.supplier } });
console.log(`[mcp-mgmt] suppliers.delete → ${createdIds.supplier}`);
await mcp(22, "tools/call", { name: "companies.delete", arguments: { id: createdIds.company } });
console.log(`[mcp-mgmt] companies.delete → ${createdIds.company}`);
console.log("\n[mcp-mgmt] All 15 MCP management tools exercised successfully.");
} catch (err) {
// Best-effort cleanup so a mid-run failure doesn't leave rows
// in production. Each delete is independently best-effort.
console.error("[mcp-mgmt] FAILED, attempting cleanup...");
if (createdIds.customer) {
await mcp(99, "tools/call", { name: "customers.delete", arguments: { id: createdIds.customer } }).catch(() => {});
}
if (createdIds.supplier) {
await mcp(99, "tools/call", { name: "suppliers.delete", arguments: { id: createdIds.supplier } }).catch(() => {});
}
if (createdIds.company) {
await mcp(99, "tools/call", { name: "companies.delete", arguments: { id: createdIds.company } }).catch(() => {});
}
throw err;
}
}
main().catch((err) => {
console.error("[mcp-mgmt] FAILED:", err);
process.exit(1);
});