import { afterAll, afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import type { BillingCheckoutStatus } from "@opengeni/sdk";
import { act } from "react";
import type { Root } from "react-dom/client";
import type { CreditCheckoutClient } from "../../lib/credit-checkout";
GlobalRegistrator.register();
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean | undefined }).IS_REACT_ACT_ENVIRONMENT = true;
afterAll(() => GlobalRegistrator.unregister());
// React's input-event detection needs a document when react-dom is imported.
const { createRoot } = await import("react-dom/client");
const { CouponRedeem } = await import("./coupon-redeem");
let root: Root | undefined;
let restoreWindowOpen: (() => void) | undefined;
afterEach(async () => {
await act(async () => root?.unmount());
document.body.replaceChildren();
restoreWindowOpen?.();
});
function checkoutTab() {
const tab = {
location: { href: "" },
close: mock(() => undefined),
document: { title: "", body: { style: { cssText: "" }, textContent: "" } },
opener: null,
};
const opened = spyOn(window, "open").mockReturnValue(tab as unknown as Window);
restoreWindowOpen = () => opened.mockRestore();
return { tab, opened };
}
async function mount(client: CreditCheckoutClient, onGranted = mock(() => undefined)) {
const container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
await act(async () => {
root!.render(
,
);
});
return container;
}
async function enterCode(container: HTMLElement, value: string) {
const input = container.querySelector("input")!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
return input;
}
async function submit(container: HTMLElement) {
await act(async () => {
container
.querySelector("form")!
.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
}
describe("coupon redemption", () => {
test("switching accounts during checkout creation cannot navigate or poll the old checkout", async () => {
const { tab } = checkoutTab();
let finish!: (value: { checkoutSessionId: string; url: string }) => void;
const getBillingCheckout = mock();
const onGranted = mock(() => undefined);
const client = {
createBillingCheckout: mock(
() =>
new Promise((resolve) => {
finish = resolve;
}),
),
getBillingCheckout,
} as unknown as CreditCheckoutClient;
const container = await mount(client, onGranted);
await enterCode(container, "LAUNCH100");
await submit(container);
await act(async () =>
root!.render(
,
),
);
expect(container.querySelector("input")!.value).toBe("");
await act(async () =>
finish({ checkoutSessionId: "cs_test_old", url: "https://checkout.stripe.test/old" }),
);
expect(tab.location.href).toBe("");
expect(tab.close).toHaveBeenCalledTimes(1);
expect(getBillingCheckout).not.toHaveBeenCalled();
expect(onGranted).not.toHaveBeenCalled();
});
test("one submission opens checkout and reports the granted credits once", async () => {
const { tab, opened } = checkoutTab();
const createBillingCheckout = mock(
async (_input: Parameters[0]) => ({
checkoutSessionId: "cs_test_credits",
url: "https://checkout.stripe.test/credits",
}),
);
const status = {
checkoutSessionId: "cs_test_credits",
status: "complete",
credit: { state: "granted", amountMicros: 100_000_000, currency: "usd", free: true },
balance: null,
} satisfies BillingCheckoutStatus;
const onGranted = mock(() => undefined);
const container = await mount(
{
createBillingCheckout,
getBillingCheckout: mock(async () => status),
} as CreditCheckoutClient,
onGranted,
);
await enterCode(container, " LAUNCH100 ");
await submit(container);
expect(opened).toHaveBeenCalledTimes(1);
expect(createBillingCheckout).toHaveBeenCalledTimes(1);
expect(createBillingCheckout.mock.calls[0]?.[0]).toMatchObject({
accountId: "account-test",
promotionCode: "LAUNCH100",
});
expect(tab.location.href).toBe("https://checkout.stripe.test/credits");
expect(onGranted).toHaveBeenCalledTimes(1);
expect(onGranted).toHaveBeenCalledWith(status);
expect(container.querySelector("input")!.value).toBe("");
});
test("invalid codes retain the value, close the blank tab, and return focus for correction", async () => {
const { tab } = checkoutTab();
const container = await mount({
createBillingCheckout: mock(async () => {
throw new Error("That code isn't valid or has expired.");
}),
getBillingCheckout: mock(),
} as CreditCheckoutClient);
await enterCode(container, "WRONG");
await submit(container);
const input = container.querySelector("input")!;
expect(tab.close).toHaveBeenCalledTimes(1);
expect(input.value).toBe("WRONG");
expect(input.getAttribute("aria-invalid")).toBe("true");
expect(document.activeElement).toBe(input);
expect(container.querySelector('[role="alert"]')?.textContent).toContain("isn't valid");
await enterCode(container, "LAUNCH100");
expect(input.hasAttribute("aria-invalid")).toBe(false);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
test("switching codes restores focus and ignores a late result from the old checkout", async () => {
checkoutTab();
let finish!: (status: BillingCheckoutStatus) => void;
const onGranted = mock(() => undefined);
const container = await mount(
{
createBillingCheckout: mock(async () => ({
checkoutSessionId: "cs_test_credits",
url: "https://checkout.stripe.test/credits",
})),
getBillingCheckout: mock(
() =>
new Promise((resolve) => {
finish = resolve;
}),
),
} as CreditCheckoutClient,
onGranted,
);
await enterCode(container, "LAUNCH100");
await submit(container);
expect(container.textContent).toContain("Finish in Stripe");
await act(async () => {
[...container.querySelectorAll("button")]
.find((button) => button.textContent === "Use another code")!
.click();
});
const input = await enterCode(container, "NEWCODE");
expect(document.activeElement).toBe(input);
await act(async () =>
finish({
checkoutSessionId: "cs_test_credits",
status: "complete",
credit: { state: "granted", amountMicros: 100_000_000, currency: "usd", free: true },
balance: null,
}),
);
expect(onGranted).not.toHaveBeenCalled();
expect(input.value).toBe("NEWCODE");
});
});