Files

332 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<ApiResponse> => {
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<string, unknown>) =>
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);