feat(api): add OpenAPI spec and end‑to‑end tests for billing API
This commit is contained in:
+420
@@ -0,0 +1,420 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: monk-code-screen billing API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Billing/invoicing/payments backend. Invoices are ingested from CSV; rows failing
|
||||
validation (negative values, empty currency, missing/unparseable amount or dates)
|
||||
are stored with status `corrupted` rather than rejected.
|
||||
|
||||
Money fields (`unit_price`, `amount`) are integer minor units (cents): CSV `150` → `15000`.
|
||||
servers:
|
||||
- url: http://localhost:3000
|
||||
# Explicitly unauthenticated: local interview skeleton, no auth layer
|
||||
security: []
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
summary: Liveness check
|
||||
operationId: getHealth
|
||||
responses:
|
||||
"200":
|
||||
description: Service is up
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
const: true
|
||||
required: [ok]
|
||||
|
||||
/customers:
|
||||
get:
|
||||
summary: List customers
|
||||
description: Ordered by `last_updated`. Customers are deduplicated by email (case-insensitive); rows without an email are keyed by name.
|
||||
operationId: listCustomers
|
||||
responses:
|
||||
"200":
|
||||
description: All customers
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Customer"
|
||||
|
||||
/customers/{id}/invoices:
|
||||
get:
|
||||
summary: List a customer's invoices
|
||||
description: Newest first. Unknown customer ids return an empty list.
|
||||
operationId: listCustomerInvoices
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
example: cus_1a2b3c4d
|
||||
responses:
|
||||
"200":
|
||||
description: The customer's invoices (empty array when the customer is unknown)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Invoice"
|
||||
|
||||
/ingest:
|
||||
post:
|
||||
summary: Ingest invoices from CSV
|
||||
description: |
|
||||
CSV attached as the raw request body. Header must be exactly:
|
||||
`invoice_number,customer_name,customer_email,invoice_date,due_date,description,quantity,unit_price,amount,currency`
|
||||
|
||||
Messy-but-parseable values are normalized (`1/12/26`, `Jan 16 2026`, `"$12,000.00"`, `"€1,850.00"`).
|
||||
Rows failing validation are still inserted with status `corrupted`.
|
||||
Not idempotent: re-posting the same file appends duplicate invoices (customers are deduplicated).
|
||||
operationId: ingestCsv
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
example: |
|
||||
invoice_number,customer_name,customer_email,invoice_date,due_date,description,quantity,unit_price,amount,currency
|
||||
INV-1001,Acme Corp,billing@acme.com,2026-01-05,2026-02-04,Consulting services,10,150,1500,USD
|
||||
responses:
|
||||
"200":
|
||||
description: Ingest summary with per-row results
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/IngestResult"
|
||||
"400":
|
||||
description: Empty body or unexpected CSV header
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/invoice/{id}:
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Surrogate id (`inv_...`) or invoice number (`INV-1001`). Duplicate invoice numbers resolve to the newest invoice.
|
||||
schema:
|
||||
type: string
|
||||
example: INV-1001
|
||||
get:
|
||||
summary: Get an invoice with its payments
|
||||
operationId: getInvoice
|
||||
responses:
|
||||
"200":
|
||||
description: The invoice (with status) and its payments (status owned by the external PSP)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
invoice:
|
||||
$ref: "#/components/schemas/Invoice"
|
||||
payments:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Payment"
|
||||
required: [invoice, payments]
|
||||
"404":
|
||||
description: Invoice not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
put:
|
||||
summary: Update an invoice
|
||||
description: |
|
||||
Partial update of non-identity fields. `id`, `invoice_number`, `customer_id`, and
|
||||
`created_at` are immutable and rejected. At least one field is required.
|
||||
|
||||
A `corrupted` invoice can be repaired by supplying the missing fields together with a
|
||||
new `status` in the same request; moving off `corrupted` while money/date fields are
|
||||
still missing fails the database CHECK and returns 400.
|
||||
operationId: updateInvoice
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InvoiceUpdate"
|
||||
responses:
|
||||
"200":
|
||||
description: The updated invoice
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Invoice"
|
||||
"400":
|
||||
description: Validation error, unknown/immutable field, or integrity CHECK failure
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: Invoice not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/payment/{invoice_id}:
|
||||
post:
|
||||
summary: Pay an invoice
|
||||
description: |
|
||||
Only `open` and `partially_paid` invoices are payable. Payments start as `pending`;
|
||||
status transitions are owned by the external PSP. `idempotency_key` is generated
|
||||
client-side: repeating a key replays the original payment instead of double-charging.
|
||||
operationId: createPayment
|
||||
parameters:
|
||||
- name: invoice_id
|
||||
in: path
|
||||
required: true
|
||||
description: Surrogate id (`inv_...`) or invoice number (`INV-1001`).
|
||||
schema:
|
||||
type: string
|
||||
example: INV-1011
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaymentCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: Payment created (status `pending`)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaymentView"
|
||||
"200":
|
||||
description: Idempotent replay — the original payment for this key
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PaymentView"
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"404":
|
||||
description: Invoice not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Invoice is not payable (paid, void, or corrupted)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Customer:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: cus_1a2b3c4d
|
||||
customer_name:
|
||||
type: string
|
||||
example: Acme Corp
|
||||
customer_email:
|
||||
type: [string, "null"]
|
||||
description: Unique when present; null for rows ingested without an email.
|
||||
example: billing@acme.com
|
||||
last_updated:
|
||||
type: string
|
||||
example: "2026-08-10 14:33:55"
|
||||
required: [id, customer_name, customer_email, last_updated]
|
||||
|
||||
Invoice:
|
||||
type: object
|
||||
description: Money fields are integer minor units (cents). Nullable fields are only null on `corrupted` invoices.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: inv_f726dd40
|
||||
invoice_number:
|
||||
type: string
|
||||
description: Not unique — duplicate numbers in source data are ingested as-is.
|
||||
example: INV-1011
|
||||
customer_id:
|
||||
type: [string, "null"]
|
||||
example: cus_1a2b3c4d
|
||||
status:
|
||||
$ref: "#/components/schemas/InvoiceStatus"
|
||||
created_at:
|
||||
type: string
|
||||
example: "2026-01-22"
|
||||
due_on:
|
||||
type: [string, "null"]
|
||||
example: "2026-02-21"
|
||||
description:
|
||||
type: [string, "null"]
|
||||
example: Security audit
|
||||
quantity:
|
||||
type: [integer, "null"]
|
||||
example: 1
|
||||
unit_price:
|
||||
type: [integer, "null"]
|
||||
description: Minor units (cents).
|
||||
example: 1500000
|
||||
amount:
|
||||
type: [integer, "null"]
|
||||
description: Minor units (cents).
|
||||
example: 1500000
|
||||
currency:
|
||||
type: [string, "null"]
|
||||
example: USD
|
||||
required:
|
||||
[id, invoice_number, customer_id, status, created_at, due_on, description, quantity, unit_price, amount, currency]
|
||||
|
||||
InvoiceStatus:
|
||||
type: string
|
||||
enum: [open, partially_paid, paid, void, corrupted]
|
||||
|
||||
InvoiceUpdate:
|
||||
type: object
|
||||
description: All fields optional, at least one required. Unknown or immutable fields are rejected.
|
||||
additionalProperties: false
|
||||
minProperties: 1
|
||||
properties:
|
||||
status:
|
||||
$ref: "#/components/schemas/InvoiceStatus"
|
||||
due_on:
|
||||
type: string
|
||||
format: date
|
||||
example: "2026-03-01"
|
||||
description:
|
||||
type: [string, "null"]
|
||||
quantity:
|
||||
type: integer
|
||||
minimum: 1
|
||||
unit_price:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Minor units (cents).
|
||||
amount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Minor units (cents).
|
||||
currency:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Normalized to uppercase.
|
||||
example: USD
|
||||
|
||||
Payment:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
example: pay_4696225e
|
||||
idempotency_key:
|
||||
type: string
|
||||
format: uuid
|
||||
invoice_id:
|
||||
type: string
|
||||
example: inv_f726dd40
|
||||
status:
|
||||
type: string
|
||||
description: Owned by the external PSP; starts as `pending`.
|
||||
example: pending
|
||||
last_updated:
|
||||
type: string
|
||||
example: "2026-08-10 14:34:11"
|
||||
completed_at:
|
||||
type: [string, "null"]
|
||||
amount:
|
||||
type: integer
|
||||
description: Minor units (cents).
|
||||
example: 1500000
|
||||
currency:
|
||||
type: string
|
||||
example: USD
|
||||
required: [id, idempotency_key, invoice_id, status, last_updated, completed_at, amount, currency]
|
||||
|
||||
PaymentCreate:
|
||||
type: object
|
||||
properties:
|
||||
idempotency_key:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Generated client-side to prevent duplicate charges.
|
||||
amount:
|
||||
type: integer
|
||||
minimum: 1
|
||||
description: Minor units (cents).
|
||||
currency:
|
||||
type: string
|
||||
minLength: 1
|
||||
required: [idempotency_key, amount, currency]
|
||||
|
||||
PaymentView:
|
||||
type: object
|
||||
properties:
|
||||
payment:
|
||||
$ref: "#/components/schemas/Payment"
|
||||
required: [payment]
|
||||
|
||||
IngestResult:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
const: ok
|
||||
ingested:
|
||||
type: integer
|
||||
description: Total rows inserted, including corrupted ones.
|
||||
errors:
|
||||
type: integer
|
||||
description: Count of rows stored with status `corrupted`.
|
||||
rows:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
line:
|
||||
type: integer
|
||||
description: 1-based CSV line number (header is line 1).
|
||||
id:
|
||||
type: string
|
||||
invoice_number:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [open, corrupted]
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required: [line, id, invoice_number, status, errors]
|
||||
required: [status, ingested, errors, rows]
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
description: Message string, or a zod error tree for validation failures.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: object
|
||||
required: [error]
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
// 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);
|
||||
Reference in New Issue
Block a user