diff --git a/e2e/fixtures/api-mocks.ts b/e2e/fixtures/api-mocks.ts index 8193efc3..79894e1e 100644 --- a/e2e/fixtures/api-mocks.ts +++ b/e2e/fixtures/api-mocks.ts @@ -11,6 +11,7 @@ export interface MockCoreApisOptions { paidImageFlow?: boolean; paidImageInsufficientCreditsFlow?: boolean; paidVoiceInsufficientCreditsFlow?: boolean; + psidLoginFlow?: boolean; } export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {}) { @@ -20,12 +21,14 @@ export async function mockCoreApis(page: Page, options: MockCoreApisOptions = {} paidImageFlow: options.paidImageFlow ?? false, paidImageInsufficientCreditsFlow: options.paidImageInsufficientCreditsFlow ?? false, paidVoiceInsufficientCreditsFlow: options.paidVoiceInsufficientCreditsFlow ?? false, + psidLoginFlow: options.psidLoginFlow ?? false, }; const chatState = { hasExpiredChatSend: false, paidImageRequested: false, paidImageUnlocked: false }; await registerAuthMocks(page, { chatSendTokenRefreshFlow: chatOptions.chatSendTokenRefreshFlow, isChatSendTokenExpired: () => chatState.hasExpiredChatSend, + psidLoginFlow: chatOptions.psidLoginFlow, }); await registerUserMocks(page); await registerChatMocks(page, chatOptions, chatState); diff --git a/e2e/fixtures/api/auth.ts b/e2e/fixtures/api/auth.ts index 8aa97e41..7ac5a257 100644 --- a/e2e/fixtures/api/auth.ts +++ b/e2e/fixtures/api/auth.ts @@ -1,14 +1,24 @@ import type { Page } from "@playwright/test"; import { apiEnvelope } from "../data/common"; -import { emailLoginResponse, guestLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth"; +import { emailLoginResponse, guestLoginResponse, psidLoginResponse, refreshedEmailLoginResponse, refreshedGuestLoginResponse } from "../data/auth"; export interface AuthMockState { chatSendTokenRefreshFlow: boolean; isChatSendTokenExpired: () => boolean; + psidLoginFlow: boolean; } export async function registerAuthMocks(page: Page, state: AuthMockState) { + await page.route("**/api/auth/login/facebook/psid", async (route) => { + if (!state.psidLoginFlow) { + await route.continue(); + return; + } + + await route.fulfill({ json: apiEnvelope(psidLoginResponse) }); + }); + await page.route("**/api/auth/guest", async (route) => { const response = state.chatSendTokenRefreshFlow && state.isChatSendTokenExpired() ? refreshedGuestLoginResponse : guestLoginResponse; await route.fulfill({ json: apiEnvelope(response) }); diff --git a/e2e/fixtures/data/auth.ts b/e2e/fixtures/data/auth.ts index 57489b21..984a85cc 100644 --- a/e2e/fixtures/data/auth.ts +++ b/e2e/fixtures/data/auth.ts @@ -49,3 +49,15 @@ export const refreshedEmailLoginResponse = { token: "e2e-refreshed-email-token", refreshToken: "e2e-refreshed-email-refresh-token", }; + +export const psidLoginResponse = { + token: "e2e-psid-guest-token", + refreshToken: "", + matchedBy: "psid", + fbAsid: "", + fbPsid: "e2e-facebook-psid", + hasCompleteFacebookIdentity: false, + isGuest: true, + user: e2eUser, + userId: e2eUser.id, +}; diff --git a/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts b/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts new file mode 100644 index 00000000..bd58194d --- /dev/null +++ b/e2e/specs/mock/auth/psid-login-from-external-entry.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from "@playwright/test"; + +import { mockCoreApis } from "@e2e/fixtures/api-mocks"; +import { clearBrowserState } from "@e2e/fixtures/test-helpers"; + +const psid = "e2e-facebook-psid"; + +test.beforeEach(async ({ baseURL, context, page }) => { + await clearBrowserState(context, page, baseURL); + await mockCoreApis(page, { psidLoginFlow: true }); +}); + +test("user enters from an external PSID link and becomes a guest", async ({ + page, +}) => { + const psidLoginRequestPromise = page.waitForRequest( + "**/api/auth/login/facebook/psid", + ); + + await page.goto(`/external-entry?target=chat&psid=${psid}`); + + const psidLoginRequest = await psidLoginRequestPromise; + expect(psidLoginRequest.method()).toBe("POST"); + expect(psidLoginRequest.postDataJSON()).toMatchObject({ + psid, + bindToGuest: true, + }); + expect(psidLoginRequest.postDataJSON().deviceId).toEqual(expect.any(String)); + + await expect(page).toHaveURL(/\/chat$/); + await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); + + await expect + .poll(() => + page.evaluate(() => ({ + loginProvider: localStorage.getItem("cozsweet:login_provider"), + guestToken: localStorage.getItem("cozsweet:guest_token"), + psid: localStorage.getItem("cozsweet:psid"), + })), + ) + .toMatchObject({ + loginProvider: "guest", + guestToken: "e2e-psid-guest-token", + psid, + }); +}); diff --git a/src/app/external-entry/external-entry-persist.tsx b/src/app/external-entry/external-entry-persist.tsx index 1a5bfef4..cfe6c328 100644 --- a/src/app/external-entry/external-entry-persist.tsx +++ b/src/app/external-entry/external-entry-persist.tsx @@ -44,6 +44,7 @@ export default function ExternalEntryPersist({ const authDispatch = useAuthDispatch(); const [hasPersisted, setHasPersisted] = useState(false); const hasNavigatedRef = useRef(false); + const hasReinitializedForPsidRef = useRef(false); const targetRoute = resolveExternalEntryTarget({ target }); const destination = resolveExternalEntryDestination({ target }); const resolvedPromotionType = resolveExternalEntryPromotionType({ @@ -94,6 +95,16 @@ export default function ExternalEntryPersist({ useEffect(() => { if (hasNavigatedRef.current || !hasPersisted) return; + + // AuthStatusChecker can initialize before this entry has persisted PSID. + // Re-run the check so PSID direct login is not skipped by that race. + if (hasValue(psid) && !hasReinitializedForPsidRef.current) { + if (!authState.hasInitialized || authState.isLoading) return; + hasReinitializedForPsidRef.current = true; + authDispatch({ type: "AuthInit" }); + return; + } + if (!authState.hasInitialized || authState.isLoading) return; hasNavigatedRef.current = true; @@ -138,3 +149,7 @@ export default function ExternalEntryPersist({ ); } + +function hasValue(value: string | null): value is string { + return typeof value === "string" && value.trim().length > 0; +}