Files
buzz/lib/payment.test.ts
T
gunshiz beb0cfaf3d
Build and Push Image / build-backend (push) Successful in 2m27s
Build and Push Image / build-frontend (push) Successful in 4m4s
Build and Push Image / push (push) Successful in 1m1s
feat: add switchable SlipOK and Slip2Go verification
2026-10-15 02:17:35 +07:00

222 lines
7.9 KiB
TypeScript

import {
afterEach,
beforeEach,
describe,
expect,
mock,
spyOn,
test,
} from "bun:test";
// Keep these module mocks in this focused test process: no external DB/auth/services.
let storedProvider: string | undefined;
let authorized = true;
const saved = mock((values: { slipCheckProvider: string }) => {
storedProvider = values.slipCheckProvider;
});
const primarySelect = mock(() => ({
from: () => ({
where: async () =>
storedProvider === undefined ? [] : [{ provider: storedProvider }],
limit: async () =>
storedProvider === undefined
? []
: [{ slipCheckProvider: storedProvider }],
}),
}));
const replicaSelect = mock(() => {
throw new Error("Provider must not be read from replica");
});
mock.module("@/lib/db", () => ({
db: { select: replicaSelect },
primaryDb: {
select: primarySelect,
insert: () => ({
values: (values: { slipCheckProvider: string }) => ({
onConflictDoUpdate: async () => saved(values),
}),
}),
},
}));
mock.module("@/lib/auth", () => ({ adminCheck: async () => authorized }));
const actionLog = mock(async () => {});
mock.module("@/lib/api", () => ({ actionLog }));
const revalidatePath = mock(() => {});
mock.module("next/cache", () => ({ revalidatePath }));
mock.module("@/lib/db/sse-endpoints", () => ({ sse: {} }));
mock.module("@/util/sync", () => ({ syncAmber: async () => {} }));
const healthCache = new Map<string, string>();
mock.module("@/lib/db/redis", () => ({
redis: {
ping: async () => "PONG",
exists: async (key: string) => Number(healthCache.has(key)),
get: async (key: string) => healthCache.get(key),
setex: async (key: string, _seconds: number, value: string) =>
healthCache.set(key, value),
},
}));
const { checkSlip, getSlipProvider } = await import("./payment");
const { getSettongs, setSlipCheckProvider } = await import(
"@/app/(ui)/admin/settings/api"
);
const { GET: getHealth } = await import("@/app/api/health/route");
const testEnv = {
SLIPOK_API_URL: "https://slipok.example/verify",
SLIPOK_API_KEY: "test-slipok-key",
SLIP2GO_VERIFY_URL: "https://slip2go.example/api/verify-slip/qr-image/info",
SLIP2GO_API_SECRET: "test-slip2go-key",
PROMPTPAY_TYPE: "mobile",
PROMPTPAY_IDENTIFIER: "0812345678",
};
const previousEnv = Object.fromEntries(
Object.keys(testEnv).map((key) => [key, process.env[key]]),
);
let fetchSpy: ReturnType<typeof spyOn<typeof globalThis, "fetch">> | undefined;
beforeEach(() => {
Object.assign(process.env, testEnv);
storedProvider = undefined;
authorized = true;
saved.mockClear();
actionLog.mockClear();
revalidatePath.mockClear();
primarySelect.mockClear();
replicaSelect.mockClear();
healthCache.clear();
});
afterEach(() => {
fetchSpy?.mockRestore();
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
describe("saved slip provider", () => {
test("defaults to SlipOK without a settings row", async () => {
expect(await getSlipProvider()).toBe("slipok");
expect((await getSettongs()).slipCheckProvider).toBe("slipok");
expect(replicaSelect).not.toHaveBeenCalled();
});
test("only admins can change or read admin settings", async () => {
authorized = false;
await expect(setSlipCheckProvider("slip2go")).rejects.toBe("Unauthorized");
await expect(getSettongs()).rejects.toBe("Unauthorized");
expect(saved).not.toHaveBeenCalled();
});
test("rejects unsupported providers before saving", async () => {
expect((await setSlipCheckProvider("unknown")).success).toBe(false);
expect(saved).not.toHaveBeenCalled();
});
test("does not save a provider with missing credentials", async () => {
storedProvider = "slipok";
delete process.env.SLIP2GO_API_SECRET;
expect((await setSlipCheckProvider("slip2go")).success).toBe(false);
expect(await getSlipProvider()).toBe("slipok");
expect(saved).not.toHaveBeenCalled();
});
test("requires a valid recipient before switching to Slip2Go", async () => {
delete process.env.PROMPTPAY_IDENTIFIER;
expect((await setSlipCheckProvider("slip2go")).success).toBe(false);
expect(saved).not.toHaveBeenCalled();
});
test("saves, audits, refreshes, and takes effect on the next check", async () => {
const transaction = { transRef: "SAME-BANK-REF", amount: 10 };
fetchSpy = spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
Response.json({ success: true, data: transaction }),
)
.mockResolvedValueOnce(
Response.json({ code: "200200", data: transaction }),
)
.mockResolvedValueOnce(
Response.json({ success: true, data: transaction }),
);
const buffer = Buffer.from("slip");
const first = await checkSlip(buffer, "image/png", 10);
expect(first.provider).toBe("slipok");
expect(await setSlipCheckProvider("slip2go")).toEqual({
success: true,
provider: "slip2go",
});
expect((await getSettongs()).slipCheckProvider).toBe("slip2go");
expect(actionLog).toHaveBeenCalledWith(
"Changed slip verification provider",
{ provider: "slip2go" },
);
expect(revalidatePath).toHaveBeenCalledWith("/admin/settings");
const second = await checkSlip(buffer, "image/png", 10);
expect(second.provider).toBe("slip2go");
if (!first.success || !second.success)
throw new Error("Expected verified transactions");
// Both providers keep the same bank reference for the existing unique DB constraint.
expect(first.data.transRef).toBe(second.data.transRef);
await setSlipCheckProvider("slipok");
expect((await checkSlip(buffer, "image/png", 10)).provider).toBe("slipok");
expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([
testEnv.SLIPOK_API_URL,
testEnv.SLIP2GO_VERIFY_URL,
testEnv.SLIPOK_API_URL,
]);
expect(replicaSelect).not.toHaveBeenCalled();
});
test("an in-progress check keeps its starting provider", async () => {
let complete!: (response: Response) => void;
const request = new Promise<Response>((resolve) => {
complete = resolve;
});
fetchSpy = spyOn(globalThis, "fetch").mockReturnValue(request);
const pending = checkSlip(Buffer.from("slip"), "image/png", 10);
// Wait until the first request is sent, then change the setting.
await Bun.sleep(0);
expect(fetchSpy).toHaveBeenCalledTimes(1);
await setSlipCheckProvider("slip2go");
complete(
Response.json({ success: true, data: { transRef: "REF", amount: 10 } }),
);
expect((await pending).provider).toBe("slipok");
expect(await getSlipProvider()).toBe("slip2go");
});
});
describe("selected provider health", () => {
test("checks origins without uploading slips and keeps caches separate", async () => {
fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 200 }),
);
expect((await (await getHealth()).json()).slip).toBe(true);
expect(healthCache.get("health:slip:slipok")).toBe("ok");
await setSlipCheckProvider("slip2go");
fetchSpy.mockResolvedValue(new Response(null, { status: 503 }));
expect((await (await getHealth()).json()).slip).toBe(false);
expect(healthCache.has("health:slip:slip2go")).toBe(false);
const providerCalls = fetchSpy.mock.calls.filter(([url]) =>
String(url).includes("slip"),
);
expect(providerCalls.map(([url]) => String(url))).toEqual([
"https://slipok.example",
"https://slip2go.example",
]);
for (const [, init] of providerCalls) {
expect(init?.method).toBeUndefined();
expect(init?.body).toBeUndefined();
expect(init?.headers).toBeUndefined();
}
});
test("an API origin returning 404 is reachable", async () => {
storedProvider = "slip2go";
fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, { status: 404 }),
);
expect((await (await getHealth()).json()).slip).toBe(true);
expect(healthCache.get("health:slip:slip2go")).toBe("ok");
});
});