Compare commits

...

5 Commits

18 changed files with 177 additions and 49 deletions
+2
View File
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import { registerAuthMocks } from "./api/auth"; import { registerAuthMocks } from "./api/auth";
import { registerChatMocks } from "./api/chat"; import { registerChatMocks } from "./api/chat";
import { registerCharacterMocks } from "./api/character";
import { registerPaymentMocks } from "./api/payment"; import { registerPaymentMocks } from "./api/payment";
import { registerUserMocks } from "./api/user"; import { registerUserMocks } from "./api/user";
@@ -30,6 +31,7 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}
isChatSendTokenExpired: () => chatState.hasExpiredChatSend, isChatSendTokenExpired: () => chatState.hasExpiredChatSend,
psidLoginFlow: chatOptions.psidLoginFlow, psidLoginFlow: chatOptions.psidLoginFlow,
}); });
await registerCharacterMocks(page);
await registerUserMocks(page); await registerUserMocks(page);
await registerChatMocks(page, chatOptions, chatState); await registerChatMocks(page, chatOptions, chatState);
await registerPaymentMocks(page); await registerPaymentMocks(page);
+10
View File
@@ -0,0 +1,10 @@
import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common";
import { chatCharactersResponse } from "../data/character";
export async function registerCharacterMocks(page: Page) {
await page.route("**/api/characters**", async (route) => {
await route.fulfill({ json: apiEnvelope(chatCharactersResponse) });
});
}
+5 -1
View File
@@ -1,7 +1,7 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import { apiEnvelope } from "../data/common"; import { apiEnvelope } from "../data/common";
import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat"; import { chatSendResponse, createChatSendResponse, createPaidImageHistoryResponse, createWeeklyLimitChatSendResponse, emptyChatHistoryResponse, emptyChatPreviewsResponse, insufficientCreditsUnlockImageResponse, insufficientCreditsUnlockVoiceResponse, paidImageChatSendResponse, paidImageUnlockHistoryResponse, paidVoiceHistoryResponse } from "../data/chat";
export interface ChatMockOptions { export interface ChatMockOptions {
chatLimitTriggerAt?: number; chatLimitTriggerAt?: number;
@@ -31,6 +31,10 @@ export async function registerChatMocks(page: Page, options: ChatMockOptions, st
await route.fulfill({ json: apiEnvelope(response) }); await route.fulfill({ json: apiEnvelope(response) });
}); });
await page.route("**/api/chat/previews", async (route) => {
await route.fulfill({ json: apiEnvelope(emptyChatPreviewsResponse) });
});
await page.route("**/api/chat/send", async (route) => { await page.route("**/api/chat/send", async (route) => {
sendCount += 1; sendCount += 1;
const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined; const requestBody = route.request().postDataJSON() as { message?: unknown } | undefined;
+26
View File
@@ -0,0 +1,26 @@
export const chatCharactersResponse = {
items: [
{
id: "elio",
displayName: "Elio Silvestri",
isActive: true,
capabilities: { chat: true, privateContent: true },
sortOrder: 10,
},
{
id: "maya-tan",
displayName: "Maya Tan",
isActive: true,
capabilities: { chat: true, privateContent: true },
sortOrder: 20,
},
{
id: "nayeli-cervantes",
displayName: "Nayeli Cervantes",
isActive: true,
capabilities: { chat: true, privateContent: true },
sortOrder: 30,
},
],
defaultCharacterId: "elio",
};
+2
View File
@@ -9,6 +9,8 @@ export const emptyChatHistoryResponse = {
privateCanViewFree: false, privateCanViewFree: false,
}; };
export const emptyChatPreviewsResponse = { items: [] };
export const chatSendResponse = { export const chatSendResponse = {
reply: "Hello from the E2E boyfriend.", reply: "Hello from the E2E boyfriend.",
audioUrl: "", audioUrl: "",
+3 -1
View File
@@ -11,7 +11,7 @@ export const splashStartChatButtonName = "Start Chatting";
export const defaultCharacterSlug = "elio"; export const defaultCharacterSlug = "elio";
export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`; export const defaultCharacterSplashPath = `/characters/${defaultCharacterSlug}/splash`;
export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`; export const defaultCharacterChatPath = `/characters/${defaultCharacterSlug}/chat`;
export const defaultCharacterSidebarPath = `/characters/${defaultCharacterSlug}/sidebar`; export const defaultCharacterSidebarPath = `/sidebar?returnTo=%2Fcharacters%2F${defaultCharacterSlug}%2Fchat`;
export const defaultCharacterChatUrl = new RegExp( export const defaultCharacterChatUrl = new RegExp(
`/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`, `/characters/${defaultCharacterSlug}/chat(?:\\?.*)?$`,
); );
@@ -34,6 +34,8 @@ export async function seedEmailSession(page: Page) {
await page.goto(defaultCharacterSplashPath); await page.goto(defaultCharacterSplashPath);
await setEmailSessionStorage(page); await setEmailSessionStorage(page);
await page.reload(); await page.reload();
// Let the root auth provider hydrate the seeded session before navigation.
await page.waitForTimeout(750);
} }
export async function switchToEmailSignIn(page: Page) { export async function switchToEmailSignIn(page: Page) {
@@ -5,8 +5,11 @@ import {
clearBrowserState, clearBrowserState,
defaultCharacterSidebarPath, defaultCharacterSidebarPath,
defaultCharacterSplashPath, defaultCharacterSplashPath,
defaultCharacterChatUrl,
enterChatFromSplash,
getSplashStartChatButton, getSplashStartChatButton,
signInWithEmailAndOpenChat, submitEmailLogin,
switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
@@ -17,11 +20,19 @@ test.beforeEach(async ({ baseURL, context, page }) => {
test("user can log out from the sidebar after email login", async ({ test("user can log out from the sidebar after email login", async ({
page, page,
}) => { }) => {
await signInWithEmailAndOpenChat(page); await enterChatFromSplash(page);
await page
.getByRole("button", { name: "Sign up to unlock more features" })
.click();
await expect(page).toHaveURL(/\/auth(?:\?.*)?$/);
await switchToEmailSignIn(page);
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
await expect(page.getByRole("button", { name: "Menu" })).toBeVisible(); await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();
await page.getByRole("button", { name: "Menu" }).click(); await page.getByRole("button", { name: "Menu" }).click();
await expect(page).toHaveURL(new RegExp(`${defaultCharacterSidebarPath}$`)); await expect(page).toHaveURL(
new RegExp(defaultCharacterSidebarPath.replace("?", "\\?") + "$"),
);
const logoutRequestPromise = page.waitForRequest("**/api/auth/logout"); const logoutRequestPromise = page.waitForRequest("**/api/auth/logout");
await page.getByRole("button", { name: /Log out/i }).click(); await page.getByRole("button", { name: /Log out/i }).click();
@@ -3,7 +3,8 @@ import { expect, test, type Request } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { import {
clearBrowserState, clearBrowserState,
signInWithEmailAndOpenChat, enterChatFromSplash,
setEmailSessionStorage,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
@@ -23,7 +24,8 @@ test("chat send refreshes an expired token, retries the request, and renders the
} }
}); });
await signInWithEmailAndOpenChat(page); await enterChatFromSplash(page);
await setEmailSessionStorage(page);
const message = "token refresh retry test"; const message = "token refresh retry test";
const messageInput = page.getByRole("textbox", { name: "Message" }); const messageInput = page.getByRole("textbox", { name: "Message" });
@@ -7,8 +7,12 @@ describe("PaymentMethodSelector", () => {
it("orders the default channel first and renders page analytics", () => { it("orders the default channel first and renders page analytics", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PaymentMethodSelector <PaymentMethodSelector
config={{
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: true,
}}
value="ezpay" value="ezpay"
defaultChannel="ezpay"
caption="GCash by default" caption="GCash by default"
analyticsKey="tip.payment_method" analyticsKey="tip.payment_method"
onChange={() => undefined} onChange={() => undefined}
@@ -40,6 +44,11 @@ describe("PaymentMethodSelector", () => {
it("disables both payment methods while payment is busy", () => { it("disables both payment methods while payment is busy", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PaymentMethodSelector <PaymentMethodSelector
config={{
canChoosePaymentMethod: true,
initialPayChannel: "stripe",
showPaymentMethodSelector: true,
}}
value="stripe" value="stripe"
disabled disabled
analyticsKey="subscription.payment_method" analyticsKey="subscription.payment_method"
@@ -52,4 +61,21 @@ describe("PaymentMethodSelector", () => {
'data-analytics-key="subscription.payment_method"', 'data-analytics-key="subscription.payment_method"',
); );
}); });
it("does not render when payment method selection is unavailable", () => {
const html = renderToStaticMarkup(
<PaymentMethodSelector
config={{
canChoosePaymentMethod: true,
initialPayChannel: "stripe",
showPaymentMethodSelector: false,
}}
value="stripe"
analyticsKey="tip.payment_method"
onChange={() => undefined}
/>,
);
expect(html).toBe("");
});
}); });
@@ -3,6 +3,7 @@
import Image from "next/image"; import Image from "next/image";
import type { PayChannel } from "@/data/schemas/payment"; import type { PayChannel } from "@/data/schemas/payment";
import type { PaymentMethodConfig } from "@/lib/payment/payment_method";
const PAYMENT_METHODS: readonly { const PAYMENT_METHODS: readonly {
channel: PayChannel; channel: PayChannel;
@@ -21,24 +22,28 @@ const PAYMENT_METHODS: readonly {
]; ];
export interface PaymentMethodSelectorProps { export interface PaymentMethodSelectorProps {
config: PaymentMethodConfig;
value: PayChannel; value: PayChannel;
defaultChannel?: PayChannel;
disabled?: boolean; disabled?: boolean;
caption?: string; caption?: string;
className?: string;
analyticsKey: string; analyticsKey: string;
onChange: (channel: PayChannel) => void; onChange: (channel: PayChannel) => void;
} }
export function PaymentMethodSelector({ export function PaymentMethodSelector({
config,
value, value,
defaultChannel = "stripe",
disabled = false, disabled = false,
caption = "Stripe by default", caption = "Stripe by default",
className,
analyticsKey, analyticsKey,
onChange, onChange,
}: PaymentMethodSelectorProps) { }: PaymentMethodSelectorProps) {
if (!config.showPaymentMethodSelector) return null;
const paymentMethods = const paymentMethods =
defaultChannel === "ezpay" config.initialPayChannel === "ezpay"
? [...PAYMENT_METHODS].sort((a, b) => ? [...PAYMENT_METHODS].sort((a, b) =>
a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0, a.channel === "ezpay" ? -1 : b.channel === "ezpay" ? 1 : 0,
) )
@@ -46,7 +51,7 @@ export function PaymentMethodSelector({
return ( return (
<section <section
className="flex flex-col gap-[clamp(8px,1.852vw,10px)]" className={`flex flex-col gap-[clamp(8px,1.852vw,10px)] ${className ?? ""}`.trim()}
aria-labelledby="payment-method-title" aria-labelledby="payment-method-title"
> >
<div className="flex items-baseline justify-between gap-sm px-xs"> <div className="flex items-baseline justify-between gap-sm px-xs">
@@ -27,7 +27,11 @@ describe("usePaymentMethodSelection", () => {
it("forces Stripe when payment method selection is unavailable", () => { it("forces Stripe when payment method selection is unavailable", () => {
const onChange = vi.fn(); const onChange = vi.fn();
renderHarness(root, { renderHarness(root, {
config: { canChoosePaymentMethod: false, initialPayChannel: "stripe" }, config: {
canChoosePaymentMethod: false,
initialPayChannel: "stripe",
showPaymentMethodSelector: false,
},
currentPayChannel: "ezpay", currentPayChannel: "ezpay",
onChange, onChange,
}); });
@@ -40,6 +44,7 @@ describe("usePaymentMethodSelection", () => {
const config = { const config = {
canChoosePaymentMethod: true, canChoosePaymentMethod: true,
initialPayChannel: "ezpay", initialPayChannel: "ezpay",
showPaymentMethodSelector: true,
} as const; } as const;
renderHarness(root, { renderHarness(root, {
config, config,
@@ -59,13 +64,21 @@ describe("usePaymentMethodSelection", () => {
it("preserves an explicit return channel and pauses while payment is busy", () => { it("preserves an explicit return channel and pauses while payment is busy", () => {
const onChange = vi.fn(); const onChange = vi.fn();
renderHarness(root, { renderHarness(root, {
config: { canChoosePaymentMethod: true, initialPayChannel: "ezpay" }, config: {
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: true,
},
currentPayChannel: "stripe", currentPayChannel: "stripe",
requestedPayChannel: "stripe", requestedPayChannel: "stripe",
onChange, onChange,
}); });
renderHarness(root, { renderHarness(root, {
config: { canChoosePaymentMethod: false, initialPayChannel: "stripe" }, config: {
canChoosePaymentMethod: false,
initialPayChannel: "stripe",
showPaymentMethodSelector: false,
},
currentPayChannel: "ezpay", currentPayChannel: "ezpay",
isPaymentBusy: true, isPaymentBusy: true,
onChange, onChange,
+2 -5
View File
@@ -224,18 +224,15 @@ export function SubscriptionScreen({
/> />
</div> </div>
{paymentMethodConfig.canChoosePaymentMethod ? (
<section className={styles.paymentMethodSlot}>
<PaymentMethodSelector <PaymentMethodSelector
config={paymentMethodConfig}
value={payment.payChannel} value={payment.payChannel}
defaultChannel={paymentMethodConfig.initialPayChannel}
disabled={isPaymentBusy} disabled={isPaymentBusy}
caption="GCash by default in the Philippines" caption="GCash by default in the Philippines"
className={styles.paymentMethodSlot}
analyticsKey="subscription.payment_method" analyticsKey="subscription.payment_method"
onChange={handlePaymentMethodChange} onChange={handlePaymentMethodChange}
/> />
</section>
) : null}
<div className={styles.ctaSlot}> <div className={styles.ctaSlot}>
<SubscriptionCheckoutButton <SubscriptionCheckoutButton
@@ -20,13 +20,13 @@ const items: readonly TipCoffeeTierItem[] = [
}, },
{ {
type: "medium", type: "medium",
displayName: "Golden Reserve", displayName: "Gilded Heart",
priceLabel: "US$ 9.99", priceLabel: "US$ 9.99",
unavailable: false, unavailable: false,
}, },
{ {
type: "large", type: "large",
displayName: "Imperial Grand Cru", displayName: "Crown Blossom",
priceLabel: "US$ 19.99", priceLabel: "US$ 19.99",
unavailable: false, unavailable: false,
}, },
@@ -67,8 +67,8 @@ describe("TipCoffeeTierSelector", () => {
act(() => root.render(<Harness />)); act(() => root.render(<Harness />));
expect(container.textContent).toContain("Velvet Espresso"); expect(container.textContent).toContain("Velvet Espresso");
expect(container.textContent).toContain("Golden Reserve"); expect(container.textContent).toContain("Gilded Heart");
expect(container.textContent).toContain("Imperial Grand Cru"); expect(container.textContent).toContain("Crown Blossom");
expect( expect(
container.querySelector<HTMLInputElement>('input[value="medium"]') container.querySelector<HTMLInputElement>('input[value="medium"]')
?.checked, ?.checked,
+2 -5
View File
@@ -284,18 +284,15 @@ export function TipScreen({
/> />
</section> </section>
{paymentMethodConfig.canChoosePaymentMethod ? (
<section className={styles.paymentMethodSlot}>
<PaymentMethodSelector <PaymentMethodSelector
config={paymentMethodConfig}
value={payment.payChannel} value={payment.payChannel}
defaultChannel={paymentMethodConfig.initialPayChannel}
disabled={isPaymentBusy} disabled={isPaymentBusy}
caption="GCash by default in the Philippines" caption="GCash by default in the Philippines"
className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method" analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange} onChange={handlePaymentMethodChange}
/> />
</section>
) : null}
{showMissingPlan ? ( {showMissingPlan ? (
<p className={styles.statusMessage} role="alert"> <p className={styles.statusMessage} role="alert">
@@ -34,6 +34,7 @@ describe("payment method rules", () => {
).toEqual({ ).toEqual({
canChoosePaymentMethod: false, canChoosePaymentMethod: false,
initialPayChannel: "stripe", initialPayChannel: "stripe",
showPaymentMethodSelector: false,
}); });
}); });
@@ -51,7 +52,7 @@ describe("payment method rules", () => {
).toBe("stripe"); ).toBe("stripe");
}); });
it("allows every user and keeps the requested channel outside production", () => { it("keeps test channels but shows the selector only for Philippines", () => {
vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test"); vi.stubEnv("NEXT_PUBLIC_APP_ENV", "test");
expect(canChoosePayChannel(null)).toBe(true); expect(canChoosePayChannel(null)).toBe(true);
@@ -61,5 +62,27 @@ describe("payment method rules", () => {
requestedPayChannel: "ezpay", requestedPayChannel: "ezpay",
}), }),
).toBe("ezpay"); ).toBe("ezpay");
expect(
getPaymentMethodConfig({
countryCode: "HK",
requestedPayChannel: "ezpay",
}),
).toEqual({
canChoosePaymentMethod: true,
initialPayChannel: "ezpay",
showPaymentMethodSelector: false,
});
expect(
getPaymentMethodConfig({
countryCode: " ph ",
requestedPayChannel: null,
}).showPaymentMethodSelector,
).toBe(true);
expect(
getPaymentMethodConfig({
countryCode: undefined,
requestedPayChannel: null,
}).showPaymentMethodSelector,
).toBe(false);
}); });
}); });
+9 -1
View File
@@ -6,13 +6,14 @@ import { getDefaultPayChannelForCountryCode } from "./default_pay_channel";
export interface PaymentMethodConfig { export interface PaymentMethodConfig {
canChoosePaymentMethod: boolean; canChoosePaymentMethod: boolean;
initialPayChannel: PayChannel; initialPayChannel: PayChannel;
showPaymentMethodSelector: boolean;
} }
export function canChoosePayChannel( export function canChoosePayChannel(
countryCode: string | null | undefined, countryCode: string | null | undefined,
): boolean { ): boolean {
if (!AppEnvUtil.isProduction()) return true; if (!AppEnvUtil.isProduction()) return true;
return countryCode?.trim().toUpperCase() === "PH"; return isPhilippinesCountryCode(countryCode);
} }
export function resolvePayChannel(input: { export function resolvePayChannel(input: {
@@ -33,5 +34,12 @@ export function getPaymentMethodConfig(input: {
return { return {
canChoosePaymentMethod: canChoosePayChannel(input.countryCode), canChoosePaymentMethod: canChoosePayChannel(input.countryCode),
initialPayChannel: resolvePayChannel(input), initialPayChannel: resolvePayChannel(input),
showPaymentMethodSelector: isPhilippinesCountryCode(input.countryCode),
}; };
} }
function isPhilippinesCountryCode(
countryCode: string | null | undefined,
): boolean {
return countryCode?.trim().toUpperCase() === "PH";
}
+2 -2
View File
@@ -29,13 +29,13 @@ describe("tip coffee configuration", () => {
}); });
expect(getTipCoffeeOption("medium")).toMatchObject({ expect(getTipCoffeeOption("medium")).toMatchObject({
amountCents: 999, amountCents: 999,
displayName: "Golden Reserve", displayName: "Gilded Heart",
image: { src: "/images/tip/medium.png", width: 1024, height: 1024 }, image: { src: "/images/tip/medium.png", width: 1024, height: 1024 },
planId: "tip_coffee_usd_9_99", planId: "tip_coffee_usd_9_99",
}); });
expect(getTipCoffeeOption("large")).toMatchObject({ expect(getTipCoffeeOption("large")).toMatchObject({
amountCents: 1999, amountCents: 1999,
displayName: "Imperial Grand Cru", displayName: "Crown Blossom",
image: { src: "/images/tip/large.png", width: 1024, height: 1024 }, image: { src: "/images/tip/large.png", width: 1024, height: 1024 },
planId: "tip_coffee_usd_19_99", planId: "tip_coffee_usd_19_99",
}); });
+2 -2
View File
@@ -32,7 +32,7 @@ const TIP_COFFEE_OPTION_BY_TYPE: Record<TipCoffeeType, TipCoffeeOption> = {
medium: { medium: {
type: "medium", type: "medium",
amountCents: 999, amountCents: 999,
displayName: "Golden Reserve", displayName: "Gilded Heart",
image: { image: {
src: "/images/tip/medium.png", src: "/images/tip/medium.png",
width: 1024, width: 1024,
@@ -43,7 +43,7 @@ const TIP_COFFEE_OPTION_BY_TYPE: Record<TipCoffeeType, TipCoffeeOption> = {
large: { large: {
type: "large", type: "large",
amountCents: 1999, amountCents: 1999,
displayName: "Imperial Grand Cru", displayName: "Crown Blossom",
image: { image: {
src: "/images/tip/large.png", src: "/images/tip/large.png",
width: 1024, width: 1024,