From e813333607919483bb8d8109cfacd639b0a032d8 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 22 Jul 2026 16:53:09 +0800 Subject: [PATCH] fix(e2e): stabilize authenticated unlock flows --- e2e/README.md | 2 +- e2e/fixtures/data/chat.ts | 1 + e2e/fixtures/helpers/chat.ts | 2 - .../chat/paid-image-promotion-topup.spec.ts | 6 +- e2e/specs/mock/paid-image.spec.ts | 21 +++--- .../image-unlock-insufficient-credits.spec.ts | 7 +- playwright.config.ts | 5 +- src/app/chat/chat-screen.tsx | 8 ++- .../chat/__tests__/chat-helpers.test.ts | 3 + src/stores/chat/chat-context.tsx | 3 + src/stores/chat/chat-state.ts | 3 + src/stores/chat/helper/history.ts | 2 + src/stores/chat/machine/history-flow.ts | 1 + .../sync/__tests__/chat-auth-sync.test.tsx | 69 +++++++++++++++++++ src/stores/sync/chat-auth-sync.tsx | 4 +- src/stores/sync/chat-payment-success-sync.tsx | 7 +- 16 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 src/stores/sync/__tests__/chat-auth-sync.test.tsx diff --git a/e2e/README.md b/e2e/README.md index 13c88b11..72f17875 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -40,6 +40,6 @@ pnpm test:e2e:real ```bash PLAYWRIGHT_BASE_URL=https://cozsweet.com \ -E2E_API_BASE_URL=https://proapi.banlv-ai.com \ +E2E_API_BASE_URL=https://api.banlv-ai.com \ pnpm test:e2e:prod ``` diff --git a/e2e/fixtures/data/chat.ts b/e2e/fixtures/data/chat.ts index 43ccbb8d..8303b19b 100644 --- a/e2e/fixtures/data/chat.ts +++ b/e2e/fixtures/data/chat.ts @@ -66,6 +66,7 @@ export function createWeeklyLimitChatSendResponse( } export const paidImageMessageId = "msg_photo_paywall_001"; +export const paidImageDisplayMessageId = `server:${paidImageMessageId}:assistant`; export const paidImageUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; export const paidImageChatSendResponse = { diff --git a/e2e/fixtures/helpers/chat.ts b/e2e/fixtures/helpers/chat.ts index 9bada90f..31c2dc84 100644 --- a/e2e/fixtures/helpers/chat.ts +++ b/e2e/fixtures/helpers/chat.ts @@ -42,9 +42,7 @@ export async function dismissChatInterruptions(page: Page) { export async function signInWithEmailAndOpenChat(page: Page) { await seedEmailSession(page); - const historyResponsePromise = page.waitForResponse("**/api/chat/history**"); await page.goto(defaultCharacterChatPath); - await historyResponsePromise; await dismissChatInterruptions(page); await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 }); } diff --git a/e2e/specs/mock/chat/paid-image-promotion-topup.spec.ts b/e2e/specs/mock/chat/paid-image-promotion-topup.spec.ts index 9f3ff07b..4a00bbd2 100644 --- a/e2e/specs/mock/chat/paid-image-promotion-topup.spec.ts +++ b/e2e/specs/mock/chat/paid-image-promotion-topup.spec.ts @@ -7,7 +7,6 @@ import { defaultCharacterChatUrl, expectInsufficientCreditsDialog, expectVipChatSubscriptionUrl, - setEmailSessionStorage, submitEmailLogin, switchToEmailSignIn, } from "@e2e/fixtures/test-helpers"; @@ -36,13 +35,10 @@ test("guest unlocks a promoted image through email login and top-up", async ({ await expect(page).toHaveURL(/\/auth\?redirect=/); await switchToEmailSignIn(page); - await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl }); - - await setEmailSessionStorage(page); const unlockRequestPromise = page.waitForRequest( "**/api/chat/unlock-private", ); - await page.reload(); + await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl }); const unlockRequest = await unlockRequestPromise; expect(unlockRequest.method()).toBe("POST"); diff --git a/e2e/specs/mock/paid-image.spec.ts b/e2e/specs/mock/paid-image.spec.ts index cd5dac9c..b9f2202c 100644 --- a/e2e/specs/mock/paid-image.spec.ts +++ b/e2e/specs/mock/paid-image.spec.ts @@ -1,7 +1,10 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; -import { paidImageMessageId } from "@e2e/fixtures/test-data"; +import { + paidImageDisplayMessageId, + paidImageMessageId, +} from "@e2e/fixtures/test-data"; import { clearBrowserState, completeVipPayment, @@ -10,13 +13,12 @@ import { enterChatFromSplash, expectInsufficientCreditsDialog, expectVipChatSubscriptionUrl, - setEmailSessionStorage, submitEmailLogin, switchToEmailSignIn, } from "@e2e/fixtures/test-helpers"; const paidImageOverlayUrl = new RegExp( - `${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`, + `${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`, ); test.beforeEach(async ({ baseURL, context, page }) => { @@ -63,19 +65,16 @@ test("guest can unlock a paid image after email login and subscription payment", await expect(page).toHaveURL(/\/auth\?redirect=/); expect(new URL(page.url()).searchParams.get("redirect")).toBe( - `${defaultCharacterChatPath}?image=${paidImageMessageId}`, + `${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`, ); await switchToEmailSignIn(page); - await submitEmailLogin(page, { - expectedUrl: paidImageOverlayUrl, - }); - - await setEmailSessionStorage(page); const unlockRequestPromise = page.waitForRequest( "**/api/chat/unlock-private", ); - await page.goto(`${defaultCharacterChatPath}?image=${paidImageMessageId}`); + await submitEmailLogin(page, { + expectedUrl: paidImageOverlayUrl, + }); const unlockRequest = await unlockRequestPromise; expect(unlockRequest.postDataJSON()).toMatchObject({ @@ -92,7 +91,7 @@ test("guest can unlock a paid image after email login and subscription payment", await expect(page).toHaveURL( new RegExp( - `${defaultCharacterChatPath}(?:\\?image=msg_photo_paywall_001)?$`, + `${defaultCharacterChatPath}(?:\\?image=${encodeURIComponent(paidImageDisplayMessageId)})?$`, ), ); expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath); diff --git a/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts b/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts index 769e40a8..b6f05802 100644 --- a/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts +++ b/e2e/specs/mock/unlock-message/image-unlock-insufficient-credits.spec.ts @@ -1,7 +1,10 @@ import { expect, test } from "@playwright/test"; import { mockCoreApis } from "@e2e/fixtures/api-mocks"; -import { paidImageMessageId } from "@e2e/fixtures/test-data"; +import { + paidImageDisplayMessageId, + paidImageMessageId, +} from "@e2e/fixtures/test-data"; import { clearBrowserState, defaultCharacterChatPath, @@ -12,7 +15,7 @@ import { } from "@e2e/fixtures/test-helpers"; const paidImageOverlayUrl = new RegExp( - `${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`, + `${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`, ); test.beforeEach(async ({ baseURL, context, page }) => { diff --git a/playwright.config.ts b/playwright.config.ts index 9f01d9d4..35df12b9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,7 +12,10 @@ export default defineConfig({ fullyParallel: true, forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + // Next.js dev + Turbopack can return incomplete module payloads when the + // local mock suite compiles many routes concurrently. Keep this tier + // deterministic locally as well as in CI. + workers: 1, reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"], ["html", { open: "never" }]], diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 67377274..50b3d3b9 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -132,7 +132,11 @@ export function ChatScreen() { }); useEffect(() => { - if (!imageMessageId || !state.historyLoaded || selectedImageMessage) { + if ( + !imageMessageId || + !state.networkHistoryLoaded || + selectedImageMessage + ) { return; } router.replace( @@ -145,7 +149,7 @@ export function ChatScreen() { router, searchParams, selectedImageMessage, - state.historyLoaded, + state.networkHistoryLoaded, ]); useEffect(() => { diff --git a/src/stores/chat/__tests__/chat-helpers.test.ts b/src/stores/chat/__tests__/chat-helpers.test.ts index f0f0b4bf..27ef12d6 100644 --- a/src/stores/chat/__tests__/chat-helpers.test.ts +++ b/src/stores/chat/__tests__/chat-helpers.test.ts @@ -55,6 +55,7 @@ function makeChatState(overrides: Partial = {}): ChatState { requiredCredits: 0, shortfallCredits: 0, historyLoaded: true, + networkHistoryLoaded: true, historyTotal: 0, historyLimit: 50, nextHistoryOffset: 0, @@ -267,6 +268,8 @@ describe("chat history pagination helpers", () => { limit: 0, }); + expect(nextState.networkHistoryLoaded).toBe(true); + expect(nextState.historyLimit).toBe(50); expect(nextState.nextHistoryOffset).toBe(50); expect(nextState.historyTotal).toBe(120); diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 105909fc..66403da2 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -22,6 +22,8 @@ interface ChatState { upgradeReason: MachineContext["upgradeReason"]; /** True as soon as a local snapshot or the first network result is renderable. */ historyLoaded: boolean; + /** True after the network history request has completed or failed. */ + networkHistoryLoaded: boolean; hasMoreHistory: boolean; isLoadingMoreHistory: boolean; unlockHistoryPromptVisible: boolean; @@ -93,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState { upgradePromptVisible: state.context.upgradePromptVisible, upgradeReason: state.context.upgradeReason, historyLoaded: state.context.historyLoaded, + networkHistoryLoaded: state.context.networkHistoryLoaded, hasMoreHistory: state.context.historyLoaded && state.context.nextHistoryOffset < state.context.historyTotal, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index 359f8d51..ff5bd436 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -46,6 +46,8 @@ export interface ChatState { shortfallCredits: number; /** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */ historyLoaded: boolean; + /** 网络历史已返回或已明确失败;用于避免过早判定深链消息不存在。 */ + networkHistoryLoaded: boolean; historyTotal: number; historyLimit: number; nextHistoryOffset: number; @@ -80,6 +82,7 @@ export function createInitialChatState( requiredCredits: 0, shortfallCredits: 0, historyLoaded: false, + networkHistoryLoaded: false, historyTotal: 0, historyLimit: 50, nextHistoryOffset: 0, diff --git a/src/stores/chat/helper/history.ts b/src/stores/chat/helper/history.ts index c1253d15..13f0556e 100644 --- a/src/stores/chat/helper/history.ts +++ b/src/stores/chat/helper/history.ts @@ -29,6 +29,7 @@ export function applyNetworkHistoryLoadedOutput( ChatState, | "messages" | "historyLoaded" + | "networkHistoryLoaded" | "historyTotal" | "historyLimit" | "nextHistoryOffset" @@ -47,6 +48,7 @@ export function applyNetworkHistoryLoadedOutput( return { messages: [...output.messages, ...optimisticTail], historyLoaded: true, + networkHistoryLoaded: true, historyTotal: Math.max(0, output.total), historyLimit, nextHistoryOffset: historyLimit, diff --git a/src/stores/chat/machine/history-flow.ts b/src/stores/chat/machine/history-flow.ts index 4e5db9e5..66537f1a 100644 --- a/src/stores/chat/machine/history-flow.ts +++ b/src/stores/chat/machine/history-flow.ts @@ -55,6 +55,7 @@ const markHistoryLoadFailedAction = baseChatMachineSetup.assign( const characterErrorCode = getCharacterErrorCode(event.error); return { historyLoaded: true, + networkHistoryLoaded: true, characterErrorCode, canSendMessage: characterErrorCode === "CHARACTER_DISABLED" || diff --git a/src/stores/sync/__tests__/chat-auth-sync.test.tsx b/src/stores/sync/__tests__/chat-auth-sync.test.tsx new file mode 100644 index 00000000..d1e508ea --- /dev/null +++ b/src/stores/sync/__tests__/chat-auth-sync.test.tsx @@ -0,0 +1,69 @@ +import { StrictMode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + chatDispatch: vi.fn(), + getLoginToken: vi.fn(async () => ({ + success: true as const, + data: "email-token", + })), +})); + +vi.mock("@/data/storage/auth", () => ({ + AuthStorage: { + getInstance: () => ({ getLoginToken: mocks.getLoginToken }), + }, +})); + +vi.mock("@/stores/auth/auth-context", () => ({ + selectAuthIsLoading: () => false, + useAuthSelector: (selector: (state: unknown) => unknown) => + selector({ + context: { + hasInitialized: true, + loginStatus: "email", + }, + }), +})); + +vi.mock("@/stores/chat/chat-context", () => ({ + useChatDispatch: () => mocks.chatDispatch, +})); + +import { ChatAuthSync } from "../chat-auth-sync"; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; + mocks.chatDispatch.mockReset(); + mocks.getLoginToken.mockClear(); +}); + +describe("ChatAuthSync", () => { + it("dispatches the authenticated session after StrictMode restarts the effect", async () => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + + await act(async () => { + root?.render( + + + , + ); + }); + + expect(mocks.chatDispatch).toHaveBeenCalledTimes(1); + expect(mocks.chatDispatch).toHaveBeenCalledWith({ + type: "ChatUserLogin", + token: "email-token", + }); + }); +}); diff --git a/src/stores/sync/chat-auth-sync.tsx b/src/stores/sync/chat-auth-sync.tsx index 93e57cb6..17e59a64 100644 --- a/src/stores/sync/chat-auth-sync.tsx +++ b/src/stores/sync/chat-auth-sync.tsx @@ -34,14 +34,15 @@ export function ChatAuthSync() { const sessionKey = authState.loginStatus; if (prevSessionKeyRef.current === sessionKey) return; - prevSessionKeyRef.current = sessionKey; if (authState.loginStatus === "notLoggedIn") { + prevSessionKeyRef.current = sessionKey; chatDispatch({ type: "ChatLogout" }); return; } if (authState.loginStatus === "guest") { + prevSessionKeyRef.current = sessionKey; chatDispatch({ type: "ChatGuestLogin" }); return; } @@ -50,6 +51,7 @@ export function ChatAuthSync() { void (async () => { const tokenR = await AuthStorage.getInstance().getLoginToken(); if (!cancelled && tokenR.success && tokenR.data) { + prevSessionKeyRef.current = sessionKey; chatDispatch({ type: "ChatUserLogin", token: tokenR.data, diff --git a/src/stores/sync/chat-payment-success-sync.tsx b/src/stores/sync/chat-payment-success-sync.tsx index 4adeace3..a80b8374 100644 --- a/src/stores/sync/chat-payment-success-sync.tsx +++ b/src/stores/sync/chat-payment-success-sync.tsx @@ -29,12 +29,13 @@ export function ChatPaymentSuccessSync() { const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; if (lastPaidKeyRef.current === paidKey) return; - lastPaidKeyRef.current = paidKey; let cancelled = false; void (async () => { - if (await hasPendingChatUnlock(characterId)) return; - if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); + const hasPendingUnlock = await hasPendingChatUnlock(characterId); + if (cancelled) return; + lastPaidKeyRef.current = paidKey; + if (!hasPendingUnlock) chatDispatch({ type: "ChatPaymentSucceeded" }); })(); return () => {