import { describe, expect, it } from "@suaveplan/testing/runner"; import { FakePaymentProvider } from "../../tests/setup/fake-provider.js"; import { resetPaymentInstruments } from "../otel/instruments.js"; import { createPaymentService } from "../services/payment-service.js"; import { MemoryPaymentIntentStore } from "../store/memory-store.js"; import { buildPaymentRoutes } from "./build-routes.js"; function makeEntries() { resetPaymentInstruments(); const provider = new FakePaymentProvider(); const store = new MemoryPaymentIntentStore(); const service = createPaymentService({ provider, store }); return { entries: buildPaymentRoutes({ service }), service, store }; } describe("buildPaymentRoutes", () => { it("returns exactly 7 route entries", { timeout: 5000 }, () => { const { entries } = makeEntries(); expect(entries).toHaveLength(7); }); it("every route is tagged 'payments'", { timeout: 5000 }, () => { const { entries } = makeEntries(); for (const e of entries) { expect(e.route.tags).toContain("payments"); } }); it("every route paths to an /intents subtree", { timeout: 5000 }, () => { const { entries } = makeEntries(); for (const e of entries) { expect(e.route.path.startsWith("/intents")).toBe(true); } }); it("every route has a handler function", { timeout: 5000 }, () => { const { entries } = makeEntries(); for (const e of entries) { expect(typeof e.handler).toBe("function"); } }); it("includes create, list, get, confirm, capture, cancel, refund", { timeout: 5000, }, () => { const { entries } = makeEntries(); const paths = entries.map( (e) => `${e.route.method.toUpperCase()} ${e.route.path}` ); expect(paths).toContain("POST /intents"); expect(paths).toContain("GET /intents"); expect(paths).toContain("GET /intents/{id}"); expect(paths).toContain("POST /intents/{id}/confirm"); expect(paths).toContain("POST /intents/{id}/capture"); expect(paths).toContain("POST /intents/{id}/cancel"); expect(paths).toContain("POST /intents/{id}/refund"); }); it("create handler forwards description + metadata to the service", { timeout: 5000, }, async () => { const { entries, store } = makeEntries(); const createEntry = entries.find( (e) => e.route.method === "post" && e.route.path === "/intents" ); if (!createEntry) throw new Error("missing create entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [], tenantId: "t1", }), req: { param: () => ({}), query: () => ({}), json: async () => ({ amount: { amount: "100", currency: "USD" }, description: "Order #42", metadata: { order: "42" }, }), raw: new Request("http://x/intents", { method: "POST" }), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await createEntry.handler(c)) as Response; expect(res.status).toBe(201); const stored = await store.listByOwner("u1"); expect(stored[0]?.tenantId).toBe("t1"); }); it("capture handler forwards an explicit amount", { timeout: 5000, }, async () => { const { entries, service } = makeEntries(); const created = await service.createIntent({ ownerId: "u1", input: { amount: { amount: 500n, currency: "USD" } }, }); const capEntry = entries.find( (e) => e.route.method === "post" && e.route.path === "/intents/{id}/capture" ); if (!capEntry) throw new Error("missing capture entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({ id: created.id }), query: () => ({}), json: async () => ({ amount: { amount: "300", currency: "USD" }, }), raw: new Request("http://x", { method: "POST" }), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await capEntry.handler(c)) as Response; expect(res.status).toBe(200); }); it("refund handler forwards full body (amount, reason, metadata)", { timeout: 5000, }, async () => { const { entries, service } = makeEntries(); const created = await service.createIntent({ ownerId: "u1", input: { amount: { amount: 1000n, currency: "USD" } }, }); await service.captureIntent({ id: created.id, ownerId: "u1" }); const refundEntry = entries.find( (e) => e.route.method === "post" && e.route.path === "/intents/{id}/refund" ); if (!refundEntry) throw new Error("missing refund entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({ id: created.id }), query: () => ({}), json: async () => ({ amount: { amount: "200", currency: "USD" }, reason: "fraudulent", metadata: { ticket: "T-99" }, }), raw: new Request("http://x", { method: "POST" }), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await refundEntry.handler(c)) as Response; expect(res.status).toBe(200); }); it("chain returns 500 when PaymentPluginError has no statusCode", { timeout: 5000, }, async () => { const { entries, service } = makeEntries(); const created = await service.createIntent({ ownerId: "u1", input: { amount: { amount: 1n, currency: "USD" } }, }); const getEntry = entries.find( (e) => e.route.method === "get" && e.route.path === "/intents/{id}" ); if (!getEntry) throw new Error("missing get entry"); const { PaymentPluginError, PAYMENT_PLUGIN_CODES } = await import( "../error-codes/error-codes.js" ); const stubService = service as unknown as { getIntent: (a: unknown) => Promise; }; const original = stubService.getIntent.bind(stubService); stubService.getIntent = async () => { throw new PaymentPluginError( PAYMENT_PLUGIN_CODES.PROVIDER_ERROR, "boom" ); }; try { const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({ id: created.id }), query: () => ({}), json: async () => ({}), raw: new Request("http://x"), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await getEntry.handler(c)) as Response; expect(res.status).toBe(500); } finally { stubService.getIntent = original; } }); it("handler returns 404 when ownerScope finds no principal", { timeout: 5000, }, async () => { const { entries } = makeEntries(); // Pick a route that does NOT call requireAuth's throw path first // (we want ownerScope to be the layer that fires) — supply a // pre-set `user: { id: '...' }` to pass requireAuth, then null // it for ownerScope by overriding `get`. const getEntry = entries.find( (e) => e.route.method === "get" && e.route.path === "/intents/{id}" ); if (!getEntry) throw new Error("missing get entry"); let toggle = true; const c = { // First call: requireAuth sees a non-null principal. // Second call (inside ownerScope): returns null to hit the // defensive branch. get: () => { if (toggle) { toggle = false; return { id: "u1", emailVerified: true, roles: [] }; } return null; }, req: { param: () => ({ id: "pi_x" }), query: () => ({}), json: async () => ({}), raw: new Request("http://x/intents/pi_x"), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await getEntry.handler(c)) as Response; expect(res.status).toBe(404); }); it("handler propagates unknown errors from inner call", { timeout: 5000, }, async () => { const { entries } = makeEntries(); const entry = entries.find( (e) => e.route.method === "post" && e.route.path === "/intents" ); if (!entry) throw new Error("missing post entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({}), query: () => ({}), json: async () => { throw new TypeError("kaboom"); }, raw: new Request("http://x/intents", { method: "POST" }), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; let caught: unknown; try { await entry.handler(c); } catch (err) { caught = err; } expect(caught).toBeInstanceOf(TypeError); }); it("list handler filters by status when query supplies one", { timeout: 5000, }, async () => { const { entries, service } = makeEntries(); await service.createIntent({ ownerId: "u1", input: { amount: { amount: 1n, currency: "USD" } }, }); const listEntry = entries.find( (e) => e.route.method === "get" && e.route.path === "/intents" ); if (!listEntry) throw new Error("missing list entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({}), query: () => ({ status: "captured" }), json: async () => ({}), raw: new Request("http://x/intents?status=captured"), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await listEntry.handler(c)) as Response; expect(res.status).toBe(200); const body = (await res.json()) as { intents: unknown[] }; expect(body.intents).toEqual([]); }); it("capture handler tolerates malformed JSON body", { timeout: 5000, }, async () => { const { entries, service } = makeEntries(); const created = await service.createIntent({ ownerId: "u1", input: { amount: { amount: 1n, currency: "USD" } }, }); const capEntry = entries.find( (e) => e.route.method === "post" && e.route.path === "/intents/{id}/capture" ); if (!capEntry) throw new Error("missing capture entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({ id: created.id }), query: () => ({}), json: async () => { throw new SyntaxError("bad json"); }, raw: new Request("http://x", { method: "POST" }), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await capEntry.handler(c)) as Response; expect(res.status).toBe(200); }); it("refund handler tolerates malformed JSON body", { timeout: 5000, }, async () => { const { entries, service } = makeEntries(); const created = await service.createIntent({ ownerId: "u1", input: { amount: { amount: 1n, currency: "USD" } }, }); await service.captureIntent({ id: created.id, ownerId: "u1" }); const refundEntry = entries.find( (e) => e.route.method === "post" && e.route.path === "/intents/{id}/refund" ); if (!refundEntry) throw new Error("missing refund entry"); const c = { get: () => ({ id: "u1", emailVerified: true, roles: [] }), req: { param: () => ({ id: created.id }), query: () => ({}), json: async () => { throw new SyntaxError("bad json"); }, raw: new Request("http://x", { method: "POST" }), }, json: (body: unknown, status: number) => new Response(JSON.stringify(body), { status }), } as never; const res = (await refundEntry.handler(c)) as Response; expect(res.status).toBe(200); }); });