test(payment): cover the CAC Bank OTP confirm path

The confirm flow settles money on a provider with no webhook, so the two
places it can go wrong are worth pinning: that the OTP is forwarded against
the GATEWAY intent id (not the local projection id), and that a rejected
code leaves the intent open instead of failing the payment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathnael
2026-07-31 08:20:35 +00:00
parent 97bfe95ec3
commit 6f5f6d7b0a

View File

@@ -0,0 +1,190 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { of, throwError } from "rxjs";
import { AxiosError, AxiosHeaders } from "axios";
import { PaymentReferenceType, ProviderPaymentStatus } from "@edr/types";
import { PaymentClientService } from "./payment-client.service";
import { PaymentService } from "./payment.service";
/** Local intent projection row (the invoice's `paymentId` points at this). */
function localIntent(overrides: Record<string, unknown> = {}) {
return {
id: "intent-1",
refId: "booking-1",
referenceType: PaymentReferenceType.SHIPMENT,
status: "action-required",
method: "cac-bank",
merchantOrderId: "EDR_INV_1",
clientAction: { type: "COLLECT_OTP", providerOrderId: "471583397" },
...overrides,
};
}
function makeRepo(rows: Record<string, unknown>[]) {
const store = [...rows];
return {
findOneBy: jest.fn((where: Record<string, unknown>) =>
Promise.resolve(
store.find((r) =>
Object.entries(where).every(([k, v]) => r[k] === v),
) ?? null,
),
),
update: jest.fn((where: { id: string }, data: Record<string, unknown>) => {
const row = store.find((r) => r.id === where.id);
if (row) Object.assign(row, data);
return Promise.resolve(undefined);
}),
};
}
describe("PaymentService.confirmOtp", () => {
const build = (
client: Partial<PaymentClientService>,
rows = [localIntent()],
) => {
const repo = makeRepo(rows);
const billing = { settleByPaymentId: jest.fn().mockResolvedValue(null) };
const service = new PaymentService(
repo as never,
client as never,
billing as never,
);
return { service, repo, billing };
};
it("settles the local intent and tells billing to settle the invoice on SUCCEEDED", async () => {
const paidAt = "2026-07-31T10:00:00.000Z";
const { service, repo, billing } = build({
getIntentByReference: jest
.fn()
.mockResolvedValue({ intentId: "gw-1", status: "REQUIRES_ACTION" }),
confirmOtp: jest.fn().mockResolvedValue({
intentId: "gw-1",
status: ProviderPaymentStatus.SUCCEEDED,
providerTxnId: "11709363209530624",
paidAt,
}),
});
const result = await service.confirmOtp("intent-1", "8280");
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({
status: "success",
transactionId: "11709363209530624",
}),
);
// Billing settles the invoice linked by this intent id.
expect(billing.settleByPaymentId).toHaveBeenCalledWith(
"intent-1",
"11709363209530624",
new Date(paidAt),
);
expect(result.status).toBe(ProviderPaymentStatus.SUCCEEDED);
});
it("forwards the OTP against the GATEWAY intent id, not the local one", async () => {
const confirmOtp = jest
.fn()
.mockResolvedValue({ status: ProviderPaymentStatus.REQUIRES_ACTION });
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp,
});
await service.confirmOtp("intent-1", "8280");
expect(confirmOtp).toHaveBeenCalledWith("gw-1", "8280");
});
it("leaves the intent open and does not settle when the OTP is not accepted", async () => {
const { service, repo, billing } = build({
getIntentByReference: jest.fn().mockResolvedValue({ intentId: "gw-1" }),
confirmOtp: jest.fn().mockResolvedValue({
status: ProviderPaymentStatus.REQUIRES_ACTION,
failureMessage: "OTP confirmation failed",
}),
});
const result = await service.confirmOtp("intent-1", "0000");
expect(billing.settleByPaymentId).not.toHaveBeenCalled();
expect(repo.update).toHaveBeenCalledWith(
{ id: "intent-1" },
expect.objectContaining({ status: "action-required" }),
);
expect(result.status).toBe(ProviderPaymentStatus.REQUIRES_ACTION);
});
it("404s when the gateway has no active intent for the reference", async () => {
const { service } = build({
getIntentByReference: jest.fn().mockResolvedValue(null),
confirmOtp: jest.fn(),
});
await expect(service.confirmOtp("intent-1", "8280")).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe("PaymentClientService.confirmOtp", () => {
const axiosErr = (status: number, message: string) =>
new AxiosError(
`Request failed with status code ${status}`,
undefined,
undefined,
undefined,
{
status,
statusText: "",
data: { message },
headers: new AxiosHeaders(),
config: { headers: new AxiosHeaders() },
},
);
const build = (request: jest.Mock) =>
new PaymentClientService({ request } as never);
it("posts the OTP to the payment service intent-confirm route", async () => {
const request = jest
.fn()
.mockReturnValue(of({ data: { intentId: "gw-1", status: "SUCCEEDED" } }));
const result = await build(request).confirmOtp("gw-1", "8280");
expect(request).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: expect.stringContaining("/payments/intents/gw-1/confirm"),
data: { otp: "8280" },
}),
);
expect(result.status).toBe("SUCCEEDED");
});
it("maps a rejected OTP (400) to BadRequest so the payer can retry", async () => {
const request = jest
.fn()
.mockReturnValue(
throwError(() => axiosErr(400, "OTP confirmation failed")),
);
await expect(build(request).confirmOtp("gw-1", "0000")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("maps an unknown intent (404) to BadRequest rather than a gateway error", async () => {
const request = jest
.fn()
.mockReturnValue(throwError(() => axiosErr(404, "PaymentIntent not found")));
await expect(build(request).confirmOtp("nope", "8280")).rejects.toBeInstanceOf(
BadRequestException,
);
});
});