feat(payment): expand Stripe methods and checkout handoff
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { createCheckoutHandoff, openUrlWithExternalBrowser } = vi.hoisted(
|
||||
() => ({
|
||||
createCheckoutHandoff: vi.fn(),
|
||||
openUrlWithExternalBrowser: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib/auth/checkout_handoff", () => ({ createCheckoutHandoff }));
|
||||
vi.mock("@/utils/url-launcher-util", () => ({
|
||||
UrlLauncherUtil: { openUrlWithExternalBrowser },
|
||||
}));
|
||||
vi.mock("@/utils/browser-detect", () => ({
|
||||
BrowserDetector: { isFacebookInAppBrowser: () => true },
|
||||
}));
|
||||
|
||||
import { ExternalBrowserCheckoutButton } from "../external-browser-checkout-button";
|
||||
|
||||
describe("ExternalBrowserCheckoutButton", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
createCheckoutHandoff.mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
externalUrl:
|
||||
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||
expiresAt: "2026-07-28T16:00:00+00:00",
|
||||
},
|
||||
});
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("creates only a handoff before opening the external browser", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ExternalBrowserCheckoutButton
|
||||
checkoutIntent={{
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
commercialOfferId: "offer-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const button = container.querySelector("button");
|
||||
expect(button?.textContent).toContain(
|
||||
"Open in browser for more payment methods",
|
||||
);
|
||||
await act(async () => button?.click());
|
||||
|
||||
expect(createCheckoutHandoff).toHaveBeenCalledWith({
|
||||
planId: "vip_monthly",
|
||||
autoRenew: true,
|
||||
commercialOfferId: "offer-1",
|
||||
});
|
||||
expect(openUrlWithExternalBrowser).toHaveBeenCalledWith(
|
||||
"https://cozsweet.com/external-entry?target=checkout&handoffToken=opaque",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ const hiddenLaunch = {
|
||||
shouldShowQrisDialog: false,
|
||||
shouldShowStripeDialog: false,
|
||||
stripeClientSecret: null,
|
||||
stripeCustomerSessionClientSecret: null,
|
||||
savedPaymentMethodsEnabled: false,
|
||||
};
|
||||
|
||||
describe("PaymentLaunchDialogs", () => {
|
||||
|
||||
@@ -2,18 +2,57 @@ import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { StripePaymentDialogLoading } from "../lazy-stripe-payment-dialog";
|
||||
const stripeConfirmPayment = vi.fn(async () => ({}));
|
||||
const elementsSubmit = vi.fn(async () => ({}));
|
||||
let capturedElementsOptions: Record<string, unknown> | null = null;
|
||||
let capturedExpressProps: Record<string, unknown> | null = null;
|
||||
let capturedPaymentProps: Record<string, unknown> | null = null;
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY = "pk_test_checkout";
|
||||
});
|
||||
|
||||
vi.mock("@stripe/stripe-js", () => ({
|
||||
loadStripe: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
vi.mock("@stripe/react-stripe-js", () => ({
|
||||
Elements: ({
|
||||
children,
|
||||
options,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
options: Record<string, unknown>;
|
||||
}) => {
|
||||
capturedElementsOptions = options;
|
||||
return <>{children}</>;
|
||||
},
|
||||
ExpressCheckoutElement: (props: Record<string, unknown>) => {
|
||||
capturedExpressProps = props;
|
||||
return <div data-testid="express-checkout" />;
|
||||
},
|
||||
PaymentElement: (props: Record<string, unknown>) => {
|
||||
capturedPaymentProps = props;
|
||||
return <div data-testid="payment-element" />;
|
||||
},
|
||||
useElements: () => ({ submit: elementsSubmit }),
|
||||
useStripe: () => ({ confirmPayment: stripeConfirmPayment }),
|
||||
}));
|
||||
|
||||
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||
|
||||
describe("Stripe payment portal states", () => {
|
||||
describe("StripePaymentDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedElementsOptions = null;
|
||||
capturedExpressProps = null;
|
||||
capturedPaymentProps = null;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
||||
.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
container.style.transform = "translateX(50%)";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
@@ -24,40 +63,101 @@ describe("Stripe payment portal states", () => {
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
it("portals the unavailable state and keeps its explicit close action", () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
it("renders Express Checkout first and a collapsed Pay by card section last", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="client-secret"
|
||||
onClose={onClose}
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="cuss_123_secret_saved"
|
||||
savedPaymentMethodsEnabled
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = document.body.querySelector('[role="alertdialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(container.contains(dialog)).toBe(false);
|
||||
expect(dialog?.textContent).toContain("Payment unavailable");
|
||||
expect(dialog?.getAttribute("aria-labelledby")).toBeTruthy();
|
||||
expect(dialog?.getAttribute("aria-describedby")).toBeTruthy();
|
||||
|
||||
const okButton = Array.from(dialog?.querySelectorAll("button") ?? []).find(
|
||||
(button) => button.textContent === "OK",
|
||||
);
|
||||
act(() => okButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(capturedElementsOptions).toMatchObject({
|
||||
clientSecret: "pi_123_secret_payment",
|
||||
customerSessionClientSecret: "cuss_123_secret_saved",
|
||||
});
|
||||
expect(capturedExpressProps?.options).toMatchObject({
|
||||
paymentMethodOrder: [
|
||||
"apple_pay",
|
||||
"google_pay",
|
||||
"link",
|
||||
"paypal",
|
||||
"amazon_pay",
|
||||
"klarna",
|
||||
],
|
||||
paymentMethods: {
|
||||
applePay: "always",
|
||||
googlePay: "always",
|
||||
link: "auto",
|
||||
paypal: "auto",
|
||||
amazonPay: "auto",
|
||||
klarna: "auto",
|
||||
},
|
||||
});
|
||||
expect(capturedPaymentProps?.options).toMatchObject({
|
||||
layout: { type: "accordion", defaultCollapsed: true },
|
||||
paymentMethodOrder: ["card"],
|
||||
wallets: { applePay: "never", googlePay: "never", link: "never" },
|
||||
});
|
||||
const text = document.body.textContent ?? "";
|
||||
expect(text).toContain("Pay by card");
|
||||
const express = document.body.querySelector('[data-testid="express-checkout"]');
|
||||
const card = document.body.querySelector('[data-testid="payment-element"]');
|
||||
expect(express).not.toBeNull();
|
||||
expect(card).not.toBeNull();
|
||||
expect(
|
||||
express!.compareDocumentPosition(card!) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("portals the lazy loading state", () => {
|
||||
act(() => root.render(<StripePaymentDialogLoading />));
|
||||
it("does not pass an invalid or disabled Customer Session secret", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
customerSessionClientSecret="invalid"
|
||||
savedPaymentMethodsEnabled={false}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const dialog = document.body.querySelector('[role="dialog"]');
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(container.contains(dialog)).toBe(false);
|
||||
expect(dialog?.textContent).toContain("Preparing secure payment");
|
||||
expect(dialog?.textContent).toContain("Loading payment methods...");
|
||||
expect(dialog?.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
expect(capturedElementsOptions).not.toHaveProperty(
|
||||
"customerSessionClientSecret",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the same confirmation path for an Express Checkout approval", async () => {
|
||||
const onConfirmed = vi.fn();
|
||||
act(() =>
|
||||
root.render(
|
||||
<StripePaymentDialog
|
||||
clientSecret="pi_123_secret_payment"
|
||||
onClose={vi.fn()}
|
||||
onConfirmed={onConfirmed}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const onConfirm = capturedExpressProps?.onConfirm as
|
||||
| ((event: { paymentFailed: ReturnType<typeof vi.fn> }) => Promise<void>)
|
||||
| undefined;
|
||||
expect(onConfirm).toBeTypeOf("function");
|
||||
await act(async () => {
|
||||
await onConfirm?.({ paymentFailed: vi.fn() });
|
||||
});
|
||||
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledTimes(1);
|
||||
expect(stripeConfirmPayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
elements: expect.any(Object),
|
||||
redirect: "if_required",
|
||||
}),
|
||||
);
|
||||
expect(onConfirmed).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user