Initial Commit
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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<unknown>;
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user