fix(e2e): stabilize authenticated unlock flows

This commit is contained in:
Codex
2026-07-22 16:53:09 +08:00
parent 8bd67b83c5
commit e813333607
16 changed files with 116 additions and 28 deletions
+1 -1
View File
@@ -40,6 +40,6 @@ pnpm test:e2e:real
```bash ```bash
PLAYWRIGHT_BASE_URL=https://cozsweet.com \ 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 pnpm test:e2e:prod
``` ```
+1
View File
@@ -66,6 +66,7 @@ export function createWeeklyLimitChatSendResponse(
} }
export const paidImageMessageId = "msg_photo_paywall_001"; export const paidImageMessageId = "msg_photo_paywall_001";
export const paidImageDisplayMessageId = `server:${paidImageMessageId}:assistant`;
export const paidImageUrl = export const paidImageUrl =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
export const paidImageChatSendResponse = { export const paidImageChatSendResponse = {
-2
View File
@@ -42,9 +42,7 @@ export async function dismissChatInterruptions(page: Page) {
export async function signInWithEmailAndOpenChat(page: Page) { export async function signInWithEmailAndOpenChat(page: Page) {
await seedEmailSession(page); await seedEmailSession(page);
const historyResponsePromise = page.waitForResponse("**/api/chat/history**");
await page.goto(defaultCharacterChatPath); await page.goto(defaultCharacterChatPath);
await historyResponsePromise;
await dismissChatInterruptions(page); await dismissChatInterruptions(page);
await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 }); await expect(page.getByRole("textbox", { name: "Message" })).toBeEnabled({ timeout: 20_000 });
} }
@@ -7,7 +7,6 @@ import {
defaultCharacterChatUrl, defaultCharacterChatUrl,
expectInsufficientCreditsDialog, expectInsufficientCreditsDialog,
expectVipChatSubscriptionUrl, expectVipChatSubscriptionUrl,
setEmailSessionStorage,
submitEmailLogin, submitEmailLogin,
switchToEmailSignIn, switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers"; } 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 expect(page).toHaveURL(/\/auth\?redirect=/);
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
await setEmailSessionStorage(page);
const unlockRequestPromise = page.waitForRequest( const unlockRequestPromise = page.waitForRequest(
"**/api/chat/unlock-private", "**/api/chat/unlock-private",
); );
await page.reload(); await submitEmailLogin(page, { expectedUrl: defaultCharacterChatUrl });
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
expect(unlockRequest.method()).toBe("POST"); expect(unlockRequest.method()).toBe("POST");
+10 -11
View File
@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { paidImageMessageId } from "@e2e/fixtures/test-data"; import {
paidImageDisplayMessageId,
paidImageMessageId,
} from "@e2e/fixtures/test-data";
import { import {
clearBrowserState, clearBrowserState,
completeVipPayment, completeVipPayment,
@@ -10,13 +13,12 @@ import {
enterChatFromSplash, enterChatFromSplash,
expectInsufficientCreditsDialog, expectInsufficientCreditsDialog,
expectVipChatSubscriptionUrl, expectVipChatSubscriptionUrl,
setEmailSessionStorage,
submitEmailLogin, submitEmailLogin,
switchToEmailSignIn, switchToEmailSignIn,
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
const paidImageOverlayUrl = new RegExp( const paidImageOverlayUrl = new RegExp(
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`, `${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
); );
test.beforeEach(async ({ baseURL, context, page }) => { 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=/); await expect(page).toHaveURL(/\/auth\?redirect=/);
expect(new URL(page.url()).searchParams.get("redirect")).toBe( expect(new URL(page.url()).searchParams.get("redirect")).toBe(
`${defaultCharacterChatPath}?image=${paidImageMessageId}`, `${defaultCharacterChatPath}?image=${encodeURIComponent(paidImageDisplayMessageId)}`,
); );
await switchToEmailSignIn(page); await switchToEmailSignIn(page);
await submitEmailLogin(page, {
expectedUrl: paidImageOverlayUrl,
});
await setEmailSessionStorage(page);
const unlockRequestPromise = page.waitForRequest( const unlockRequestPromise = page.waitForRequest(
"**/api/chat/unlock-private", "**/api/chat/unlock-private",
); );
await page.goto(`${defaultCharacterChatPath}?image=${paidImageMessageId}`); await submitEmailLogin(page, {
expectedUrl: paidImageOverlayUrl,
});
const unlockRequest = await unlockRequestPromise; const unlockRequest = await unlockRequestPromise;
expect(unlockRequest.postDataJSON()).toMatchObject({ 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( await expect(page).toHaveURL(
new RegExp( new RegExp(
`${defaultCharacterChatPath}(?:\\?image=msg_photo_paywall_001)?$`, `${defaultCharacterChatPath}(?:\\?image=${encodeURIComponent(paidImageDisplayMessageId)})?$`,
), ),
); );
expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath); expect(new URL(page.url()).pathname).toBe(defaultCharacterChatPath);
@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockCoreApis } from "@e2e/fixtures/api-mocks"; import { mockCoreApis } from "@e2e/fixtures/api-mocks";
import { paidImageMessageId } from "@e2e/fixtures/test-data"; import {
paidImageDisplayMessageId,
paidImageMessageId,
} from "@e2e/fixtures/test-data";
import { import {
clearBrowserState, clearBrowserState,
defaultCharacterChatPath, defaultCharacterChatPath,
@@ -12,7 +15,7 @@ import {
} from "@e2e/fixtures/test-helpers"; } from "@e2e/fixtures/test-helpers";
const paidImageOverlayUrl = new RegExp( const paidImageOverlayUrl = new RegExp(
`${defaultCharacterChatPath}\\?image=${paidImageMessageId}$`, `${defaultCharacterChatPath}\\?image=${encodeURIComponent(paidImageDisplayMessageId)}$`,
); );
test.beforeEach(async ({ baseURL, context, page }) => { test.beforeEach(async ({ baseURL, context, page }) => {
+4 -1
View File
@@ -12,7 +12,10 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: Boolean(process.env.CI), forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0, 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 reporter: process.env.CI
? [["list"], ["html", { open: "never" }]] ? [["list"], ["html", { open: "never" }]]
: [["list"], ["html", { open: "never" }]], : [["list"], ["html", { open: "never" }]],
+6 -2
View File
@@ -132,7 +132,11 @@ export function ChatScreen() {
}); });
useEffect(() => { useEffect(() => {
if (!imageMessageId || !state.historyLoaded || selectedImageMessage) { if (
!imageMessageId ||
!state.networkHistoryLoaded ||
selectedImageMessage
) {
return; return;
} }
router.replace( router.replace(
@@ -145,7 +149,7 @@ export function ChatScreen() {
router, router,
searchParams, searchParams,
selectedImageMessage, selectedImageMessage,
state.historyLoaded, state.networkHistoryLoaded,
]); ]);
useEffect(() => { useEffect(() => {
@@ -55,6 +55,7 @@ function makeChatState(overrides: Partial<ChatState> = {}): ChatState {
requiredCredits: 0, requiredCredits: 0,
shortfallCredits: 0, shortfallCredits: 0,
historyLoaded: true, historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: 0, historyTotal: 0,
historyLimit: 50, historyLimit: 50,
nextHistoryOffset: 0, nextHistoryOffset: 0,
@@ -267,6 +268,8 @@ describe("chat history pagination helpers", () => {
limit: 0, limit: 0,
}); });
expect(nextState.networkHistoryLoaded).toBe(true);
expect(nextState.historyLimit).toBe(50); expect(nextState.historyLimit).toBe(50);
expect(nextState.nextHistoryOffset).toBe(50); expect(nextState.nextHistoryOffset).toBe(50);
expect(nextState.historyTotal).toBe(120); expect(nextState.historyTotal).toBe(120);
+3
View File
@@ -22,6 +22,8 @@ interface ChatState {
upgradeReason: MachineContext["upgradeReason"]; upgradeReason: MachineContext["upgradeReason"];
/** True as soon as a local snapshot or the first network result is renderable. */ /** True as soon as a local snapshot or the first network result is renderable. */
historyLoaded: boolean; historyLoaded: boolean;
/** True after the network history request has completed or failed. */
networkHistoryLoaded: boolean;
hasMoreHistory: boolean; hasMoreHistory: boolean;
isLoadingMoreHistory: boolean; isLoadingMoreHistory: boolean;
unlockHistoryPromptVisible: boolean; unlockHistoryPromptVisible: boolean;
@@ -93,6 +95,7 @@ function selectChatState(state: ChatSnapshot): SelectedChatState {
upgradePromptVisible: state.context.upgradePromptVisible, upgradePromptVisible: state.context.upgradePromptVisible,
upgradeReason: state.context.upgradeReason, upgradeReason: state.context.upgradeReason,
historyLoaded: state.context.historyLoaded, historyLoaded: state.context.historyLoaded,
networkHistoryLoaded: state.context.networkHistoryLoaded,
hasMoreHistory: hasMoreHistory:
state.context.historyLoaded && state.context.historyLoaded &&
state.context.nextHistoryOffset < state.context.historyTotal, state.context.nextHistoryOffset < state.context.historyTotal,
+3
View File
@@ -46,6 +46,8 @@ export interface ChatState {
shortfallCredits: number; shortfallCredits: number;
/** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */ /** history 首屏可展示标志(本地快照加载完成即可进入 ready)。 */
historyLoaded: boolean; historyLoaded: boolean;
/** 网络历史已返回或已明确失败;用于避免过早判定深链消息不存在。 */
networkHistoryLoaded: boolean;
historyTotal: number; historyTotal: number;
historyLimit: number; historyLimit: number;
nextHistoryOffset: number; nextHistoryOffset: number;
@@ -80,6 +82,7 @@ export function createInitialChatState(
requiredCredits: 0, requiredCredits: 0,
shortfallCredits: 0, shortfallCredits: 0,
historyLoaded: false, historyLoaded: false,
networkHistoryLoaded: false,
historyTotal: 0, historyTotal: 0,
historyLimit: 50, historyLimit: 50,
nextHistoryOffset: 0, nextHistoryOffset: 0,
+2
View File
@@ -29,6 +29,7 @@ export function applyNetworkHistoryLoadedOutput(
ChatState, ChatState,
| "messages" | "messages"
| "historyLoaded" | "historyLoaded"
| "networkHistoryLoaded"
| "historyTotal" | "historyTotal"
| "historyLimit" | "historyLimit"
| "nextHistoryOffset" | "nextHistoryOffset"
@@ -47,6 +48,7 @@ export function applyNetworkHistoryLoadedOutput(
return { return {
messages: [...output.messages, ...optimisticTail], messages: [...output.messages, ...optimisticTail],
historyLoaded: true, historyLoaded: true,
networkHistoryLoaded: true,
historyTotal: Math.max(0, output.total), historyTotal: Math.max(0, output.total),
historyLimit, historyLimit,
nextHistoryOffset: historyLimit, nextHistoryOffset: historyLimit,
+1
View File
@@ -55,6 +55,7 @@ const markHistoryLoadFailedAction = baseChatMachineSetup.assign(
const characterErrorCode = getCharacterErrorCode(event.error); const characterErrorCode = getCharacterErrorCode(event.error);
return { return {
historyLoaded: true, historyLoaded: true,
networkHistoryLoaded: true,
characterErrorCode, characterErrorCode,
canSendMessage: canSendMessage:
characterErrorCode === "CHARACTER_DISABLED" || characterErrorCode === "CHARACTER_DISABLED" ||
@@ -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<typeof createRoot> | 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(
<StrictMode>
<ChatAuthSync />
</StrictMode>,
);
});
expect(mocks.chatDispatch).toHaveBeenCalledTimes(1);
expect(mocks.chatDispatch).toHaveBeenCalledWith({
type: "ChatUserLogin",
token: "email-token",
});
});
});
+3 -1
View File
@@ -34,14 +34,15 @@ export function ChatAuthSync() {
const sessionKey = authState.loginStatus; const sessionKey = authState.loginStatus;
if (prevSessionKeyRef.current === sessionKey) return; if (prevSessionKeyRef.current === sessionKey) return;
prevSessionKeyRef.current = sessionKey;
if (authState.loginStatus === "notLoggedIn") { if (authState.loginStatus === "notLoggedIn") {
prevSessionKeyRef.current = sessionKey;
chatDispatch({ type: "ChatLogout" }); chatDispatch({ type: "ChatLogout" });
return; return;
} }
if (authState.loginStatus === "guest") { if (authState.loginStatus === "guest") {
prevSessionKeyRef.current = sessionKey;
chatDispatch({ type: "ChatGuestLogin" }); chatDispatch({ type: "ChatGuestLogin" });
return; return;
} }
@@ -50,6 +51,7 @@ export function ChatAuthSync() {
void (async () => { void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken(); const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) { if (!cancelled && tokenR.success && tokenR.data) {
prevSessionKeyRef.current = sessionKey;
chatDispatch({ chatDispatch({
type: "ChatUserLogin", type: "ChatUserLogin",
token: tokenR.data, token: tokenR.data,
@@ -29,12 +29,13 @@ export function ChatPaymentSuccessSync() {
const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`;
if (lastPaidKeyRef.current === paidKey) return; if (lastPaidKeyRef.current === paidKey) return;
lastPaidKeyRef.current = paidKey;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
if (await hasPendingChatUnlock(characterId)) return; const hasPendingUnlock = await hasPendingChatUnlock(characterId);
if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); if (cancelled) return;
lastPaidKeyRef.current = paidKey;
if (!hasPendingUnlock) chatDispatch({ type: "ChatPaymentSucceeded" });
})(); })();
return () => { return () => {