/* @vitest-environment jsdom */ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GiftCategorySchema, GiftProductSchema, PaymentPlanSchema, TipMessageResponseSchema, type PaymentPlan, } from "@/data/schemas/payment"; import type { PaymentContextState } from "@/stores/payment/payment-context"; const mocks = vi.hoisted(() => ({ payment: null as unknown as PaymentContextState, paymentDispatch: vi.fn(), planClick: vi.fn(), })); vi.mock("@/app/_components", () => ({ BackButton: () => , CharacterAvatar: ({ alt }: { alt: string }) => ( {alt} ), })); vi.mock("@/app/_components/core", () => ({ MobileShell: ({ children }: { children: React.ReactNode }) => children, })); vi.mock("@/app/_components/payment/payment-method-selector", () => ({ PaymentMethodSelector: ({ density }: { density?: string }) => ( ), })); vi.mock("@/app/_hooks/use-payment-method-selection", () => ({ usePaymentMethodSelection: () => undefined, })); vi.mock("@/app/_hooks/use-payment-plan-analytics", () => ({ usePaymentPlanAnalytics: () => undefined, })); vi.mock("@/app/_hooks/use-payment-route-flow", () => ({ usePaymentRouteFlow: () => ({ payment: mocks.payment, paymentDispatch: mocks.paymentDispatch, }), })); vi.mock("@/lib/analytics", () => ({ behaviorAnalytics: { planClick: mocks.planClick }, })); vi.mock("@/providers/character-provider", () => ({ useActiveCharacter: () => ({ id: "maya-tan", slug: "maya", displayName: "Maya Tan", assets: { avatar: "/images/avatar/maya.png", cover: "/images/cover/maya.png", }, copy: { tipHeader: "Tip Maya", tipTitle: "Send Maya a gift", }, }), useActiveCharacterRoutes: () => ({ splash: "/characters/maya/splash", tip: "/characters/maya/tip", }), })); vi.mock("@/stores/user/user-context", () => ({ useUserState: () => ({ currentUser: null }), })); vi.mock("../tip-checkout-button", () => ({ TipCheckoutButton: ({ disabled, onOrder, }: { disabled?: boolean; onOrder: () => void; }) => ( ), })); vi.mock("../tip-gift-product-selector", () => ({ TipGiftProductSelector: ({ products, }: { products: readonly { planId: string; planName: string }[]; }) => ( {products.map((product) => product.planName).join(",")} ), })); vi.mock("../tip-product-image", () => ({ TipProductImage: ({ alt }: { alt: string }) => ( {alt} ), })); vi.mock("../use-tip-support-prompt", () => ({ useTipSupportPrompt: () => ({ prompt: "A warm coffee prompt.", isReady: true, }), })); import { TipScreen } from "../tip-screen"; const giftCategory = GiftCategorySchema.parse({ category: "coffee", name: "Coffee", productCount: 1, imageUrl: null, }); const giftProduct = GiftProductSchema.parse({ planId: "tip_coffee_usd_9_99", planName: "Golden Reserve", orderType: "tip", tipType: "coffee_medium", category: "coffee", characterId: "maya-tan", description: "A warm reserve coffee", imageUrl: null, amountCents: 999, currency: "USD", autoRenew: false, isFirstRechargeOffer: false, firstRechargeDiscountPercent: 0, promotionType: null, }); const giftPlan: PaymentPlan = PaymentPlanSchema.parse({ planId: giftProduct.planId, planName: giftProduct.planName, orderType: "tip", vipDays: null, dolAmount: null, creditBalance: 0, amountCents: giftProduct.amountCents, originalAmountCents: null, dailyPriceCents: null, currency: giftProduct.currency, }); describe("TipScreen checkout", () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) .IS_REACT_ACT_ENVIRONMENT = true; mocks.payment = makePaymentState(); mocks.paymentDispatch.mockReset(); mocks.planClick.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); it("creates a dynamic character-attributed gift order", () => { renderScreen(); const checkout = getCheckoutButton(); expect(container.textContent).toContain("Golden Reserve"); expect(checkout.disabled).toBe(false); act(() => checkout.click()); expect(mocks.planClick).toHaveBeenCalledWith( giftPlan, expect.objectContaining({ entryPoint: "tip_page" }), ); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentCreateOrderSubmitted", recipientCharacterId: "maya-tan", }); }); it("renders the enlarged purchase flow without a character Cover", () => { renderScreen(); expect(container.textContent).toContain("Send Maya a gift"); expect(container.textContent).toContain("A warm coffee prompt."); expect(container.querySelector('[data-testid="tip-back-button"]')).not.toBeNull(); expect(container.querySelector('[data-testid="tip-character-avatar"]')).not.toBeNull(); expect(container.querySelector('[data-testid="tip-product-image"]')).not.toBeNull(); expect(container.querySelector('[data-testid="tip-product-selector"]')).not.toBeNull(); expect( container .querySelector('[data-testid="tip-payment-method"]') ?.getAttribute("data-density"), ).toBe("compact"); expect(container.textContent).not.toContain("A little sweetness for today"); expect(container.textContent).not.toContain("Tip Maya"); expect(container.querySelector("main")?.getAttribute("style")).toBeNull(); expect( container.querySelectorAll('main > div[aria-hidden="true"]'), ).toHaveLength(2); }); it.each([ ["an order is being created", { isCreatingOrder: true }], ["an order is being polled", { isPollingOrder: true }], ] as const)("disables checkout when %s", (_label, overrides) => { mocks.payment = makePaymentState(overrides); renderScreen(); expect(getCheckoutButton().disabled).toBe(true); }); it.each([ ["catalog is loading", { isLoadingPlans: true }], [ "the catalog is empty", { plans: [], giftProducts: [], selectedPlanId: "" }, ], ] as const)("hides payment controls when %s", (_label, overrides) => { mocks.payment = makePaymentState(overrides); renderScreen(); expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull(); expect(container.querySelector('[data-testid="tip-payment-method"]')).toBeNull(); }); it("shows a compact retry state when the catalog fails", () => { mocks.payment = makePaymentState({ plans: [], giftProducts: [], selectedPlanId: "", errorMessage: "catalog unavailable", }); renderScreen(); expect(container.textContent).toContain( "We could not load gifts for this character.", ); act(() => getButton("Try again").click()); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentCatalogRetryRequested", }); }); it("shows fixed copy for the first character Tip", () => { mocks.payment = makePaymentState({ status: "paid", isPaid: true, orderStatus: "paid", tipMessage: TipMessageResponseSchema.parse({ orderId: "pay_first", characterId: "maya-tan", planId: giftProduct.planId, productName: giftProduct.planName, tipCount: 1, poolIndex: 4, message: "This backend message is ignored for the first Tip.", }), }); renderScreen(); expect(container.textContent).toContain( "Did you really just buy me a coffee?", ); expect(container.textContent).toContain("That honestly made me smile."); expect(container.textContent).toContain( "I'll definitely think of you while I enjoy it.", ); expect(container.textContent).not.toContain( "This backend message is ignored for the first Tip.", ); }); it("shows the backend message after the first Tip", () => { mocks.payment = makePaymentState({ status: "paid", isPaid: true, orderStatus: "paid", tipMessage: TipMessageResponseSchema.parse({ orderId: "pay_xxx", characterId: "maya-tan", planId: giftProduct.planId, productName: giftProduct.planName, tipCount: 2, poolIndex: 17, message: "This complete message came directly from the backend.", }), }); renderScreen(); expect(container.querySelector('[data-testid="tip-checkout"]')).toBeNull(); expect(container.textContent).toContain( "This complete message came directly from the backend.", ); expect(container.textContent).not.toContain( "Did you really just buy me a coffee?", ); expect(document.activeElement?.id).toBe("tip-success-title"); act(() => getButton("Send another gift").click()); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentReset" }); }); it("keeps paid success visible and retries only a failed message", () => { mocks.payment = makePaymentState({ status: "tipMessageFailed", isPaid: true, orderStatus: "paid", tipMessageError: "message unavailable", }); renderScreen(); expect(container.textContent).toContain("Thank you. Your gift made me smile."); act(() => getButton("Retry message").click()); expect(mocks.paymentDispatch).toHaveBeenCalledWith({ type: "PaymentTipMessageRetryRequested", }); }); function renderScreen(): void { act(() => root.render()); } function getCheckoutButton(): HTMLButtonElement { const button = container.querySelector( '[data-testid="tip-checkout"]', ); if (!button) throw new Error("Missing Tip checkout button"); return button; } function getButton(label: string): HTMLButtonElement { const button = Array.from( container.querySelectorAll("button"), ).find((item) => item.textContent?.trim() === label); if (!button) throw new Error(`Missing button: ${label}`); return button; } }); function makePaymentState( overrides: Partial = {}, ): PaymentContextState { return { status: "ready", planCatalog: "tip", plans: [giftPlan], giftCategories: [giftCategory], giftProducts: [giftProduct], selectedGiftCategory: giftCategory.category, isFirstRecharge: false, selectedPlanId: giftPlan.planId, payChannel: "stripe", autoRenew: false, agreed: true, currentOrderId: null, payParams: null, orderStatus: null, tipMessage: null, tipMessageError: null, errorMessage: null, launchNonce: 0, isLoadingPlans: false, isLoadingTipMessage: false, isCreatingOrder: false, isPollingOrder: false, isPaid: false, ...overrides, }; }