From d8a28bb4381701807736e30cad33ec0f8d35b400 Mon Sep 17 00:00:00 2001 From: chenhang Date: Thu, 2 Jul 2026 12:53:42 +0800 Subject: [PATCH] test(e2e): cover chat send token refresh retry --- e2e/fixtures/api-mocks.ts | 26 +++++++- e2e/fixtures/test-data.ts | 10 +++ e2e/fixtures/test-helpers.ts | 48 ++++++++++++-- e2e/specs/mock/chat-quantity-limit.spec.ts | 8 +-- .../chat-send-token-refresh-retry.spec.ts | 64 +++++++++++++++++++ .../image-unlock-insufficient-credits.spec.ts | 3 +- e2e/specs/mock/paid-image.spec.ts | 3 +- .../voice-unlock-insufficient-credits.spec.ts | 3 +- 8 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 e2e/specs/mock/chat-send-token-refresh-retry.spec.ts diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index dcbc4772..bc4d942e 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -18,12 +18,15 @@ import { paidVoiceHistoryResponse, paidPaymentOrderStatusResponse, paymentPlansResponse, + refreshedEmailLoginResponse, + refreshedGuestLoginResponse, userEntitlementsResponse, vipStatusResponse, } from "./test-data"; interface MockCoreApisOptions { chatLimitTriggerAt?: number; + chatSendTokenRefreshFlow?: boolean; paidImageFlow?: boolean; paidImageInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean; @@ -35,16 +38,22 @@ export async function mockCoreApis( ): Promise { const { chatLimitTriggerAt, + chatSendTokenRefreshFlow = false, paidImageFlow = false, paidImageInsufficientCreditsFlow = false, paidVoiceInsufficientCreditsFlow = false, } = options; let sendCount = 0; + let hasExpiredChatSend = false; let paidImageRequested = false; let paidImageUnlocked = false; await page.route("**/api/auth/guest", async (route) => { - await route.fulfill({ json: apiEnvelope(guestLoginResponse) }); + const response = + chatSendTokenRefreshFlow && hasExpiredChatSend + ? refreshedGuestLoginResponse + : guestLoginResponse; + await route.fulfill({ json: apiEnvelope(response) }); }); await page.route("**/api/auth/login", async (route) => { @@ -55,6 +64,10 @@ export async function mockCoreApis( await route.fulfill({ json: apiEnvelope(emailLoginResponse) }); }); + await page.route("**/api/auth/refresh", async (route) => { + await route.fulfill({ json: apiEnvelope(refreshedEmailLoginResponse) }); + }); + await page.route("**/api/auth/logout", async (route) => { await route.fulfill({ json: apiEnvelope(null) }); }); @@ -88,6 +101,17 @@ export async function mockCoreApis( const message = typeof requestBody?.message === "string" ? requestBody.message : ""; + if (chatSendTokenRefreshFlow && !hasExpiredChatSend) { + hasExpiredChatSend = true; + await route.fulfill({ + status: 401, + json: { + message: "expired", + }, + }); + return; + } + const response = paidImageFlow && message.includes("给我发图片") ? paidImageChatSendResponse diff --git a/e2e/fixtures/test-data.ts b/e2e/fixtures/test-data.ts index 33fbbe8f..a9add0fa 100644 --- a/e2e/fixtures/test-data.ts +++ b/e2e/fixtures/test-data.ts @@ -43,12 +43,22 @@ export const guestLoginResponse = { user: e2eUser, }; +export const refreshedGuestLoginResponse = { + ...guestLoginResponse, + token: "e2e-refreshed-guest-token", +}; + export const emailLoginResponse = { token: "e2e-email-token", refreshToken: "e2e-email-refresh-token", user: e2eEmailUser, }; +export const refreshedEmailLoginResponse = { + token: "e2e-refreshed-email-token", + refreshToken: "e2e-refreshed-email-refresh-token", +}; + export const userEntitlementsResponse = { userId: e2eEmailUser.id, isGuest: false, diff --git a/e2e/fixtures/test-helpers.ts b/e2e/fixtures/test-helpers.ts index 52525638..d588c76d 100644 --- a/e2e/fixtures/test-helpers.ts +++ b/e2e/fixtures/test-helpers.ts @@ -72,10 +72,32 @@ export async function dismissChatInterruptions(page: Page) { } export async function switchToEmailSignIn(page: Page) { - await page.getByRole("button", { name: /Other Sign In Options/i }).click(); - await expect( - page.getByRole("dialog", { name: /Other sign-in options/i }), - ).toBeVisible(); + const otherOptionsButton = page.getByRole("button", { + name: /Other Sign In Options/i, + }); + const otherOptionsDialog = page.getByRole("dialog", { + name: /Other sign-in options/i, + }); + + await expect(otherOptionsButton).toBeVisible({ timeout: 10_000 }); + await expect(otherOptionsButton).toBeEnabled(); + + for (let attempt = 0; attempt < 3; attempt += 1) { + await otherOptionsButton.click(); + try { + await expect(otherOptionsDialog).toBeVisible({ timeout: 2_000 }); + break; + } catch (error) { + if (attempt === 2) throw error; + await otherOptionsButton.evaluate((button) => + (button as HTMLButtonElement).click(), + ); + if (await otherOptionsDialog.isVisible().catch(() => false)) { + break; + } + await page.waitForTimeout(500); + } + } await page.getByRole("button", { name: "Email Sign In" }).click(); await expect(page.getByPlaceholder("Email")).toHaveValue( @@ -176,3 +198,21 @@ export async function expectInsufficientCreditsDialog(page: Page) { return insufficientCreditsDialog; } + +export async function expectVipChatSubscriptionUrl(page: Page) { + await expect(page).toHaveURL(/\/subscription\?/); + const url = new URL(page.url()); + expect(url.pathname).toBe("/subscription"); + expect(url.searchParams.get("type")).toBe("vip"); + expect(url.searchParams.get("returnTo")).toBe("chat"); +} + +export function expectAuthRedirectToVipChatSubscription(page: Page) { + const redirect = new URL(page.url()).searchParams.get("redirect"); + expect(redirect).not.toBeNull(); + + const redirectUrl = new URL(redirect ?? "", "http://e2e.local"); + expect(redirectUrl.pathname).toBe("/subscription"); + expect(redirectUrl.searchParams.get("type")).toBe("vip"); + expect(redirectUrl.searchParams.get("returnTo")).toBe("chat"); +} diff --git a/e2e/specs/mock/chat-quantity-limit.spec.ts b/e2e/specs/mock/chat-quantity-limit.spec.ts index 67e4cdf6..bae1c610 100644 --- a/e2e/specs/mock/chat-quantity-limit.spec.ts +++ b/e2e/specs/mock/chat-quantity-limit.spec.ts @@ -6,6 +6,8 @@ import { completeVipPayment, dismissChatInterruptions, enterChatFromSplash, + expectAuthRedirectToVipChatSubscription, + expectVipChatSubscriptionUrl, submitEmailLogin, switchToEmailSignIn, } from "../../fixtures/test-helpers"; @@ -88,13 +90,11 @@ test("guest hits the chat quantity limit and can continue to login and subscript await topUpButton.click(); await expect(page).toHaveURL(/\/auth\?redirect=/); - expect(new URL(page.url()).searchParams.get("redirect")).toBe( - "/subscription?type=vip&returnTo=chat", - ); + expectAuthRedirectToVipChatSubscription(page); await switchToEmailSignIn(page); await submitEmailLogin(page); - await expect(page).toHaveURL(/\/subscription\?type=vip&returnTo=chat$/); + await expectVipChatSubscriptionUrl(page); await completeVipPayment(page); diff --git a/e2e/specs/mock/chat-send-token-refresh-retry.spec.ts b/e2e/specs/mock/chat-send-token-refresh-retry.spec.ts new file mode 100644 index 00000000..71053355 --- /dev/null +++ b/e2e/specs/mock/chat-send-token-refresh-retry.spec.ts @@ -0,0 +1,64 @@ +import { expect, test, type Request } from "@playwright/test"; + +import { mockCoreApis } from "../../fixtures/api-mocks"; +import { + clearBrowserState, + signInWithEmailAndOpenChat, +} from "../../fixtures/test-helpers"; + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page, { + chatSendTokenRefreshFlow: true, + }); +}); + +test("chat send refreshes an expired token, retries the request, and renders the reply", async ({ + page, +}) => { + const chatSendRequests: Request[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/chat/send") { + chatSendRequests.push(request); + } + }); + + await signInWithEmailAndOpenChat(page); + + const message = "token refresh retry test"; + const messageInput = page.getByRole("textbox", { name: "Message" }); + await expect(messageInput).toBeVisible({ timeout: 10_000 }); + await expect(messageInput).toBeEnabled(); + await messageInput.fill(message); + + const refreshRequestPromise = page.waitForRequest("**/api/auth/refresh"); + const retriedSendResponsePromise = page.waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/chat/send" && + response.status() === 200, + ); + + await messageInput.press("Enter"); + + const refreshRequest = await refreshRequestPromise; + expect(refreshRequest.method()).toBe("POST"); + expect(refreshRequest.headers().authorization).toBeUndefined(); + expect(refreshRequest.postDataJSON()).toMatchObject({ + refreshToken: "e2e-email-refresh-token", + }); + + await retriedSendResponsePromise; + await expect.poll(() => chatSendRequests.length).toBe(2); + + expect(chatSendRequests[0]?.postDataJSON()).toMatchObject({ message }); + expect(chatSendRequests[0]?.headers().authorization).toBe( + "Bearer e2e-email-token", + ); + expect(chatSendRequests[1]?.postDataJSON()).toMatchObject({ message }); + expect(chatSendRequests[1]?.headers().authorization).toBe( + "Bearer e2e-refreshed-email-token", + ); + + await expect(page.getByText(message)).toBeVisible(); + await expect(page.getByText("Hello from the E2E boyfriend.")).toBeVisible(); +}); diff --git a/e2e/specs/mock/image-unlock-insufficient-credits.spec.ts b/e2e/specs/mock/image-unlock-insufficient-credits.spec.ts index 8d3a2db6..f45a4eb0 100644 --- a/e2e/specs/mock/image-unlock-insufficient-credits.spec.ts +++ b/e2e/specs/mock/image-unlock-insufficient-credits.spec.ts @@ -6,6 +6,7 @@ import { clearBrowserState, dismissChatInterruptions, expectInsufficientCreditsDialog, + expectVipChatSubscriptionUrl, signInWithEmailAndOpenChat, } from "../../fixtures/test-helpers"; @@ -53,5 +54,5 @@ test("user sees insufficient credits when unlocking a paid image from fullscreen .getByRole("button", { name: "Top up now" }) .click(); - await expect(page).toHaveURL(/\/subscription\?type=vip&returnTo=chat$/); + await expectVipChatSubscriptionUrl(page); }); diff --git a/e2e/specs/mock/paid-image.spec.ts b/e2e/specs/mock/paid-image.spec.ts index ffe03d66..69a60a2e 100644 --- a/e2e/specs/mock/paid-image.spec.ts +++ b/e2e/specs/mock/paid-image.spec.ts @@ -8,6 +8,7 @@ import { dismissChatInterruptions, enterChatFromSplash, expectInsufficientCreditsDialog, + expectVipChatSubscriptionUrl, submitEmailLogin, switchToEmailSignIn, } from "../../fixtures/test-helpers"; @@ -77,7 +78,7 @@ test("guest can unlock a paid image after email login and subscription payment", .getByRole("button", { name: "Top up now" }) .click(); - await expect(page).toHaveURL(/\/subscription\?type=vip&returnTo=chat$/); + await expectVipChatSubscriptionUrl(page); await completeVipPayment(page); await expect(page).toHaveURL(new RegExp(`/chat/image/${paidImageMessageId}$`)); diff --git a/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts b/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts index bc693b4e..92c0f7ba 100644 --- a/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts +++ b/e2e/specs/mock/voice-unlock-insufficient-credits.spec.ts @@ -5,6 +5,7 @@ import { paidVoiceMessageId } from "../../fixtures/test-data"; import { clearBrowserState, expectInsufficientCreditsDialog, + expectVipChatSubscriptionUrl, signInWithEmailAndOpenChat, } from "../../fixtures/test-helpers"; @@ -44,5 +45,5 @@ test("user sees insufficient credits when unlocking a paid voice message and can .getByRole("button", { name: "Top up now" }) .click(); - await expect(page).toHaveURL(/\/subscription\?type=vip&returnTo=chat$/); + await expectVipChatSubscriptionUrl(page); });