feat(payment): confirm renewal only at checkout
Docker Image / Build and Push Docker Image (push) Successful in 2m9s
Docker Image / Build and Push Docker Image (push) Successful in 2m9s
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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(),
|
||||
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: () => ({
|
||||
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.resetLaunchState.mockClear();
|
||||
mocks.payment.selectedPlanId = "vip_monthly";
|
||||
mocks.payment.autoRenew = true;
|
||||
mocks.payment.payChannel = "stripe";
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
const button = Array.from(root.querySelectorAll("button")).find(
|
||||
(item) => item.textContent?.trim() === label,
|
||||
);
|
||||
if (!button) throw new Error(`Expected ${label} button`);
|
||||
button.click();
|
||||
}
|
||||
Reference in New Issue
Block a user