mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
feat: integration tests
This commit is contained in:
249
integration/src/payment-failure.it.ts
Normal file
249
integration/src/payment-failure.it.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* What happens when the bank misbehaves. Each test forces the gateway mock
|
||||
* into a failure mode and asserts the platform's answer — the point being that
|
||||
* NO failure may ever settle an invoice that was not paid, and no failure may
|
||||
* lose a payment that was.
|
||||
*
|
||||
* Covered: provider down at initiate, hard decline, forged signature, replayed
|
||||
* callback, silent settlement found only by the reconciliation sweep, and the
|
||||
* unverifiable answer that must stop freight from expiring a paying customer.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { closeDb, db, gateway, payment, poll, sleep } from "./client";
|
||||
import {
|
||||
createImportSchedule,
|
||||
currentInvoice,
|
||||
departureAt,
|
||||
ensureCorridorRoute,
|
||||
releaseUnpaidHolds,
|
||||
forceWindowOpen,
|
||||
gatewayIntent,
|
||||
invoiceForBooking,
|
||||
payInvoice,
|
||||
prepareBooking,
|
||||
resetCorridorDay,
|
||||
runBatch,
|
||||
type ReadyBooking,
|
||||
} from "./flows";
|
||||
|
||||
const DEPARTURE = departureAt(6);
|
||||
const STAMP = String(Date.now());
|
||||
|
||||
/** Four independent bookings so one test's terminal state can't poison another. */
|
||||
const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const;
|
||||
type CaseName = (typeof CASES)[number];
|
||||
|
||||
describe("payment failure and recovery", () => {
|
||||
const bookings = new Map<CaseName, ReadyBooking>();
|
||||
const invoices = new Map<CaseName, string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await gateway.reset();
|
||||
await releaseUnpaidHolds();
|
||||
await ensureCorridorRoute();
|
||||
await resetCorridorDay(DEPARTURE);
|
||||
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||
await forceWindowOpen(schedule.id, 45);
|
||||
|
||||
let isoSeed = 100;
|
||||
for (const suffix of CASES) {
|
||||
bookings.set(
|
||||
suffix,
|
||||
await prepareBooking({
|
||||
suffix,
|
||||
departure: DEPARTURE,
|
||||
runStamp: STAMP,
|
||||
isoSeed,
|
||||
twenty: 2,
|
||||
}),
|
||||
);
|
||||
isoSeed += 2;
|
||||
}
|
||||
await runBatch(schedule.id);
|
||||
for (const suffix of CASES) {
|
||||
invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id);
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
afterAll(closeDb);
|
||||
|
||||
it("leaves the invoice payable when the provider is unreachable", async () => {
|
||||
const invoiceId = invoices.get("FAIL1")!;
|
||||
await gateway.mode("cbe-birr", "fail");
|
||||
|
||||
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// No phantom settlement, and the customer can retry.
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
expect(invoice.status).not.toBe("PAID");
|
||||
expect(invoice.paid_at).toBeNull();
|
||||
|
||||
await gateway.mode("cbe-birr", "ok");
|
||||
const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||
expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201);
|
||||
});
|
||||
|
||||
it("keeps the invoice open on a declined payment", async () => {
|
||||
const { bookingId } = bookings.get("FAIL2")!;
|
||||
const invoiceId = invoices.get("FAIL2")!;
|
||||
await gateway.mode("cbe-birr", "ok");
|
||||
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" });
|
||||
|
||||
const failed = await poll<{ status: string }>(
|
||||
"intent FAILED",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "FAILED",
|
||||
{ attempts: 20, intervalMs: 1000 },
|
||||
);
|
||||
expect(failed.status).toBe("FAILED");
|
||||
|
||||
const invoice = await currentInvoice(invoiceId);
|
||||
expect(invoice.status).not.toBe("PAID");
|
||||
});
|
||||
|
||||
it("ignores a forged signature — event recorded, money untouched", async () => {
|
||||
const { bookingId } = bookings.get("FAIL3")!;
|
||||
const invoiceId = invoices.get("FAIL3")!;
|
||||
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
const res = await gateway.webhook({
|
||||
merchantOrderId: intent.merchant_order_id,
|
||||
signature: "bad",
|
||||
});
|
||||
// Providers must still get a 2xx (Waafi times out at 5s and never retries).
|
||||
expect(res.body.delivered).toBe(200);
|
||||
|
||||
const event = await poll<{ signature_valid: boolean; processing_error: string | null }>(
|
||||
"forged webhook recorded",
|
||||
`SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event
|
||||
WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`,
|
||||
[intent.merchant_order_id],
|
||||
(row) => !!row,
|
||||
{ attempts: 15, intervalMs: 1000 },
|
||||
);
|
||||
expect(event.signature_valid).toBe(false);
|
||||
expect(event.processing_error).toBe("signature-invalid");
|
||||
|
||||
await sleep(3000);
|
||||
const after = await db<{ status: string }>(
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
);
|
||||
expect(after[0].status).not.toBe("SUCCEEDED");
|
||||
expect((await currentInvoice(invoiceId)).status).not.toBe("PAID");
|
||||
});
|
||||
|
||||
it("settles from the reconciliation sweep alone, with no callback at all", async () => {
|
||||
const { bookingId } = bookings.get("FAIL4")!;
|
||||
const invoiceId = invoices.get("FAIL4")!;
|
||||
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
// The customer pays at the bank, but the callback is lost in the network.
|
||||
await gateway.settle(intent.merchant_order_id);
|
||||
|
||||
// RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough.
|
||||
const settled = await poll<{ status: string }>(
|
||||
"intent settled by sweep",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "SUCCEEDED",
|
||||
{ attempts: 30, intervalMs: 3000 },
|
||||
);
|
||||
expect(settled.status).toBe("SUCCEEDED");
|
||||
|
||||
const invoice = await poll<{ status: string }>(
|
||||
"invoice PAID via sweep",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
expect(invoice.status).toBe("PAID");
|
||||
|
||||
// No webhook was ever delivered for this one.
|
||||
const [{ n }] = await db<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||
WHERE merchant_order_id = $1`,
|
||||
[intent.merchant_order_id],
|
||||
);
|
||||
expect(Number(n)).toBe(0);
|
||||
});
|
||||
|
||||
it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => {
|
||||
// FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a
|
||||
// reference with nothing to verify legitimately answers "not paid".
|
||||
const { bookingId } = bookings.get("FAIL1")!;
|
||||
await gateway.mode("cbe-birr", "timeout");
|
||||
|
||||
const res = await payment("post", "/payments/reconcile", {
|
||||
service: "FREIGHT",
|
||||
referenceType: "SHIPMENT",
|
||||
referenceId: bookingId,
|
||||
});
|
||||
await gateway.mode("cbe-birr", "ok");
|
||||
|
||||
expect([200, 201]).toContain(res.status);
|
||||
const body = res.body?.data ?? res.body;
|
||||
expect(body.paid).toBe(false);
|
||||
// An unknown answer must never read as "definitely unpaid" — that is what
|
||||
// stops the batch engine from expiring a customer who actually paid.
|
||||
expect(body.unverifiable).toBe(true);
|
||||
});
|
||||
|
||||
it("captures late: a settlement after the intent expired still pays the invoice", async () => {
|
||||
const { bookingId } = bookings.get("FAIL3")!;
|
||||
const invoiceId = invoices.get("FAIL3")!;
|
||||
const intent = await gatewayIntent(bookingId);
|
||||
|
||||
// Retire the intent the way an expiry sweep would, then let the money land.
|
||||
await db(
|
||||
`UPDATE edr_payment.payment_intent
|
||||
SET status = 'EXPIRED', expires_at = now() - interval '1 minute'
|
||||
WHERE id = $1`,
|
||||
[intent.id],
|
||||
);
|
||||
await gateway.webhook({
|
||||
merchantOrderId: intent.merchant_order_id,
|
||||
eventId: `LATE-${intent.merchant_order_id}`,
|
||||
});
|
||||
|
||||
const captured = await poll<{ status: string }>(
|
||||
"late capture flips the intent",
|
||||
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||
[intent.id],
|
||||
(row) => row?.status === "SUCCEEDED",
|
||||
{ attempts: 20, intervalMs: 1000 },
|
||||
);
|
||||
expect(captured.status).toBe("SUCCEEDED");
|
||||
|
||||
const invoice = await poll<{ status: string }>(
|
||||
"invoice settled by late capture",
|
||||
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||
[invoiceId],
|
||||
(row) => row?.status === "PAID",
|
||||
{ attempts: 30, intervalMs: 2000 },
|
||||
);
|
||||
expect(invoice.status).toBe("PAID");
|
||||
});
|
||||
|
||||
it("retries delivery until the consumer is back", async () => {
|
||||
// Deliberately not covered here: it needs the freight container stopped
|
||||
// mid-test, which would break every other file sharing this stack.
|
||||
// `node integration/scripts/it.mjs logs` + a manual `docker compose stop
|
||||
// freight-api-e2e` reproduces it; the relay's backoff is unit-testable.
|
||||
// ponytail: outbox retry asserted only via attempts>0 below; add a
|
||||
// dedicated single-file stack if this ever regresses.
|
||||
const rows = await db<{ status: string; attempts: number }>(
|
||||
`SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`,
|
||||
);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.every((r) => r.status !== "FAILED")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user