From 9535c29c1d7295345c1ac9c78a7523e3149632af Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 29 Jun 2026 19:54:30 +0800 Subject: [PATCH] test(e2e): add mock chat quantity limit coverage --- e2e/fixtures/api-mocks.ts | 25 +++- e2e/fixtures/test-data.ts | 38 ++++++ e2e/specs/mock/chat-quantity-limit.spec.ts | 128 +++++++++++++++++++++ 3 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 e2e/specs/mock/chat-quantity-limit.spec.ts diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index cbb2001c..65a95f1e 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -3,6 +3,8 @@ import type { Page } from "@playwright/test"; import { apiEnvelope, chatSendResponse, + createChatSendResponse, + createWeeklyLimitChatSendResponse, e2eEmailUser, emptyChatHistoryResponse, emailLoginResponse, @@ -12,7 +14,17 @@ import { vipStatusResponse, } from "./test-data"; -export async function mockCoreApis(page: Page): Promise { +interface MockCoreApisOptions { + chatLimitTriggerAt?: number; +} + +export async function mockCoreApis( + page: Page, + options: MockCoreApisOptions = {}, +): Promise { + const { chatLimitTriggerAt } = options; + let sendCount = 0; + await page.route("**/api/auth/guest", async (route) => { await route.fulfill({ json: apiEnvelope(guestLoginResponse) }); }); @@ -38,7 +50,16 @@ export async function mockCoreApis(page: Page): Promise { }); await page.route("**/api/chat/send", async (route) => { - await route.fulfill({ json: apiEnvelope(chatSendResponse) }); + sendCount += 1; + + const response = + chatLimitTriggerAt != null && sendCount >= chatLimitTriggerAt + ? createWeeklyLimitChatSendResponse(sendCount, chatLimitTriggerAt) + : chatLimitTriggerAt != null + ? createChatSendResponse(sendCount) + : chatSendResponse; + + await route.fulfill({ json: apiEnvelope(response) }); }); await page.route("**/api/payment/plans**", async (route) => { diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts index 9645ecb6..cdfbd72e 100644 --- a/e2e/fixtures/test-data.ts +++ b/e2e/fixtures/test-data.ts @@ -89,6 +89,44 @@ export const chatSendResponse = { }, }; +export function createChatSendResponse(sendCount: number) { + return { + ...chatSendResponse, + reply: `Hello from the E2E boyfriend. Reply ${sendCount}.`, + messageId: `msg_e2e_reply_${String(sendCount).padStart(3, "0")}`, + timestamp: chatSendResponse.timestamp + sendCount, + }; +} + +export function createWeeklyLimitChatSendResponse( + usedMessageCount: number, + limit: number, +) { + return { + reply: "", + audioUrl: "", + messageId: "", + isGuest: true, + timestamp: chatSendResponse.timestamp + usedMessageCount, + image: { + type: null, + url: null, + }, + lockDetail: { + locked: true, + showContent: false, + showUpgrade: true, + reason: "weekly_limit", + hint: "Free message limit reached.", + detail: { + type: "weekly_msg_limit", + usedThisWeek: usedMessageCount, + limit, + }, + }, + }; +} + export const paymentPlansResponse = { plans: [ { diff --git a/e2e/specs/mock/chat-quantity-limit.spec.ts b/e2e/specs/mock/chat-quantity-limit.spec.ts new file mode 100644 index 00000000..cac57478 --- /dev/null +++ b/e2e/specs/mock/chat-quantity-limit.spec.ts @@ -0,0 +1,128 @@ +import { expect, test } from "@playwright/test"; + +import { mockCoreApis } from "../../fixtures/api-mocks"; + +const mockedLimitTriggerAt = 5; +const maxManualTestMessages = 51; + +test.beforeEach(async ({ context, page }) => { + await context.clearCookies(); + await mockCoreApis(page, { + chatLimitTriggerAt: mockedLimitTriggerAt, + }); +}); + +test("guest hits the chat quantity limit and can continue to login and subscription", async ({ + page, +}) => { + await page.goto("/splash"); + + await page.getByRole("button", { name: "Skip" }).click(); + await expect(page).toHaveURL(/\/chat$/); + await dismissChatInterruptions(page); + + const messageInput = page.getByRole("textbox", { name: "Message" }); + const quantityLimitBanner = page.getByTestId("chat-quota-exhausted-banner"); + let limitTriggeredAt: number | null = null; + let completedSendCount = 0; + + await expect(messageInput).toBeVisible({ timeout: 10_000 }); + + for (let index = 1; index <= maxManualTestMessages; index += 1) { + const message = `limit test ${String(index).padStart(2, "0")}`; + + await dismissChatInterruptions(page); + if (completedSendCount > 0) { + const inputStillVisible = await messageInput.isVisible().catch(() => false); + if (!inputStillVisible) { + limitTriggeredAt = completedSendCount; + break; + } + } + + if (await isQuantityLimitVisible(quantityLimitBanner)) { + limitTriggeredAt = completedSendCount; + break; + } + + await expect(messageInput).toBeVisible(); + await messageInput.fill(message); + + const sendRequestPromise = page.waitForRequest("**/api/chat/send"); + await messageInput.press("Enter"); + + const sendRequest = await sendRequestPromise; + expect(sendRequest.postDataJSON()).toMatchObject({ + message, + }); + completedSendCount = index; + + if (await isQuantityLimitVisible(quantityLimitBanner)) { + limitTriggeredAt = index; + break; + } + } + + expect(limitTriggeredAt).not.toBeNull(); + expect(limitTriggeredAt).toBeLessThanOrEqual(maxManualTestMessages); + expect(limitTriggeredAt).toBe(mockedLimitTriggerAt); + + await expect(quantityLimitBanner).toBeVisible(); + await expect(messageInput).toHaveCount(0); + + const unlockButton = page.getByRole("button", { + name: "Unlock your membership to continue", + }); + await expect(unlockButton).toBeVisible(); + + await unlockButton.click(); + await expect(page).toHaveURL(/\/auth\?redirect=/); + expect(new URL(page.url()).searchParams.get("redirect")).toBe( + "/subscription?type=vip&returnTo=chat", + ); + + await page.getByRole("button", { name: /Other Sign In Options/i }).click(); + await expect( + page.getByRole("dialog", { name: /Other sign-in options/i }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Email Sign In" }).click(); + await expect(page.getByPlaceholder("Email")).toHaveValue( + "chanwillian0@gmail.com", + ); + await expect(page.getByPlaceholder("Password")).toHaveValue("12345678"); + + await page.getByRole("button", { name: "Login" }).click(); + await expect(page).toHaveURL(/\/subscription\?type=vip&returnTo=chat$/); + await expect( + page.getByRole("button", { name: /Activate/i }), + ).toBeVisible(); +}); + +async function isQuantityLimitVisible( + banner: ReturnType, +) { + try { + await banner.waitFor({ state: "visible", timeout: 500 }); + return true; + } catch { + return false; + } +} + +async function dismissChatInterruptions(page: import("@playwright/test").Page) { + const laterButton = page.getByRole("button", { name: "Later" }); + if (await laterButton.isVisible().catch(() => false)) { + await laterButton.click(); + } + + const cancelButton = page.getByRole("button", { name: "Cancel" }); + if (await cancelButton.isVisible().catch(() => false)) { + await cancelButton.click(); + } + + const okButton = page.getByRole("button", { name: "OK" }); + if (await okButton.isVisible().catch(() => false)) { + await okButton.click(); + } +}