245 lines
7.1 KiB
TypeScript
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;
|
|
}
|