// End-to-end API tests. Boots the real server against a throwaway DB (seeded // from invoices.csv on first boot) and exercises every endpoint, including all // the edge cases deliberately planted in the CSV. // // bun run test import { rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { z } from "zod"; const ROOT = join(import.meta.dir, ".."); const PORT = 3900 + Math.floor(Math.random() * 1000); const BASE = `http://localhost:${PORT}`; const DB_PATH = join(tmpdir(), `monk-test-${Date.now()}.db`); const BOLD = "\x1b[1m"; const DIM = "\x1b[2m"; const GREEN = "\x1b[32m"; const RED = "\x1b[31m"; const CYAN = "\x1b[36m"; const RESET = "\x1b[0m"; // --- Response schemas: network JSON is untrusted, parse it once ------------- const Customer = z.object({ id: z.string(), customer_name: z.string(), customer_email: z.string().nullable(), last_updated: z.string(), }); const Invoice = z.object({ id: z.string(), invoice_number: z.string(), customer_id: z.string().nullable(), status: z.string(), created_at: z.string(), due_on: z.string().nullable(), description: z.string().nullable(), quantity: z.number().nullable(), unit_price: z.number().nullable(), amount: z.number().nullable(), currency: z.string().nullable(), }); const Payment = z.object({ id: z.string(), idempotency_key: z.string(), invoice_id: z.string(), status: z.string(), last_updated: z.string(), completed_at: z.string().nullable(), amount: z.number(), currency: z.string(), }); const InvoiceView = z.object({ invoice: Invoice, payments: z.array(Payment) }); const PaymentView = z.object({ payment: Payment }); const IngestOk = z.object({ status: z.literal("ok"), ingested: z.number(), errors: z.number(), rows: z.array( z.object({ line: z.number(), id: z.string(), invoice_number: z.string(), status: z.enum(["open", "corrupted"]), errors: z.array(z.string()), }), ), }); // --- Harness ----------------------------------------------------------------- let passed = 0; let failed = 0; const section = (title: string) => console.log(`\n${BOLD}${CYAN}${title}${RESET}`); const check = (name: string, ok: boolean, detail?: string) => { if (ok) { passed++; console.log(` ${GREEN}✓${RESET} ${name}`); } else { failed++; console.log(` ${RED}✗ ${name}${RESET}${detail ? `\n ${DIM}${detail}${RESET}` : ""}`); } }; const eq = (name: string, actual: unknown, expected: unknown) => check(name, actual === expected, `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); type ApiResponse = { status: number; body: unknown }; const api = async (method: string, path: string, body?: unknown, raw?: string): Promise => { const res = await fetch(`${BASE}${path}`, { method, headers: body !== undefined ? { "content-type": "application/json" } : undefined, body: body !== undefined ? JSON.stringify(body) : raw, }); return { status: res.status, body: await res.json().catch(() => null) }; }; const getInvoice = async (key: string) => InvoiceView.parse((await api("GET", `/invoice/${key}`)).body).invoice; const payment = (invoice: string, overrides?: Record) => api("POST", `/payment/${invoice}`, { idempotency_key: crypto.randomUUID(), amount: 1000, currency: "USD", ...overrides, }); // --- Boot server against a throwaway DB ------------------------------------- const server = Bun.spawn(["bun", "src/index.ts"], { cwd: ROOT, env: { ...process.env, PORT: String(PORT), DB_PATH }, stdout: "ignore", stderr: "inherit", }); let up = false; for (let i = 0; i < 50 && !up; i++) { up = await fetch(`${BASE}/health`).then((r) => r.ok).catch(() => false); if (!up) await Bun.sleep(100); } if (!up) { console.error(`${RED}server failed to boot on :${PORT}${RESET}`); server.kill(); process.exit(1); } try { console.log(`${DIM}server :${PORT} db ${DB_PATH}${RESET}`); // --- GET /health ----------------------------------------------------------- section("GET /health"); const health = await api("GET", "/health"); eq("responds 200", health.status, 200); eq("body is { ok: true }", z.object({ ok: z.boolean() }).parse(health.body).ok, true); // --- GET /customers: identity edge cases ----------------------------------- section("GET /customers — customer identity edge cases"); const customers = z.array(Customer).parse((await api("GET", "/customers")).body); eq("25 CSV rows dedupe to 21 customers", customers.length, 21); const acmes = customers.filter((c) => c.customer_email === "billing@acme.com"); eq("Acme Corp / ACME CORPORATION / acme corp → one customer (email dedup)", acmes.length, 1); eq("first-seen name wins for deduped customer", acmes[0]?.customer_name, "Acme Corp"); const noEmail = customers.filter((c) => c.customer_email === null).map((c) => c.customer_name); eq("rows with empty email (Initech, Wonka) still get customers", noEmail.length, 2); check( "email-less customers keyed by name", noEmail.includes("Initech") && noEmail.includes("Wonka Industries"), `got ${JSON.stringify(noEmail)}`, ); // --- GET /customers/:id/invoices -------------------------------------------- section("GET /customers/:id/invoices"); const acmeInvoices = z.array(Invoice).parse((await api("GET", `/customers/${acmes[0]!.id}/invoices`)).body); eq("acme has 4 invoices across 3 name spellings", acmeInvoices.length, 4); eq( "exact duplicate row (INV-1001 ×2, line 9) is ingested as-is", acmeInvoices.filter((i) => i.invoice_number === "INV-1001").length, 2, ); eq( "unknown customer returns empty list", z.array(Invoice).parse((await api("GET", "/customers/cus_nope/invoices")).body).length, 0, ); // --- GET /invoice/:id: normalization edge cases ------------------------------ section("GET /invoice/:id — messy-but-parseable rows normalize"); const i1004 = await getInvoice("INV-1004"); eq("INV-1004: M/D/YY date '1/12/26' → 2026-01-12", i1004.created_at, "2026-01-12"); eq("INV-1004: quoted '$12,000.00' → 1200000 cents", i1004.amount, 1200000); eq("INV-1004: status open (messy ≠ corrupted)", i1004.status, "open"); eq("INV-1017: M/D/YYYY date '2/1/2026' → 2026-02-01", (await getInvoice("INV-1017")).created_at, "2026-02-01"); const i1007 = await getInvoice("INV-1007"); eq("INV-1007: text date 'Jan 16 2026' → 2026-01-16", i1007.created_at, "2026-01-16"); eq("INV-1007: text due date 'Feb 15 2026' → 2026-02-15", i1007.due_on, "2026-02-15"); const i1005 = await getInvoice("INV-1005"); eq("INV-1005: fractional unit_price 0.02 → 2 cents", i1005.unit_price, 2); eq("INV-1005: quantity 50000 preserved", i1005.quantity, 50000); eq("INV-1009: decimal amount 847.32 → 84732 cents", (await getInvoice("INV-1009")).amount, 84732); eq("INV-1012: decimal unit_price 49.99 → 4999 cents", (await getInvoice("INV-1012")).unit_price, 4999); eq("INV-1014: bare quoted '9,999.99' → 999999 cents", (await getInvoice("INV-1014")).amount, 999999); const i1018 = await getInvoice("INV-1018"); eq("INV-1018: '€1,850.00' → 185000 cents", i1018.amount, 185000); eq("INV-1018: non-USD currency preserved (EUR)", i1018.currency, "EUR"); eq("INV-1022: far-future dates are valid (open)", (await getInvoice("INV-1022")).status, "open"); eq("INV-1003: missing email doesn't corrupt the invoice", (await getInvoice("INV-1003")).status, "open"); // --- Corrupted rows ----------------------------------------------------------- section("Corrupted rows — stored, status 'corrupted'"); const i1010 = await getInvoice("INV-1010"); eq("INV-1010: negative values → corrupted", i1010.status, "corrupted"); eq("INV-1010: negative quantity stored as-is (-2)", i1010.quantity, -2); eq("INV-1010: negative amount stored as-is (-100000 cents)", i1010.amount, -100000); const i1020 = await getInvoice("INV-1020"); eq("INV-1020: missing amount → corrupted", i1020.status, "corrupted"); eq("INV-1020: amount stored as null", i1020.amount, null); eq("INV-1020: parseable fields kept (unit_price 480000)", i1020.unit_price, 480000); // duplicate number INV-1006: line 7 corrupted (empty currency), line 18 open revision const i1006 = await getInvoice("INV-1006"); eq("INV-1006 by number resolves to newest (revised, open)", i1006.status, "open"); eq("INV-1006 newest is the 5200.00 revision", i1006.amount, 520000); // --- GET /invoice/:id resolution ---------------------------------------------- section("GET /invoice/:id — resolution"); const i1002 = await getInvoice("INV-1002"); eq("surrogate inv_ id resolves to the same invoice", (await getInvoice(i1002.id)).invoice_number, "INV-1002"); eq("unknown invoice → 404", (await api("GET", "/invoice/INV-9999")).status, 404); // --- POST /payment/:invoice_id --------------------------------------------------- section("POST /payment/:invoice_id"); const key = crypto.randomUUID(); const pay1 = await payment("INV-1011", { idempotency_key: key, amount: 1500000 }); eq("open invoice → 201", pay1.status, 201); const created = PaymentView.parse(pay1.body).payment; eq("payment starts pending (PSP owns transitions)", created.status, "pending"); const replay = await payment("INV-1011", { idempotency_key: key, amount: 1500000 }); eq("same idempotency_key replays → 200", replay.status, 200); eq("replay returns the original payment, no double-charge", PaymentView.parse(replay.body).payment.id, created.id); const shown = InvoiceView.parse((await api("GET", "/invoice/INV-1011")).body); eq("payment visible on GET /invoice", shown.payments[0]?.id, created.id); eq("corrupted invoice (INV-1010) not payable → 409", (await payment("INV-1010")).status, 409); eq("corrupted invoice (INV-1020) not payable → 409", (await payment("INV-1020")).status, 409); eq("unknown invoice → 404", (await payment("INV-9999")).status, 404); eq("non-uuid idempotency_key → 400", (await payment("INV-1011", { idempotency_key: "nope" })).status, 400); eq("negative amount → 400", (await payment("INV-1011", { amount: -5 })).status, 400); eq("malformed JSON body → 400", (await api("POST", "/payment/INV-1011", undefined, "not json")).status, 400); // --- PUT /invoice/:id ------------------------------------------------------------- section("PUT /invoice/:id"); const put = (key: string, body: unknown) => api("PUT", `/invoice/${key}`, body); const updated = await put("INV-1002", { description: "Platform subscription Q1 (amended)", quantity: 2 }); eq("partial update → 200", updated.status, 200); const u1002 = Invoice.parse(updated.body); eq("returns the updated invoice (description)", u1002.description, "Platform subscription Q1 (amended)"); eq("returns the updated invoice (quantity)", u1002.quantity, 2); eq("untouched fields unchanged (amount)", u1002.amount, 240000); eq("currency normalized to uppercase", Invoice.parse((await put("INV-1002", { currency: "eur" })).body).currency, "EUR"); eq("description can be cleared with null", Invoice.parse((await put("INV-1002", { description: null })).body).description, null); eq("empty body {} → 400", (await put("INV-1002", {})).status, 400); eq("immutable field (invoice_number) → 400", (await put("INV-1002", { invoice_number: "INV-X" })).status, 400); eq("immutable field (customer_id) → 400", (await put("INV-1002", { customer_id: "cus_x" })).status, 400); eq("negative amount → 400", (await put("INV-1002", { amount: -1 })).status, 400); eq("malformed due_on → 400", (await put("INV-1002", { due_on: "Jan 16 2026" })).status, 400); eq("unknown invoice → 404", (await put("INV-9999", { quantity: 1 })).status, 404); // repair flow for the corrupted INV-1020 (missing amount) eq("corrupted → open without supplying missing amount → 400 (CHECK)", (await put("INV-1020", { status: "open" })).status, 400); const repaired = await put("INV-1020", { status: "open", amount: 480000 }); eq("corrupted → open with missing amount supplied → 200", repaired.status, 200); eq("repaired invoice is open", Invoice.parse(repaired.body).status, "open"); eq("repaired invoice is now payable → 201", (await payment("INV-1020")).status, 201); // --- POST /ingest (runs last: appends rows) --------------------------------------- section("POST /ingest"); eq("empty body → 400", (await api("POST", "/ingest", undefined, " ")).status, 400); eq("wrong header → 400", (await api("POST", "/ingest", undefined, "foo,bar\n1,2")).status, 400); const csv = await Bun.file(join(ROOT, "invoices.csv")).text(); const ingest = IngestOk.parse((await api("POST", "/ingest", undefined, csv)).body); eq("re-ingesting invoices.csv → status ok", ingest.status, "ok"); eq("ingested count = 25 rows", ingest.ingested, 25); eq("error count = 3 corrupted rows", ingest.errors, 3); const corrupted = ingest.rows.filter((r) => r.status === "corrupted"); eq("corrupted rows are exactly CSV lines 7, 12, 23", corrupted.map((r) => r.line).join(","), "7,12,23"); check( "INV-1006 (line 7) flagged for empty currency code", corrupted[0]?.errors.includes("empty currency code") ?? false, JSON.stringify(corrupted[0]?.errors), ); check( "INV-1010 (line 12) flagged for negative quantity and amount", (corrupted[1]?.errors.includes("negative quantity") && corrupted[1]?.errors.includes("negative amount")) ?? false, JSON.stringify(corrupted[1]?.errors), ); check( "INV-1020 (line 23) flagged for missing amount", corrupted[2]?.errors.includes("missing or invalid amount") ?? false, JSON.stringify(corrupted[2]?.errors), ); // the corrupted line-7 copy is addressable by its returned surrogate id const corrupt1006 = await getInvoice(corrupted[0]!.id); eq("line-7 INV-1006 copy: quoted '4,500.50 USD' amount still parsed", corrupt1006.amount, 450050); eq("line-7 INV-1006 copy: empty currency stored as null", corrupt1006.currency, null); eq( "re-ingest creates no duplicate customers (still 21)", z.array(Customer).parse((await api("GET", "/customers")).body).length, 21, ); } catch (err) { failed++; console.error(`\n${RED}aborted: ${err instanceof Error ? err.message : String(err)}${RESET}`); } finally { server.kill(); rmSync(DB_PATH, { force: true }); rmSync(`${DB_PATH}-wal`, { force: true }); rmSync(`${DB_PATH}-shm`, { force: true }); } console.log( `\n${BOLD}${failed === 0 ? GREEN : RED}${passed} passed, ${failed} failed${RESET} ${DIM}(${passed + failed} checks)${RESET}`, ); process.exit(failed === 0 ? 0 : 1);