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