import { flush, settle, timedTest } from "./boot.js"; import assert from "node:assert/strict"; import { AbortedError, ApiError, BACKOFF_START, readInvoice, type InvoiceView, type Sleep } from "../src/api.js"; import { SINGLE_MONTH } from "../src/catalog.js"; import { CODE_ATTEMPTS, DEFAULT_RETRY_AFTER_SECONDS, Flow, GIVE_UP_MS, type FlowDeps } from "../src/flow.js"; import { applyView, historyRows, offlineInvoice, recordFromView, orderPhase, selectionFromOrder, viewFor, withoutCode, type PaymentView } from "../src/order.js"; import { countdown, money, moneyCompact, startedAgo } from "../src/format.js"; import { type OrderRecord } from "../src/domain.js"; import { Store, type StorageLike } from "../src/store.js"; const apiTest = timedTest(2000); class Mem implements StorageLike { readonly m = new Map(); getItem(k: string): string | null { return this.m.get(k) ?? null; } setItem(k: string, v: string): void { this.m.set(k, v); } removeItem(k: string): void { this.m.delete(k); } } class Refusing implements StorageLike { getItem(): string | null { return null; } setItem(): void { throw new Error("QuotaExceededError"); } removeItem(): void {} } interface Reply { status: number; body?: unknown; raw?: string; headers?: Record } function toResponse(r: Reply): Response { const headerMap = r.headers ?? {}; return { ok: r.status < 400, status: r.status, headers: { get: (name: string) => { const key = Object.keys(headerMap).find((k) => k.toLowerCase() === name.toLowerCase()); return key ? headerMap[key]! : null; }, }, json: async () => { if (r.raw !== undefined) throw new SyntaxError(`Unexpected token < in JSON at position 0`); return r.body; }, text: async () => r.raw ?? JSON.stringify(r.body ?? {}), } as unknown as Response; } // Answers from a script or holds the connection like ?wait=, so maxInFlight above one means two loops are live on one order. class Net { readonly calls: Array<{ url: string; init?: RequestInit }> = []; inFlight = 0; maxInFlight = 0; private readonly scripted: Reply[] = []; private held: Array<{ resolve: (r: Reply) => void; reject: (e: unknown) => void; onAbort?: () => void; signal?: AbortSignal }> = []; script(...replies: Reply[]): void { this.scripted.push(...replies); } get pendingCount(): number { return this.held.length; } get urls(): string[] { return this.calls.map((c) => c.url); } answerHeld(...replies: Reply[]): void { const held = this.held; this.held = []; held.forEach((h, i) => { h.signal?.removeEventListener("abort", h.onAbort!); h.resolve(replies[Math.min(i, replies.length - 1)]!); }); } readonly fetch = (async (input: unknown, init?: RequestInit): Promise => { const url = String(input); this.calls.push(init ? { url, init } : { url }); this.inFlight++; this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); try { if (this.scripted.length > 0) return toResponse(this.scripted.shift()!); return toResponse(await this.hold(init?.signal ?? undefined)); } finally { this.inFlight--; } }) as unknown as typeof fetch; private hold(signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("aborted")); return; } const entry: { resolve: (r: Reply) => void; reject: (e: unknown) => void; onAbort?: () => void; signal?: AbortSignal } = { resolve, reject, ...(signal ? { signal } : {}) }; entry.onAbort = () => { this.held = this.held.filter((h) => h !== entry); reject(new Error("aborted")); }; signal?.addEventListener("abort", entry.onAbort, { once: true }); this.held.push(entry); }); } } class Clock { ms = Date.parse("2026-08-28T12:00:00Z"); private timers: Array<{ at: number; resolve: () => void; onAbort?: () => void; signal?: AbortSignal }> = []; readonly now = (): number => this.ms; readonly sleep: Sleep = (ms, signal) => { if (signal?.aborted) return Promise.reject(new AbortedError()); return new Promise((resolve, reject) => { const t: { at: number; resolve: () => void; onAbort?: () => void; signal?: AbortSignal } = { at: this.ms + ms, resolve, ...(signal ? { signal } : {}) }; t.onAbort = () => { this.timers = this.timers.filter((x) => x !== t); reject(new AbortedError()); }; signal?.addEventListener("abort", t.onAbort, { once: true }); this.timers.push(t); }); }; async advance(ms: number): Promise { this.ms += ms; const due = this.timers.filter((t) => t.at <= this.ms); this.timers = this.timers.filter((t) => t.at > this.ms); for (const t of due) { t.signal?.removeEventListener("abort", t.onAbort!); t.resolve(); } await settle(); } } interface Harness { flow: Flow; store: Store; net: Net; clock: Clock; views: PaymentView[]; codes: string[]; } let codeSeed = 0; function harness(storage: StorageLike = new Mem(), fetchOverride?: typeof fetch): Harness { const store = new Store(storage); const net = new Net(); const clock = new Clock(); const views: PaymentView[] = []; const codes: string[] = []; const deps: FlowDeps = { store, fetch: fetchOverride ?? net.fetch, sleep: clock.sleep, now: clock.now, newCode: () => { const c = `CODE${String(codeSeed++).padStart(16, "0")}`; codes.push(c); return c; }, hashCode: async (c) => `hash-of-${c}`, render: (v) => { views.push(v); }, }; return { flow: new Flow(deps), store, net, clock, views, codes }; } function offlineHarness(): Harness { const failing = (async () => { throw new TypeError("offline"); }) as unknown as typeof fetch; return harness(new Mem(), failing); } const openXmr = { status: "open", badgeType: "legend", months: 12, amount: 42000, currency: "usd", expiresAt: "2026-08-28T13:00:00Z", address: "48HqK2XmVexampleAddress9fRtWc", cryptoAmount: "1.482", cryptoCurrency: "xmr", } as const; const openCard = { status: "open", badgeType: "supporter", months: 1, amount: 700, currency: "usd", expiresAt: "2026-08-28T13:00:00Z", clientSecret: "cs_test_abc", } as const; const paidXmr = { ...openXmr, status: "paid", amountPaid: 42000, cryptoAmountPaid: "1.482", settledAt: "2026-08-28T12:05:00Z" } as const; const expiredXmr = { ...openXmr, status: "expired", amountPaid: 21000, cryptoAmountPaid: "0.734" } as const; const processingXmr = { ...openXmr, amountPaid: 42000, cryptoAmountPaid: "1.482", cryptoAmountDue: "0.001", paidInFull: true } as const; const partPaidXmr = { ...openXmr, amountPaid: 21000, cryptoAmountPaid: "0.741", paidInFull: false } as const; const createdXmr = { invoiceId: "inv_9f3a", badgeType: "legend", months: 12, amount: 42000, currency: "usd", expiresAt: "2026-08-28T13:00:00Z", address: "48HqK2XmVexampleAddress9fRtWc", cryptoAmount: "1.482", cryptoCurrency: "xmr" as const, }; function record(over: Partial = {}): OrderRecord { return { orderId: "inv_9f3a", badgeType: "legend", months: 12, createdAt: "2026-08-28T11:00:00Z", status: "open", ...over, }; } apiTest("flow: readInvoice issues the plain GET, with no wait parameter", async () => { const net = new Net(); net.script({ status: 200, body: openXmr }); const view = await readInvoice("inv 9f3a/../x", net.fetch); assert.equal(net.urls[0], "/api/invoice/inv%209f3a%2F..%2Fx"); assert.ok(!net.urls[0]!.includes("wait="), "the first load has nothing to compare against"); assert.equal(view.status, "open"); assert.equal(view.address, openXmr.address); }); apiTest("flow: readInvoice refuses a 200 whose status is not one of the three, rather than passing it on", async () => { for (const body of [{ status: "settled" }, { status: 1 }, {}, null, "paid"]) { const net = new Net(); net.script({ status: 200, body }); await assert.rejects(() => readInvoice("x", net.fetch), (e: unknown) => e instanceof ApiError && e.code === "invalid_response", `body=${JSON.stringify(body)}`); } }); apiTest("flow: readInvoice refuses a 200 the holding read refuses — the same body, the same verdict", async () => { const bodies: unknown[] = [ { ...openXmr, cryptoCurrency: "doge" }, { ...openXmr, cryptoCurrency: 7 }, { ...openXmr, address: 42 }, { ...openXmr, clientSecret: { toString: "no" } }, ]; for (const body of bodies) { const net = new Net(); net.script({ status: 200, body }); await assert.rejects(() => readInvoice("x", net.fetch), (e: unknown) => e instanceof ApiError && e.code === "invalid_response", `body=${JSON.stringify(body)}`); } }); apiTest("flow: readInvoice returns the fields and nothing else it was sent", async () => { const net = new Net(); net.script({ status: 200, body: { ...openXmr, nonsense: "