241 lines
7.3 KiB
TypeScript
241 lines
7.3 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import {
|
|
defineGateway,
|
|
definePayment,
|
|
memoryPaymentStore,
|
|
paypalGateway,
|
|
razorpayGateway,
|
|
sandboxGateway,
|
|
signSandboxWebhook,
|
|
stripeGateway,
|
|
derivePaymentStatus,
|
|
verifyGatewayContract,
|
|
} from "../src/index.ts";
|
|
|
|
test("gateway occurrence order and sticky terminal states prevent webhook regression", () => {
|
|
const events = [
|
|
{
|
|
id: "refund",
|
|
intentId: "p",
|
|
type: "refunded",
|
|
status: "refunded" as const,
|
|
at: 100,
|
|
occurredAt: 300,
|
|
},
|
|
{
|
|
id: "late-capture-retry",
|
|
intentId: "p",
|
|
type: "captured",
|
|
status: "captured" as const,
|
|
at: 400,
|
|
occurredAt: 200,
|
|
},
|
|
];
|
|
expect(derivePaymentStatus(events)).toBe("refunded");
|
|
expect(
|
|
derivePaymentStatus([...events, { ...events[1], id: "new-id", at: 500, occurredAt: 500 }]),
|
|
).toBe("refunded");
|
|
});
|
|
|
|
test("sandbox drives webhook-authoritative capture and bounded partial refunds", async () => {
|
|
const store = memoryPaymentStore(),
|
|
gateway = sandboxGateway({ secret: "test-secret" });
|
|
const payment = definePayment({
|
|
store,
|
|
adapters: [gateway],
|
|
default: "sandbox",
|
|
webhookSecrets: { sandbox: "test-secret" },
|
|
environment: "test",
|
|
});
|
|
const created = await payment.initializePayment({
|
|
subject: "user-1",
|
|
amount: 1000,
|
|
currency: "USD",
|
|
idempotencyKey: "checkout-1",
|
|
});
|
|
expect(created.ok).toBe(true);
|
|
if (!created.ok) return;
|
|
expect(await payment.checkPayment(created.value.id)).toBe("pending");
|
|
// A browser redirect cannot advance local state.
|
|
expect(await payment.checkPayment(created.value.id)).toBe("pending");
|
|
const signed = await signSandboxWebhook(
|
|
{
|
|
id: "evt-1",
|
|
intentRef: created.value.gatewayRef,
|
|
type: "payment.captured",
|
|
status: "captured",
|
|
},
|
|
"test-secret",
|
|
);
|
|
const handler = payment.paymentWebhookHandler("sandbox");
|
|
expect(
|
|
(
|
|
await handler({
|
|
req: new Request("https://app.test/webhook", {
|
|
method: "POST",
|
|
headers: { "x-wrnexus-signature": signed.signature },
|
|
body: signed.body,
|
|
}),
|
|
})
|
|
).status,
|
|
).toBe(200);
|
|
expect(await payment.checkPayment(created.value.id)).toBe("captured");
|
|
const first = await payment.refundPayment({
|
|
id: created.value.id,
|
|
amount: 400,
|
|
reason: "partial return",
|
|
idempotencyKey: "refund-1",
|
|
});
|
|
expect(first.ok).toBe(true);
|
|
expect((await payment.refundableAmount(created.value.id)).amount).toBe(600);
|
|
const excessive = await payment.refundPayment({
|
|
id: created.value.id,
|
|
amount: 601,
|
|
reason: "too much",
|
|
idempotencyKey: "refund-2",
|
|
});
|
|
expect(excessive).toMatchObject({ ok: false, fault: "refused" });
|
|
expect(
|
|
(
|
|
await payment.refundPayment({
|
|
id: created.value.id,
|
|
amount: 600,
|
|
reason: "remaining return",
|
|
idempotencyKey: "refund-remaining",
|
|
})
|
|
).ok,
|
|
).toBe(true);
|
|
const stale = await signSandboxWebhook(
|
|
{
|
|
id: "evt-delayed-capture",
|
|
intentRef: created.value.gatewayRef,
|
|
type: "payment.captured",
|
|
status: "captured",
|
|
occurredAt: 1,
|
|
},
|
|
"test-secret",
|
|
);
|
|
await handler({
|
|
req: new Request("https://app.test/webhook", {
|
|
method: "POST",
|
|
headers: { "x-wrnexus-signature": stale.signature },
|
|
body: stale.body,
|
|
}),
|
|
});
|
|
expect(await payment.checkPayment(created.value.id)).toBe("refunded");
|
|
});
|
|
|
|
test("initialize, webhook, and refund replay change records once", async () => {
|
|
const store = memoryPaymentStore(),
|
|
gateway = sandboxGateway();
|
|
const payment = definePayment({
|
|
store,
|
|
adapters: [gateway],
|
|
default: "sandbox",
|
|
webhookSecrets: { sandbox: "sandbox-secret" },
|
|
});
|
|
const input = { subject: "user-2", amount: 500, currency: "EUR", idempotencyKey: "same" };
|
|
const one = await payment.initializePayment(input),
|
|
two = await payment.initializePayment(input);
|
|
expect(one.ok && two.ok && one.value.id).toBe(two.ok ? two.value.id : "");
|
|
if (!one.ok) return;
|
|
const signed = await signSandboxWebhook({
|
|
id: "evt-replay",
|
|
intentRef: one.value.gatewayRef,
|
|
type: "payment.captured",
|
|
status: "captured",
|
|
});
|
|
const request = () =>
|
|
new Request("https://app.test/webhook", {
|
|
method: "POST",
|
|
headers: { "x-wrnexus-signature": signed.signature },
|
|
body: signed.body,
|
|
});
|
|
const handler = payment.paymentWebhookHandler("sandbox");
|
|
await handler({ req: request() });
|
|
const replay = await handler({ req: request() });
|
|
expect(await replay.json()).toEqual({ received: true, duplicate: true });
|
|
const refund = await payment.refundPayment({
|
|
id: one.value.id,
|
|
amount: 100,
|
|
reason: "return",
|
|
idempotencyKey: "same-refund",
|
|
});
|
|
const duplicate = await payment.refundPayment({
|
|
id: one.value.id,
|
|
amount: 100,
|
|
reason: "return",
|
|
idempotencyKey: "same-refund",
|
|
});
|
|
expect(refund.ok && duplicate.ok && refund.value.id).toBe(duplicate.ok ? duplicate.value.id : "");
|
|
expect(await payment.listRefunds(one.value.id)).toHaveLength(1);
|
|
});
|
|
|
|
test("selection and capability differences are refused before money moves", async () => {
|
|
const sandbox = sandboxGateway();
|
|
const noPartial = defineGateway({
|
|
...sandbox,
|
|
id: "full-only",
|
|
capabilities: { ...sandbox.capabilities, partialRefund: false },
|
|
});
|
|
const payment = definePayment({
|
|
store: memoryPaymentStore(),
|
|
adapters: [sandbox, noPartial],
|
|
default: "sandbox",
|
|
route: (input) => (input.currency === "INR" ? "full-only" : "sandbox"),
|
|
});
|
|
const manual = await payment.initializePayment({
|
|
subject: "u",
|
|
amount: 100,
|
|
currency: "INR",
|
|
country: "IN",
|
|
capture: "manual",
|
|
idempotencyKey: "route",
|
|
});
|
|
expect(manual.ok).toBe(true);
|
|
expect(verifyGatewayContract(sandbox)).toEqual({ ok: true, issues: [] });
|
|
const broken = defineGateway({
|
|
...sandbox,
|
|
id: "broken",
|
|
capabilities: { ...sandbox.capabilities, authorizeThenCapture: false },
|
|
});
|
|
expect(verifyGatewayContract(broken).ok).toBe(false);
|
|
});
|
|
|
|
test("invalid webhook signatures never update payment status", async () => {
|
|
const gateway = sandboxGateway(),
|
|
payment = definePayment({
|
|
store: memoryPaymentStore(),
|
|
adapters: [gateway],
|
|
default: "sandbox",
|
|
webhookSecrets: { sandbox: "right" },
|
|
});
|
|
const created = await payment.initializePayment({
|
|
subject: "u",
|
|
amount: 100,
|
|
currency: "USD",
|
|
idempotencyKey: "bad-hook",
|
|
});
|
|
if (!created.ok) return;
|
|
const response = await payment.paymentWebhookHandler("sandbox")({
|
|
req: new Request("https://app.test", {
|
|
method: "POST",
|
|
headers: { "x-wrnexus-signature": "wrong" },
|
|
body: JSON.stringify({ id: "bad", intentRef: created.value.gatewayRef, status: "captured" }),
|
|
}),
|
|
});
|
|
expect(response.status).toBe(400);
|
|
expect(await payment.checkPayment(created.value.id)).toBe("pending");
|
|
});
|
|
|
|
test("every Tier 1 adapter passes the shared capability contract", () => {
|
|
const adapters = [
|
|
sandboxGateway(),
|
|
stripeGateway({ secretKey: "sk_test", publishableKey: "pk_test", webhookSecret: "whsec" }),
|
|
razorpayGateway({ keyId: "rzp_test", keySecret: "secret", webhookSecret: "hook" }),
|
|
paypalGateway({ clientId: "client", clientSecret: "secret", webhookId: "hook" }),
|
|
];
|
|
for (const adapter of adapters)
|
|
expect(verifyGatewayContract(adapter)).toEqual({ ok: true, issues: [] });
|
|
});
|