120 lines
6.5 KiB
TypeScript
120 lines
6.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
vi.mock("./slip-provider", () => ({
|
|
getCommissionSlipProvider: vi.fn(), slipProviderConfigured: vi.fn().mockReturnValue(true),
|
|
}));
|
|
import { getCommissionSlipProvider, slipProviderConfigured } from "./slip-provider";
|
|
import { verifyCommissionSlip } from "./payment";
|
|
import { verificationError } from "./verification-error";
|
|
|
|
const checkoutDate = new Date("2026-10-10T10:00:00Z");
|
|
const validData = { success: true, data: { success: true, transRef: "bank-ref", amount: 100,
|
|
transTimestamp: "2026-10-10T10:01:00.000Z" } };
|
|
const file = new File(["image"], "slip.png", { type: "image/png" });
|
|
const fetchMock = vi.fn();
|
|
const verify = () => verifyCommissionSlip(file, 100, checkoutDate);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-10-10T10:02:00Z"));
|
|
vi.stubEnv("SLIPOK_BRANCH_ID", "123");
|
|
vi.stubEnv("SLIPOK_API_KEY", "secret");
|
|
vi.mocked(getCommissionSlipProvider).mockResolvedValue("slipok");
|
|
vi.mocked(slipProviderConfigured).mockReturnValue(true);
|
|
fetchMock.mockResolvedValue(Response.json(validData));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
});
|
|
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
|
|
|
describe("SlipOK verification", () => {
|
|
it("sends the image, receiver logging and expected amount and normalizes the transfer", async () => {
|
|
const result = await verify();
|
|
expect(result).toMatchObject({ provider: "slipok", referenceId: "slipok:bank-ref",
|
|
transRef: "bank-ref", amountBaht: 100, transferredAt: new Date("2026-10-10T10:01:00Z"),
|
|
providerData: validData });
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(url).toBe("https://api.slipok.com/api/line/apikey/123");
|
|
expect(init).toMatchObject({ method: "POST", headers: { "x-authorization": "secret" }, cache: "no-store" });
|
|
expect(init.body.get("files")).toBeInstanceOf(File);
|
|
expect(init.body.get("log")).toBe("true");
|
|
expect(init.body.get("amount")).toBe("100");
|
|
expect(init.body.get("file")).toBeNull();
|
|
});
|
|
|
|
it("interprets legacy date and time as Bangkok time", async () => {
|
|
fetchMock.mockResolvedValue(Response.json({ success: true, data: { success: true,
|
|
transRef: "bank-ref", amount: 100, transDate: "20261010", transTime: "17:01:00" } }));
|
|
expect((await verify()).transferredAt).toEqual(new Date("2026-10-10T10:01:00Z"));
|
|
});
|
|
|
|
it.each(["invalid", "2026-02-30T10:01:00Z", "2026-10-10T25:01:00Z", "2026-10-10T10:01:00"])
|
|
("rejects an invalid timestamp %s", async (transTimestamp) => {
|
|
fetchMock.mockResolvedValue(Response.json({ ...validData, data: { ...validData.data, transTimestamp } }));
|
|
await expect(verify()).rejects.toMatchObject({ status: 503 });
|
|
});
|
|
|
|
it.each([{}, { success: true, data: {} }, { ...validData, success: false },
|
|
{ ...validData, data: { ...validData.data, success: false } }])("rejects malformed or unverified responses", async (body) => {
|
|
fetchMock.mockResolvedValue(Response.json(body));
|
|
await expect(verify()).rejects.toMatchObject({ status: 503 });
|
|
});
|
|
|
|
it.each([1000, 1001, 1002, 1003, 1004, 1009, 1010, 1015])("classifies service error %s", async (code) => {
|
|
fetchMock.mockResolvedValue(Response.json({ code }, { status: 400 }));
|
|
await expect(verify()).rejects.toMatchObject({ status: 503, message: `slipok-service:${code}` });
|
|
});
|
|
it.each([1005, 1006, 1007, 1008, 1011, 1012, 1013, 1014])("rejects slip error %s, including accompanying data", async (code) => {
|
|
fetchMock.mockResolvedValue(Response.json({ code, data: validData.data }, { status: 400 }));
|
|
await expect(verify()).rejects.toMatchObject({ status: 422, message: `slipok-rejected:${code}` });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([401, 429, 500])("handles HTTP %s without valid JSON", async (status) => {
|
|
fetchMock.mockResolvedValue(new Response("upstream failure", { status }));
|
|
await expect(verify()).rejects.toMatchObject({ status: 503 });
|
|
});
|
|
|
|
it("does not fall back on timeout", async () => {
|
|
fetchMock.mockRejectedValue(new DOMException("Timed out", "TimeoutError"));
|
|
await expect(verify()).rejects.toMatchObject({ status: 503, message: "slip-verification-unavailable" });
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([{ amount: 80 }, { transTimestamp: "2026-10-10T09:57:59Z" },
|
|
{ transTimestamp: "2026-10-10T10:04:01Z" }])("rejects mismatched amount or transfer time", async (fields) => {
|
|
fetchMock.mockResolvedValue(Response.json({ ...validData, data: { ...validData.data, ...fields } }));
|
|
await expect(verify()).rejects.toMatchObject({ status: 422, message: "slip-amount-or-date-mismatch" });
|
|
});
|
|
it.each(["2026-10-10T09:58:00Z", "2026-10-10T10:04:00Z"])("accepts the date boundary %s", async (transTimestamp) => {
|
|
fetchMock.mockResolvedValue(Response.json({ ...validData, data: { ...validData.data, transTimestamp } }));
|
|
await expect(verify()).resolves.toMatchObject({ transferredAt: new Date(transTimestamp) });
|
|
});
|
|
|
|
it("reads the provider once on every attempt and keeps bank references across a switch", async () => {
|
|
expect((await verify()).transRef).toBe("bank-ref");
|
|
vi.mocked(getCommissionSlipProvider).mockResolvedValue("slip2go");
|
|
vi.stubEnv("SLIP2GO_VERIFY_URL", "https://slip2go.test/verify");
|
|
vi.stubEnv("SLIP2GO_API_SECRET", "secret");
|
|
vi.stubEnv("COMMISSION_PROMPTPAY_TYPE", "mobile");
|
|
vi.stubEnv("COMMISSION_PROMPTPAY_VALUE", "0812345678");
|
|
vi.stubEnv("COMMISSION_RECEIVER_ACCOUNT_NUMBER", "0812345678");
|
|
fetchMock.mockResolvedValue(Response.json({ code: "200200", data: { referenceId: "provider-ref",
|
|
transRef: "bank-ref", amount: 100, dateTime: "2026-10-10T10:01:00Z" } }));
|
|
expect(await verify()).toMatchObject({ provider: "slip2go", transRef: "bank-ref", referenceId: "provider-ref" });
|
|
expect(getCommissionSlipProvider).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("does not call an unconfigured provider", async () => {
|
|
vi.mocked(slipProviderConfigured).mockReturnValue(false);
|
|
await expect(verify()).rejects.toMatchObject({ status: 503, message: "slip-provider-not-configured" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
it("warns against paying again after a provider duplicate rejection", () => {
|
|
expect(verificationError("slipok-rejected:1012")).toContain("ก่อนโอนซ้ำ");
|
|
expect(verificationError("slipok-service:1002")).toContain("API Key");
|
|
expect(verificationError("slipok-service:1010")).toContain("รอสักครู่");
|
|
});
|
|
});
|