Files
cozsweet-frontend-nextjs/src/app/subscription/components/__tests__/subscription-checkout-button.test.tsx
T
Codex 4cbdd0da7c
Docker Image / Build and Push Docker Image (push) Successful in 2m5s
fix(payment): resume QRIS after closing dialog
2026-07-29 12:27:56 +08:00

245 lines
7.1 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
dispatch: vi.fn(),
handleQrisResume: vi.fn(),
hasHiddenQrisPayment: false,
resetLaunchState: vi.fn(),
payment: {
selectedPlanId: "vip_monthly",
autoRenew: true,
payChannel: "stripe" as "stripe" | "ezpay",
commercialOfferId: null,
chatActionId: null,
currentOrderId: null,
errorMessage: null,
isCreatingOrder: false,
isPollingOrder: false,
},
}));
vi.mock("@/stores/payment/payment-context", () => ({
usePaymentState: () => mocks.payment,
usePaymentDispatch: () => mocks.dispatch,
}));
vi.mock("@/app/_hooks/use-payment-launch-flow", () => ({
usePaymentLaunchFlow: () => ({
handleQrisResume: mocks.handleQrisResume,
hasHiddenQrisPayment: mocks.hasHiddenQrisPayment,
resetLaunchState: mocks.resetLaunchState,
launch: {},
}),
}));
vi.mock("@/app/_components/payment/payment-launch-dialogs", () => ({
PaymentLaunchDialogs: () => null,
}));
vi.mock("@/app/_components/payment/external-browser-checkout-button", () => ({
ExternalBrowserCheckoutButton: () => null,
}));
vi.mock("../subscription-cta-button", () => ({
SubscriptionCtaButton: ({
children,
disabled,
onClick,
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
}) => (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("../subscription-renewal-confirmation-dialog", () => ({
SubscriptionRenewalConfirmationDialog: ({
open,
onCancel,
onConfirm,
}: {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}) =>
open ? (
<div role="dialog">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
) : null,
}));
import { SubscriptionCheckoutButton } from "../subscription-checkout-button";
const renewalPlan = {
id: "vip_monthly",
title: "Monthly",
price: "19.90",
currency: "usd",
originalPrice: "39.90",
};
describe("SubscriptionCheckoutButton renewal confirmation", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
.IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
mocks.dispatch.mockClear();
mocks.handleQrisResume.mockClear();
mocks.hasHiddenQrisPayment = false;
mocks.resetLaunchState.mockClear();
mocks.payment.selectedPlanId = "vip_monthly";
mocks.payment.autoRenew = true;
mocks.payment.payChannel = "stripe";
mocks.payment.isCreatingOrder = false;
mocks.payment.isPollingOrder = false;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("shows confirmation on the first auto-renewing VIP checkout and resumes after confirm", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.dispatch).not.toHaveBeenCalled();
act(() => clickButton(container, "Confirm"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("does not ask the same user again after confirmation", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
act(() => clickButton(container, "Confirm"));
mocks.dispatch.mockClear();
mocks.resetLaunchState.mockClear();
act(() => root.unmount());
root = createRoot(container);
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.resetLaunchState).toHaveBeenCalledOnce();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("keeps asking after cancel and isolates acknowledgement by user", () => {
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
act(() => clickButton(container, "Cancel"));
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
act(() => clickButton(container, "Confirm"));
mocks.dispatch.mockClear();
act(() => root.unmount());
root = createRoot(container);
renderButton(root, "user-2");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
it("skips confirmation for a non-renewing purchase", () => {
mocks.payment.autoRenew = false;
renderButton(root, "user-1", null);
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("skips confirmation for one-time EzPay membership checkout", () => {
mocks.payment.payChannel = "ezpay";
renderButton(root, "user-1");
act(() => clickButton(container, "Pay and Top Up"));
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(mocks.dispatch).toHaveBeenCalledWith({
type: "PaymentCreateOrderSubmitted",
});
});
it("reopens a hidden QRIS order without creating another order", () => {
mocks.payment.payChannel = "ezpay";
mocks.payment.isPollingOrder = true;
mocks.hasHiddenQrisPayment = true;
act(() =>
root.render(
<SubscriptionCheckoutButton
disabled
subscriptionType="topup"
sourceCharacterSlug="elio"
/>,
),
);
const resumeButton = getButton(container, "Resume QRIS payment");
expect(resumeButton.disabled).toBe(false);
act(() => resumeButton.click());
expect(mocks.handleQrisResume).toHaveBeenCalledOnce();
expect(mocks.resetLaunchState).not.toHaveBeenCalled();
expect(mocks.dispatch).not.toHaveBeenCalled();
});
});
function renderButton(
root: Root,
renewalConsentSubjectId: string,
plan: typeof renewalPlan | null = renewalPlan,
): void {
act(() =>
root.render(
<SubscriptionCheckoutButton
subscriptionType="vip"
sourceCharacterSlug="elio"
renewalPlan={plan}
renewalConsentSubjectId={renewalConsentSubjectId}
/>,
),
);
}
function clickButton(root: ParentNode, label: string): void {
getButton(root, label).click();
}
function getButton(root: ParentNode, label: string): HTMLButtonElement {
const button = Array.from(root.querySelectorAll("button")).find(
(item) => item.textContent?.trim() === label,
);
if (!button) throw new Error(`Expected ${label} button`);
return button;
}