233 lines
7.1 KiB
TypeScript
233 lines
7.1 KiB
TypeScript
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
import { createOpenAPIRegistry } from "@suaveplan/api-openapi";
|
|
import { createPluginContext } from "@suaveplan/api-plugin";
|
|
import { EventBus } from "@suaveplan/events";
|
|
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
createPaymentPlugin,
|
|
resetPaymentInstruments,
|
|
} from "../../src/index.js";
|
|
import type { PaymentPluginConfig } from "../../src/types/types.js";
|
|
import { FakePaymentProvider } from "../setup/fake-provider.js";
|
|
|
|
const noopLogger = {
|
|
debug: () => undefined,
|
|
info: () => undefined,
|
|
warn: () => undefined,
|
|
error: () => undefined,
|
|
};
|
|
|
|
const SECRET = "shared-webhook-secret-32-chars-or-more";
|
|
|
|
interface BootResult {
|
|
app: OpenAPIHono;
|
|
provider: FakePaymentProvider;
|
|
setPrincipal: (id: string | null) => void;
|
|
}
|
|
|
|
async function boot(): Promise<BootResult> {
|
|
resetPaymentInstruments();
|
|
const app = new OpenAPIHono();
|
|
let currentPrincipalId: string | null = "u1";
|
|
app.use("*", async (c, next) => {
|
|
if (currentPrincipalId === null) {
|
|
c.set("user", null);
|
|
} else {
|
|
c.set("user", {
|
|
id: currentPrincipalId,
|
|
emailVerified: true,
|
|
roles: [],
|
|
});
|
|
}
|
|
await next();
|
|
});
|
|
|
|
const provider = new FakePaymentProvider();
|
|
const services = new Map<string, unknown>();
|
|
services.set("app", app);
|
|
const registry = createOpenAPIRegistry({
|
|
info: { title: "test", version: "0.1" },
|
|
});
|
|
services.set("openAPIRegistry", registry);
|
|
|
|
const plugin = createPaymentPlugin({
|
|
provider,
|
|
webhookSecret: SECRET,
|
|
});
|
|
await plugin.onInit?.(
|
|
createPluginContext<PaymentPluginConfig>({
|
|
logger: noopLogger,
|
|
eventBus: new EventBus(),
|
|
signal: new AbortController().signal,
|
|
config: plugin.defaults as PaymentPluginConfig,
|
|
services,
|
|
})
|
|
);
|
|
|
|
return {
|
|
app,
|
|
provider,
|
|
setPrincipal: (id) => {
|
|
currentPrincipalId = id;
|
|
},
|
|
};
|
|
}
|
|
|
|
async function jsonReq(
|
|
app: OpenAPIHono,
|
|
path: string,
|
|
init: { method: string; body?: unknown; headers?: Record<string, string> }
|
|
) {
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/json",
|
|
...(init.headers ?? {}),
|
|
};
|
|
const reqInit: RequestInit = { method: init.method, headers };
|
|
if (init.body !== undefined) {
|
|
reqInit.body = JSON.stringify(init.body);
|
|
}
|
|
const res = await app.request(path, reqInit);
|
|
const text = await res.text();
|
|
let parsed: unknown = null;
|
|
try {
|
|
parsed = text.length > 0 ? JSON.parse(text) : null;
|
|
} catch {
|
|
parsed = text;
|
|
}
|
|
return { res, body: parsed as Record<string, unknown> | null };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetPaymentInstruments();
|
|
});
|
|
|
|
describe("payment plugin — full lifecycle", () => {
|
|
it("create → confirm → capture → refund → get", {
|
|
timeout: 60_000,
|
|
}, async () => {
|
|
const { app, setPrincipal } = await boot();
|
|
setPrincipal("u1");
|
|
|
|
const created = await jsonReq(app, "/payments/intents", {
|
|
method: "POST",
|
|
body: {
|
|
amount: { amount: "1000", currency: "USD" },
|
|
captureMethod: "manual",
|
|
},
|
|
});
|
|
expect(created.res.status).toBe(201);
|
|
const intentId = (created.body?.["intent"] as { id: string }).id;
|
|
|
|
const confirmed = await jsonReq(
|
|
app,
|
|
`/payments/intents/${intentId}/confirm`,
|
|
{ method: "POST", body: {} }
|
|
);
|
|
expect(confirmed.res.status).toBe(200);
|
|
|
|
const captured = await jsonReq(
|
|
app,
|
|
`/payments/intents/${intentId}/capture`,
|
|
{ method: "POST", body: {} }
|
|
);
|
|
expect(captured.res.status).toBe(200);
|
|
expect((captured.body?.["intent"] as { status: string }).status).toBe(
|
|
"captured"
|
|
);
|
|
|
|
const refunded = await jsonReq(
|
|
app,
|
|
`/payments/intents/${intentId}/refund`,
|
|
{
|
|
method: "POST",
|
|
body: {
|
|
amount: { amount: "400", currency: "USD" },
|
|
reason: "requested_by_customer",
|
|
},
|
|
}
|
|
);
|
|
expect(refunded.res.status).toBe(200);
|
|
|
|
const final = await jsonReq(app, `/payments/intents/${intentId}`, {
|
|
method: "GET",
|
|
});
|
|
expect(final.res.status).toBe(200);
|
|
const finalIntent = final.body?.["intent"] as {
|
|
status: string;
|
|
refundedAmount: { amount: string };
|
|
};
|
|
expect(finalIntent.status).toBe("partially_refunded");
|
|
});
|
|
|
|
it("foreign intent returns 404 anti-enumeration", {
|
|
timeout: 60_000,
|
|
}, async () => {
|
|
const { app, setPrincipal } = await boot();
|
|
setPrincipal("u1");
|
|
const created = await jsonReq(app, "/payments/intents", {
|
|
method: "POST",
|
|
body: { amount: { amount: "100", currency: "USD" } },
|
|
});
|
|
const intentId = (created.body?.["intent"] as { id: string }).id;
|
|
|
|
setPrincipal("u2");
|
|
const foreign = await jsonReq(app, `/payments/intents/${intentId}`, {
|
|
method: "GET",
|
|
});
|
|
expect(foreign.res.status).toBe(404);
|
|
|
|
const missing = await jsonReq(
|
|
app,
|
|
"/payments/intents/pi_does_not_exist",
|
|
{ method: "GET" }
|
|
);
|
|
expect(missing.res.status).toBe(404);
|
|
});
|
|
|
|
it("list returns only owner-owned intents", {
|
|
timeout: 60_000,
|
|
}, async () => {
|
|
const { app, setPrincipal } = await boot();
|
|
setPrincipal("u1");
|
|
await jsonReq(app, "/payments/intents", {
|
|
method: "POST",
|
|
body: { amount: { amount: "100", currency: "USD" } },
|
|
});
|
|
await jsonReq(app, "/payments/intents", {
|
|
method: "POST",
|
|
body: { amount: { amount: "200", currency: "USD" } },
|
|
});
|
|
setPrincipal("u2");
|
|
await jsonReq(app, "/payments/intents", {
|
|
method: "POST",
|
|
body: { amount: { amount: "300", currency: "USD" } },
|
|
});
|
|
|
|
setPrincipal("u1");
|
|
const list = await jsonReq(app, "/payments/intents", {
|
|
method: "GET",
|
|
});
|
|
expect(list.res.status).toBe(200);
|
|
expect((list.body?.["intents"] as readonly unknown[]).length).toBe(2);
|
|
});
|
|
|
|
it("cancel route works", { timeout: 60_000 }, async () => {
|
|
const { app, setPrincipal } = await boot();
|
|
setPrincipal("u1");
|
|
const created = await jsonReq(app, "/payments/intents", {
|
|
method: "POST",
|
|
body: { amount: { amount: "100", currency: "USD" } },
|
|
});
|
|
const intentId = (created.body?.["intent"] as { id: string }).id;
|
|
const canceled = await jsonReq(
|
|
app,
|
|
`/payments/intents/${intentId}/cancel`,
|
|
{ method: "POST", body: {} }
|
|
);
|
|
expect(canceled.res.status).toBe(200);
|
|
expect((canceled.body?.["intent"] as { status: string }).status).toBe(
|
|
"canceled"
|
|
);
|
|
});
|
|
});
|