285 lines
7.8 KiB
TypeScript
285 lines
7.8 KiB
TypeScript
/* @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: () => null,
|
|
CharacterAvatar: () => null,
|
|
}));
|
|
vi.mock("@/app/_components/core", () => ({
|
|
MobileShell: ({ children }: { children: React.ReactNode }) => children,
|
|
}));
|
|
vi.mock("@/app/_components/payment/payment-method-selector", () => ({
|
|
PaymentMethodSelector: () => null,
|
|
}));
|
|
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;
|
|
}) => (
|
|
<button
|
|
type="button"
|
|
data-testid="tip-checkout"
|
|
disabled={disabled}
|
|
onClick={onOrder}
|
|
>
|
|
Checkout
|
|
</button>
|
|
),
|
|
}));
|
|
vi.mock("../tip-gift-product-selector", () => ({
|
|
TipGiftProductSelector: () => null,
|
|
}));
|
|
vi.mock("../tip-product-image", () => ({
|
|
TipProductImage: ({ alt }: { alt: string }) => <span>{alt}</span>,
|
|
}));
|
|
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.each([
|
|
["catalog is loading", { isLoadingPlans: true }],
|
|
[
|
|
"the selected product is missing",
|
|
{ plans: [], giftProducts: [], selectedPlanId: "" },
|
|
],
|
|
["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("shows the complete backend Tip message after payment", () => {
|
|
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(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(<TipScreen />));
|
|
}
|
|
|
|
function getCheckoutButton(): HTMLButtonElement {
|
|
const button = container.querySelector<HTMLButtonElement>(
|
|
'[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<HTMLButtonElement>("button"),
|
|
).find((item) => item.textContent?.trim() === label);
|
|
if (!button) throw new Error(`Missing button: ${label}`);
|
|
return button;
|
|
}
|
|
});
|
|
|
|
function makePaymentState(
|
|
overrides: Partial<PaymentContextState> = {},
|
|
): 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,
|
|
};
|
|
}
|