From 4efa124c85ec7e319f79970e160e482a95f14372 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Thu, 2 Oct 2025 11:50:06 +0100 Subject: [PATCH] refactor(core): Rework behaviors --- packages/core/src/auth.ts | 2 +- packages/core/src/behaviors.test.ts | 322 ------------------ packages/core/src/behaviors.ts | 137 -------- .../src/behaviors/anonymous-upgrade.test.ts | 97 ++++++ .../core/src/behaviors/anonymous-upgrade.ts | 36 ++ .../behaviors/auto-anonymous-login.test.ts | 32 ++ .../src/behaviors/auto-anonymous-login.ts | 9 + packages/core/src/behaviors/index.test.ts | 182 ++++++++++ packages/core/src/behaviors/index.ts | 71 ++++ packages/core/src/behaviors/recaptcha.test.ts | 214 ++++++++++++ packages/core/src/behaviors/recaptcha.ts | 20 ++ packages/core/src/behaviors/utils.test.ts | 198 +++++++++++ packages/core/src/behaviors/utils.ts | 33 ++ packages/core/src/config.test.ts | 279 ++++++++++++--- packages/core/src/config.ts | 74 ++-- packages/core/src/country-data.test.ts | 39 ++- 16 files changed, 1202 insertions(+), 543 deletions(-) delete mode 100644 packages/core/src/behaviors.test.ts delete mode 100644 packages/core/src/behaviors.ts create mode 100644 packages/core/src/behaviors/anonymous-upgrade.test.ts create mode 100644 packages/core/src/behaviors/anonymous-upgrade.ts create mode 100644 packages/core/src/behaviors/auto-anonymous-login.test.ts create mode 100644 packages/core/src/behaviors/auto-anonymous-login.ts create mode 100644 packages/core/src/behaviors/index.test.ts create mode 100644 packages/core/src/behaviors/index.ts create mode 100644 packages/core/src/behaviors/recaptcha.test.ts create mode 100644 packages/core/src/behaviors/recaptcha.ts create mode 100644 packages/core/src/behaviors/utils.test.ts create mode 100644 packages/core/src/behaviors/utils.ts diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index bc036743..b8d38339 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -32,9 +32,9 @@ import { signInWithRedirect, UserCredential, } from "firebase/auth"; -import { getBehavior, hasBehavior } from "./behaviors"; import { FirebaseUIConfiguration } from "./config"; import { handleFirebaseError } from "./errors"; +import { hasBehavior, getBehavior } from "./behaviors/index"; async function handlePendingCredential(ui: FirebaseUIConfiguration, user: UserCredential): Promise { const pendingCredString = window.sessionStorage.getItem("pendingCred"); diff --git a/packages/core/src/behaviors.test.ts b/packages/core/src/behaviors.test.ts deleted file mode 100644 index 24433f25..00000000 --- a/packages/core/src/behaviors.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createMockUI } from "~/tests/utils"; -import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior, recaptchaVerification } from "./behaviors"; -import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider, RecaptchaVerifier } from "firebase/auth"; - -vi.mock("firebase/auth", () => ({ - signInAnonymously: vi.fn(), - linkWithCredential: vi.fn(), - linkWithRedirect: vi.fn(), - RecaptchaVerifier: vi.fn(), -})); - -describe("hasBehavior", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should return true if the behavior is enabled, but not call it", () => { - const mockBehavior = vi.fn(); - const ui = createMockUI({ - behaviors: { - autoAnonymousLogin: mockBehavior, - }, - }); - - expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true); - expect(mockBehavior).not.toHaveBeenCalled(); - }); - - it("should return false if the behavior is not enabled", () => { - const ui = createMockUI(); - expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(false); - }); -}); - -describe("getBehavior", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should throw if the behavior is not enabled", () => { - const ui = createMockUI(); - expect(() => getBehavior(ui, "autoAnonymousLogin")).toThrow(); - }); - - it("should call the behavior if it is enabled", () => { - const mockBehavior = vi.fn(); - const ui = createMockUI({ - behaviors: { - autoAnonymousLogin: mockBehavior, - }, - }); - - expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true); - expect(getBehavior(ui, "autoAnonymousLogin")).toBe(mockBehavior); - }); -}); - -describe("autoAnonymousLogin", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should sign the user in anonymously if they are not signed in', async () => { - const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); - const mockUI = createMockUI({ - auth: { - currentUser: null, - authStateReady: mockAuthStateReady, - } as unknown as Auth, - }); - - vi.mocked(signInAnonymously).mockResolvedValue({} as UserCredential); - - await autoAnonymousLogin().autoAnonymousLogin(mockUI); - - expect(mockAuthStateReady).toHaveBeenCalled(); - expect(signInAnonymously).toHaveBeenCalledWith(mockUI.auth); - - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["loading"], ["idle"]]); - }); - - it('should not attempt to sign in anonymously if the user is already signed in', async () => { - const mockAuthStateReady = vi.fn().mockResolvedValue(undefined); - const mockUI = createMockUI({ - auth: { - currentUser: { uid: "test-user" } as User, - authStateReady: mockAuthStateReady, - } as unknown as Auth, - }); - - vi.mocked(signInAnonymously).mockResolvedValue({} as UserCredential); - - await autoAnonymousLogin().autoAnonymousLogin(mockUI); - - expect(mockAuthStateReady).toHaveBeenCalled(); - expect(signInAnonymously).not.toHaveBeenCalled(); - - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([ ["idle"]]); - }); - - it("should return noop behavior in SSR mode", async () => { - // Mock window as undefined to simulate SSR - const originalWindow = global.window; - // @ts-ignore - delete global.window; - - const behavior = autoAnonymousLogin(); - const mockUI = createMockUI(); - - const result = await behavior.autoAnonymousLogin(mockUI); - - expect(result).toEqual({ uid: "server-placeholder" }); - expect(signInAnonymously).not.toHaveBeenCalled(); - - // Restore window - global.window = originalWindow; - }); -}); - -describe("autoUpgradeAnonymousUsers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe("autoUpgradeAnonymousCredential", () => { - it("should upgrade anonymous user with credential", async () => { - const mockCredential = { providerId: "password" } as AuthCredential; - const mockUserCredential = { user: { uid: "test-user" } } as UserCredential; - const mockAnonymousUser = { uid: "anonymous-user", isAnonymous: true } as User; - - const mockUI = createMockUI({ - auth: { - currentUser: mockAnonymousUser, - } as unknown as Auth, - }); - - vi.mocked(linkWithCredential).mockResolvedValue(mockUserCredential); - - const behavior = autoUpgradeAnonymousUsers(); - const result = await behavior.autoUpgradeAnonymousCredential(mockUI, mockCredential); - - expect(linkWithCredential).toHaveBeenCalledWith(mockAnonymousUser, mockCredential); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); - expect(result).toBe(mockUserCredential); - }); - - it("should return undefined if user is not anonymous", async () => { - const mockCredential = { providerId: "password" } as AuthCredential; - const mockRegularUser = { uid: "regular-user", isAnonymous: false } as User; - - const mockUI = createMockUI({ - auth: { - currentUser: mockRegularUser, - } as unknown as Auth, - }); - - const behavior = autoUpgradeAnonymousUsers(); - const result = await behavior.autoUpgradeAnonymousCredential(mockUI, mockCredential); - - expect(linkWithCredential).not.toHaveBeenCalled(); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([]); - expect(result).toBeUndefined(); - }); - - it("should return undefined if no current user", async () => { - const mockCredential = { providerId: "password" } as AuthCredential; - - const mockUI = createMockUI({ - auth: { - currentUser: null, - } as unknown as Auth, - }); - - const behavior = autoUpgradeAnonymousUsers(); - const result = await behavior.autoUpgradeAnonymousCredential(mockUI, mockCredential); - - expect(linkWithCredential).not.toHaveBeenCalled(); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([]); - expect(result).toBeUndefined(); - }); - }); - - describe("autoUpgradeAnonymousProvider", () => { - it("should upgrade anonymous user with provider", async () => { - const mockProvider = { providerId: "google.com" } as AuthProvider; - const mockAnonymousUser = { uid: "anonymous-user", isAnonymous: true } as User; - - const mockUI = createMockUI({ - auth: { - currentUser: mockAnonymousUser, - } as unknown as Auth, - }); - - vi.mocked(linkWithRedirect).mockResolvedValue(undefined as never); - - const behavior = autoUpgradeAnonymousUsers(); - await behavior.autoUpgradeAnonymousProvider(mockUI, mockProvider); - - expect(linkWithRedirect).toHaveBeenCalledWith(mockAnonymousUser, mockProvider); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"]]); - }); - - it("should return early if user is not anonymous", async () => { - const mockProvider = { providerId: "google.com" } as AuthProvider; - const mockRegularUser = { uid: "regular-user", isAnonymous: false } as User; - - const mockUI = createMockUI({ - auth: { - currentUser: mockRegularUser, - } as unknown as Auth, - }); - - const behavior = autoUpgradeAnonymousUsers(); - await behavior.autoUpgradeAnonymousProvider(mockUI, mockProvider); - - expect(linkWithRedirect).not.toHaveBeenCalled(); - expect(vi.mocked(mockUI.setState).mock.calls).toEqual([]); - }); - }); -}); - -describe("recaptchaVerification", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create a RecaptchaVerifier with default options", () => { - const mockRecaptchaVerifier = { render: vi.fn() }; - vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); - - const mockElement = document.createElement("div"); - const mockUI = createMockUI(); - - const behavior = recaptchaVerification(); - const result = behavior.recaptchaVerification(mockUI, mockElement); - - expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { - size: "invisible", - theme: "light", - tabindex: 0, - }); - expect(result).toBe(mockRecaptchaVerifier); - }); - - it("should create a RecaptchaVerifier with custom options", () => { - const mockRecaptchaVerifier = { render: vi.fn() }; - vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); - - const mockElement = document.createElement("div"); - const mockUI = createMockUI(); - const customOptions = { - size: "normal" as const, - theme: "dark" as const, - tabindex: 5, - }; - - const behavior = recaptchaVerification(customOptions); - const result = behavior.recaptchaVerification(mockUI, mockElement); - - expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { - size: "normal", - theme: "dark", - tabindex: 5, - }); - expect(result).toBe(mockRecaptchaVerifier); - }); - - it("should create a RecaptchaVerifier with partial custom options", () => { - const mockRecaptchaVerifier = { render: vi.fn() }; - vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); - - const mockElement = document.createElement("div"); - const mockUI = createMockUI(); - const partialOptions = { - size: "compact" as const, - }; - - const behavior = recaptchaVerification(partialOptions); - const result = behavior.recaptchaVerification(mockUI, mockElement); - - expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { - size: "compact", - theme: "light", - tabindex: 0, - }); - expect(result).toBe(mockRecaptchaVerifier); - }); - - it("should work with hasBehavior and getBehavior", () => { - const mockRecaptchaVerifier = { render: vi.fn() }; - vi.mocked(RecaptchaVerifier).mockImplementation(() => mockRecaptchaVerifier as any); - - const mockElement = document.createElement("div"); - const mockUI = createMockUI({ - behaviors: { - recaptchaVerification: recaptchaVerification().recaptchaVerification, - }, - }); - - expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(true); - - const behavior = getBehavior(mockUI, "recaptchaVerification"); - const result = behavior(mockUI, mockElement); - - expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { - size: "invisible", - theme: "light", - tabindex: 0, - }); - expect(result).toBe(mockRecaptchaVerifier); - }); - - it("should throw error when trying to get non-existent recaptchaVerification behavior", () => { - const mockUI = createMockUI(); - - expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(false); - expect(() => getBehavior(mockUI, "recaptchaVerification")).toThrow("Behavior recaptchaVerification not found"); - }); -}); - - diff --git a/packages/core/src/behaviors.ts b/packages/core/src/behaviors.ts deleted file mode 100644 index 43d0b8af..00000000 --- a/packages/core/src/behaviors.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { - AuthCredential, - AuthProvider, - linkWithCredential, - linkWithRedirect, - signInAnonymously, - User, - UserCredential, - RecaptchaVerifier, -} from "firebase/auth"; -import { FirebaseUIConfiguration } from "./config"; - -export type BehaviorHandlers = { - autoAnonymousLogin: (ui: FirebaseUIConfiguration) => Promise; - autoUpgradeAnonymousCredential: ( - ui: FirebaseUIConfiguration, - credential: AuthCredential - ) => Promise; - autoUpgradeAnonymousProvider: (ui: FirebaseUIConfiguration, provider: AuthProvider) => Promise; - recaptchaVerification: (ui: FirebaseUIConfiguration, element: HTMLElement) => RecaptchaVerifier; -}; - -export type Behavior = Pick; - -export type BehaviorKey = keyof BehaviorHandlers; - -export function hasBehavior(ui: FirebaseUIConfiguration, key: BehaviorKey): boolean { - return !!ui.behaviors[key]; -} - -export function getBehavior(ui: FirebaseUIConfiguration, key: T): Behavior[T] { - if (!hasBehavior(ui, key)) { - throw new Error(`Behavior ${key} not found`); - } - - return ui.behaviors[key] as Behavior[T]; -} - -export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { - /** No-op on Server render */ - if (typeof window === "undefined") { - // eslint-disable-next-line no-console - console.log("[autoAnonymousLogin] SSR mode — returning noop behavior"); - return { - autoAnonymousLogin: async (_ui) => { - /** Return a placeholder user object */ - return { uid: "server-placeholder" } as unknown as User; - }, - }; - } - - return { - autoAnonymousLogin: async (ui) => { - const auth = ui.auth; - - await auth.authStateReady(); - - if (!auth.currentUser) { - ui.setState("loading"); - await signInAnonymously(auth); - } - - ui.setState("idle"); - return auth.currentUser!; - }, - }; -} - -export function autoUpgradeAnonymousUsers(): Behavior< - "autoUpgradeAnonymousCredential" | "autoUpgradeAnonymousProvider" -> { - return { - autoUpgradeAnonymousCredential: async (ui, credential) => { - const currentUser = ui.auth.currentUser; - - // Check if the user is anonymous. If not, we can't upgrade them. - if (!currentUser?.isAnonymous) { - return; - } - - ui.setState("pending"); - const result = await linkWithCredential(currentUser, credential); - ui.setState("idle"); - return result; - }, - autoUpgradeAnonymousProvider: async (ui, provider) => { - const currentUser = ui.auth.currentUser; - - if (!currentUser?.isAnonymous) { - return; - } - - ui.setState("pending"); - await linkWithRedirect(currentUser, provider); - // We don't modify state here since the user is redirected. - // If we support popups, we'd need to modify state here. - }, - }; -} - -export type RecaptchaVerification = { - size?: "normal" | "invisible" | "compact"; - theme?: "light" | "dark"; - tabindex?: number; -}; - -export function recaptchaVerification(options?: RecaptchaVerification): Behavior<"recaptchaVerification"> { - return { - recaptchaVerification: (ui, element) => { - return new RecaptchaVerifier(ui.auth, element, { - size: options?.size ?? "invisible", - theme: options?.theme ?? "light", - tabindex: options?.tabindex ?? 0, - }); - }, - }; -} - -export const defaultBehaviors = { - ...recaptchaVerification(), -}; \ No newline at end of file diff --git a/packages/core/src/behaviors/anonymous-upgrade.test.ts b/packages/core/src/behaviors/anonymous-upgrade.test.ts new file mode 100644 index 00000000..918ef6d9 --- /dev/null +++ b/packages/core/src/behaviors/anonymous-upgrade.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Auth, AuthCredential, AuthProvider, linkWithCredential, linkWithRedirect, User } from "firebase/auth"; +import { autoUpgradeAnonymousCredentialHandler, autoUpgradeAnonymousProviderHandler } from "./anonymous-upgrade"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + linkWithCredential: vi.fn(), + linkWithRedirect: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("autoUpgradeAnonymousCredentialHandler", () => { + it("should upgrade anonymous user with credential", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + + const mockResult = { user: { uid: "upgraded-123" } }; + vi.mocked(linkWithCredential).mockResolvedValue(mockResult as any); + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential); + + expect(linkWithCredential).toHaveBeenCalledWith(mockUser, mockCredential); + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(mockUI.setState).toHaveBeenCalledWith("idle"); + expect(result).toBe(mockResult); + }); + + it("should not upgrade when user is not anonymous", async () => { + const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it("should not upgrade when no current user", async () => { + const mockAuth = { currentUser: null } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockCredential = { providerId: "password" } as AuthCredential; + + const result = await autoUpgradeAnonymousCredentialHandler(mockUI, mockCredential); + + expect(linkWithCredential).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); +}); + +describe("autoUpgradeAnonymousProviderHandler", () => { + it("should upgrade anonymous user with provider", async () => { + const mockUser = { isAnonymous: true, uid: "anonymous-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(linkWithRedirect).mockResolvedValue({} as never); + + await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider); + + expect(linkWithRedirect).toHaveBeenCalledWith(mockUser, mockProvider); + expect(mockUI.setState).toHaveBeenCalledWith("pending"); + expect(mockUI.setState).not.toHaveBeenCalledWith("idle"); + }); + + it("should not upgrade when user is not anonymous", async () => { + const mockUser = { isAnonymous: false, uid: "regular-user-123" } as User; + const mockAuth = { currentUser: mockUser } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider); + + expect(linkWithRedirect).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + }); + + it("should not upgrade when no current user", async () => { + const mockAuth = { currentUser: null } as Auth; + const mockUI = createMockUI({ auth: mockAuth }); + const mockProvider = { providerId: "google.com" } as AuthProvider; + + await autoUpgradeAnonymousProviderHandler(mockUI, mockProvider); + + expect(linkWithRedirect).not.toHaveBeenCalled(); + expect(mockUI.setState).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/behaviors/anonymous-upgrade.ts b/packages/core/src/behaviors/anonymous-upgrade.ts new file mode 100644 index 00000000..e43ef429 --- /dev/null +++ b/packages/core/src/behaviors/anonymous-upgrade.ts @@ -0,0 +1,36 @@ +import { AuthCredential, AuthProvider, linkWithCredential, linkWithRedirect } from "firebase/auth"; +import { FirebaseUIConfiguration } from "~/config"; +import { RedirectHandler } from "./utils"; + +export const autoUpgradeAnonymousCredentialHandler = async (ui: FirebaseUIConfiguration, credential: AuthCredential) => { + const currentUser = ui.auth.currentUser; + + // Check if the user is anonymous. If not, we can't upgrade them. + if (!currentUser?.isAnonymous) { + return; + } + + ui.setState("pending"); + const result = await linkWithCredential(currentUser, credential); + + ui.setState("idle"); + return result; +}; + +export const autoUpgradeAnonymousProviderHandler = async (ui: FirebaseUIConfiguration, provider: AuthProvider) => { + const currentUser = ui.auth.currentUser; + + if (!currentUser?.isAnonymous) { + return; + } + + ui.setState("pending"); + // TODO... this should use redirect OR popup + await linkWithRedirect(currentUser, provider); + // We don't modify state here since the user is redirected. + // If we support popups, we'd need to modify state here. +}; + +export const autoUpgradeAnonymousUserRedirectHandler: RedirectHandler = async () => { + // TODO +}; \ No newline at end of file diff --git a/packages/core/src/behaviors/auto-anonymous-login.test.ts b/packages/core/src/behaviors/auto-anonymous-login.test.ts new file mode 100644 index 00000000..9dd033a9 --- /dev/null +++ b/packages/core/src/behaviors/auto-anonymous-login.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Auth, signInAnonymously, User } from "firebase/auth"; +import { autoAnonymousLoginHandler } from "./auto-anonymous-login"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + signInAnonymously: vi.fn(), +})); + +describe("autoAnonymousLoginHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should sign in anonymously when no current user exists", async () => { + const mockUI = createMockUI({ auth: { currentUser: null } as Auth }); + + const mockSignInResult = { user: { uid: "anonymous-123" } }; + vi.mocked(signInAnonymously).mockResolvedValue(mockSignInResult as any); + + await autoAnonymousLoginHandler(mockUI); + + expect(signInAnonymously).toHaveBeenCalledWith(mockUI.auth); + expect(signInAnonymously).toHaveBeenCalledTimes(1); + }); + + it("should not sign in when current user already exists", async () => { + const mockUI = createMockUI({ auth: { currentUser: { uid: "existing-user-123" } as User } as Auth }); + await autoAnonymousLoginHandler(mockUI); + expect(signInAnonymously).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/behaviors/auto-anonymous-login.ts b/packages/core/src/behaviors/auto-anonymous-login.ts new file mode 100644 index 00000000..53805844 --- /dev/null +++ b/packages/core/src/behaviors/auto-anonymous-login.ts @@ -0,0 +1,9 @@ +import { signInAnonymously } from "firebase/auth"; +import { InitHandler } from "./utils"; + +export const autoAnonymousLoginHandler: InitHandler = async (ui) => { + const auth = ui.auth; + if (!auth.currentUser) { + await signInAnonymously(auth); + } +}; diff --git a/packages/core/src/behaviors/index.test.ts b/packages/core/src/behaviors/index.test.ts new file mode 100644 index 00000000..2e98214c --- /dev/null +++ b/packages/core/src/behaviors/index.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockUI } from "~/tests/utils"; +import { + autoAnonymousLogin, + autoUpgradeAnonymousUsers, + getBehavior, + hasBehavior, + recaptchaVerification, + defaultBehaviors, +} from "./index"; + +vi.mock("firebase/auth", () => ({ + RecaptchaVerifier: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("hasBehavior", () => { + it("should return true if the behavior is enabled", () => { + const mockBehavior = { type: "init" as const, handler: vi.fn() }; + const ui = createMockUI({ + behaviors: { + autoAnonymousLogin: mockBehavior, + } as any, + }); + + expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true); + expect(mockBehavior.handler).not.toHaveBeenCalled(); + }); + + it("should return false if the behavior is not enabled", () => { + const ui = createMockUI(); + expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(false); + }); + + it("should work with all behavior types", () => { + const mockUI = createMockUI({ + behaviors: { + autoAnonymousLogin: { type: "init" as const, handler: vi.fn() }, + autoUpgradeAnonymousCredential: { type: "callable" as const, handler: vi.fn() }, + autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, + recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, + } as any, + }); + + expect(hasBehavior(mockUI, "autoAnonymousLogin")).toBe(true); + expect(hasBehavior(mockUI, "autoUpgradeAnonymousCredential")).toBe(true); + expect(hasBehavior(mockUI, "autoUpgradeAnonymousProvider")).toBe(true); + expect(hasBehavior(mockUI, "recaptchaVerification")).toBe(true); + }); +}); + +describe("getBehavior", () => { + it("should throw if the behavior is not enabled", () => { + const ui = createMockUI(); + expect(() => getBehavior(ui, "autoAnonymousLogin")).toThrow("Behavior autoAnonymousLogin not found"); + }); + + it("should return the behavior handler if it is enabled", () => { + const mockBehavior = { type: "init" as const, handler: vi.fn() }; + const ui = createMockUI({ + behaviors: { + autoAnonymousLogin: mockBehavior, + } as any, + }); + + expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true); + expect(getBehavior(ui, "autoAnonymousLogin")).toBe(mockBehavior.handler); + }); + + it("should work with all behavior types", () => { + const mockBehaviors = { + autoAnonymousLogin: { type: "init" as const, handler: vi.fn() }, + autoUpgradeAnonymousCredential: { type: "callable" as const, handler: vi.fn() }, + autoUpgradeAnonymousProvider: { type: "callable" as const, handler: vi.fn() }, + recaptchaVerification: { type: "callable" as const, handler: vi.fn() }, + }; + + const ui = createMockUI({ behaviors: mockBehaviors as any }); + + expect(getBehavior(ui, "autoAnonymousLogin")).toBe(mockBehaviors.autoAnonymousLogin.handler); + expect(getBehavior(ui, "autoUpgradeAnonymousCredential")).toBe( + mockBehaviors.autoUpgradeAnonymousCredential.handler + ); + expect(getBehavior(ui, "autoUpgradeAnonymousProvider")).toBe(mockBehaviors.autoUpgradeAnonymousProvider.handler); + expect(getBehavior(ui, "recaptchaVerification")).toBe(mockBehaviors.recaptchaVerification.handler); + }); +}); + +describe("autoAnonymousLogin", () => { + it("should return behavior with correct structure", () => { + const behavior = autoAnonymousLogin(); + + expect(behavior).toHaveProperty("autoAnonymousLogin"); + expect(behavior.autoAnonymousLogin).toHaveProperty("type", "init"); + expect(behavior.autoAnonymousLogin).toHaveProperty("handler"); + expect(typeof behavior.autoAnonymousLogin.handler).toBe("function"); + }); + + it("should not include other behaviors", () => { + const behavior = autoAnonymousLogin(); + + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousProvider"); + expect(behavior).not.toHaveProperty("recaptchaVerification"); + }); +}); + +describe("autoUpgradeAnonymousUsers", () => { + it("should return behaviors with correct structure", () => { + const behavior = autoUpgradeAnonymousUsers(); + + expect(behavior).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(behavior).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + expect(behavior.autoUpgradeAnonymousCredential).toHaveProperty("type", "callable"); + expect(behavior.autoUpgradeAnonymousProvider).toHaveProperty("type", "callable"); + expect(behavior.autoUpgradeAnonymousUserRedirectHandler).toHaveProperty("type", "redirect"); + + expect(typeof behavior.autoUpgradeAnonymousCredential.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousProvider.handler).toBe("function"); + expect(typeof behavior.autoUpgradeAnonymousUserRedirectHandler.handler).toBe("function"); + }); + + it("should not include other behaviors", () => { + const behavior = autoUpgradeAnonymousUsers(); + + expect(behavior).not.toHaveProperty("autoAnonymousLogin"); + expect(behavior).not.toHaveProperty("recaptchaVerification"); + }); +}); + +describe("recaptchaVerification", () => { + it("should return behavior with correct structure", () => { + const behavior = recaptchaVerification(); + + expect(behavior).toHaveProperty("recaptchaVerification"); + expect(behavior.recaptchaVerification).toHaveProperty("type", "callable"); + expect(behavior.recaptchaVerification).toHaveProperty("handler"); + expect(typeof behavior.recaptchaVerification.handler).toBe("function"); + }); + + it("should work with custom options", () => { + const customOptions = { + size: "normal" as const, + theme: "dark" as const, + tabindex: 5, + }; + + const behavior = recaptchaVerification(customOptions); + + expect(behavior).toHaveProperty("recaptchaVerification"); + expect(behavior.recaptchaVerification).toHaveProperty("type", "callable"); + expect(behavior.recaptchaVerification).toHaveProperty("handler"); + expect(typeof behavior.recaptchaVerification.handler).toBe("function"); + }); + + it("should not include other behaviors", () => { + const behavior = recaptchaVerification(); + + expect(behavior).not.toHaveProperty("autoAnonymousLogin"); + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousCredential"); + expect(behavior).not.toHaveProperty("autoUpgradeAnonymousProvider"); + }); +}); + +describe("defaultBehaviors", () => { + it("should include recaptchaVerification by default", () => { + expect(defaultBehaviors).toHaveProperty("recaptchaVerification"); + expect(defaultBehaviors.recaptchaVerification).toHaveProperty("type", "callable"); + expect(typeof defaultBehaviors.recaptchaVerification.handler).toBe("function"); + }); + + it("should not include other behaviors by default", () => { + expect(defaultBehaviors).not.toHaveProperty("autoAnonymousLogin"); + expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousCredential"); + expect(defaultBehaviors).not.toHaveProperty("autoUpgradeAnonymousProvider"); + }); +}); diff --git a/packages/core/src/behaviors/index.ts b/packages/core/src/behaviors/index.ts new file mode 100644 index 00000000..8b491582 --- /dev/null +++ b/packages/core/src/behaviors/index.ts @@ -0,0 +1,71 @@ +import type { FirebaseUIConfiguration } from "~/config"; +import type { RecaptchaVerifier } from "firebase/auth"; +import * as anonymousUpgradeHandlers from "./anonymous-upgrade"; +import * as autoAnonymousLoginHandlers from "./auto-anonymous-login"; +import * as recaptchaHandlers from "./recaptcha"; +import { + callableBehavior, + initBehavior, + redirectBehavior, + type CallableBehavior, + type InitBehavior, + type RedirectBehavior, +} from "./utils"; + +type Registry = { + autoAnonymousLogin: InitBehavior; + autoUpgradeAnonymousCredential: CallableBehavior< + typeof anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler + >; + autoUpgradeAnonymousProvider: CallableBehavior; + autoUpgradeAnonymousUserRedirectHandler: RedirectBehavior< + typeof anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler + >; + recaptchaVerification: CallableBehavior<(ui: FirebaseUIConfiguration, element: HTMLElement) => RecaptchaVerifier>; +}; + +export type Behavior = Pick; + +export function autoAnonymousLogin(): Behavior<"autoAnonymousLogin"> { + return { + autoAnonymousLogin: initBehavior(autoAnonymousLoginHandlers.autoAnonymousLoginHandler), + }; +} + +export function autoUpgradeAnonymousUsers(): Behavior< + "autoUpgradeAnonymousCredential" | "autoUpgradeAnonymousProvider" | "autoUpgradeAnonymousUserRedirectHandler" +> { + return { + autoUpgradeAnonymousCredential: callableBehavior(anonymousUpgradeHandlers.autoUpgradeAnonymousCredentialHandler), + autoUpgradeAnonymousProvider: callableBehavior(anonymousUpgradeHandlers.autoUpgradeAnonymousProviderHandler), + autoUpgradeAnonymousUserRedirectHandler: redirectBehavior( + anonymousUpgradeHandlers.autoUpgradeAnonymousUserRedirectHandler + ), + }; +} + +export type RecaptchaVerificationOptions = recaptchaHandlers.RecaptchaVerificationOptions; + +export function recaptchaVerification(options?: RecaptchaVerificationOptions): Behavior<"recaptchaVerification"> { + return { + recaptchaVerification: callableBehavior((ui, element) => + recaptchaHandlers.recaptchaVerificationHandler(ui, element, options) + ), + }; +} + +export function hasBehavior(ui: FirebaseUIConfiguration, key: T): boolean { + return !!ui.behaviors[key]; +} + +export function getBehavior(ui: FirebaseUIConfiguration, key: T): Registry[T]["handler"] { + if (!hasBehavior(ui, key)) { + throw new Error(`Behavior ${key} not found`); + } + + return (ui.behaviors[key] as Registry[T]).handler; +} + +export const defaultBehaviors: Behavior<"recaptchaVerification"> = { + ...recaptchaVerification(), +}; diff --git a/packages/core/src/behaviors/recaptcha.test.ts b/packages/core/src/behaviors/recaptcha.test.ts new file mode 100644 index 00000000..72b873de --- /dev/null +++ b/packages/core/src/behaviors/recaptcha.test.ts @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { RecaptchaVerifier } from "firebase/auth"; +import { recaptchaVerificationHandler, type RecaptchaVerificationOptions } from "./recaptcha"; +import type { FirebaseUIConfiguration } from "~/config"; +import { createMockUI } from "~/tests/utils"; + +vi.mock("firebase/auth", () => ({ + RecaptchaVerifier: vi.fn().mockImplementation(() => {}), +})); + +describe("Recaptcha Verification Handler", () => { + let mockElement: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + mockElement = document.createElement("div"); + }); + + describe("recaptchaVerificationHandler", () => { + it("should create RecaptchaVerifier with default options", () => { + const mockUI = createMockUI(); + const result = recaptchaVerificationHandler(mockUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBeDefined(); + }); + + it("should create RecaptchaVerifier with custom options", () => { + const mockUI = createMockUI(); + const customOptions: RecaptchaVerificationOptions = { + size: "normal", + theme: "dark", + tabindex: 5, + }; + + const result = recaptchaVerificationHandler(mockUI, mockElement, customOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "normal", + theme: "dark", + tabindex: 5, + }); + expect(result).toBeDefined(); + }); + + it("should handle partial options", () => { + const mockUI = createMockUI(); + const partialOptions: RecaptchaVerificationOptions = { + size: "compact", + // theme and tabindex should use defaults + }; + + const result = recaptchaVerificationHandler(mockUI, mockElement, partialOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "compact", + theme: "light", // default + tabindex: 0, // default + }); + expect(result).toBeDefined(); + }); + + it("should handle undefined options", () => { + const mockUI = createMockUI(); + const result = recaptchaVerificationHandler(mockUI, mockElement, undefined); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBeDefined(); + }); + + it("should pass correct auth instance", () => { + const mockUI = createMockUI(); + const customAuth = { uid: "test-uid" } as any; + const customUI = { auth: customAuth } as FirebaseUIConfiguration; + + recaptchaVerificationHandler(customUI, mockElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + customAuth, + mockElement, + expect.any(Object) + ); + }); + + it("should pass correct element", () => { + const mockUI = createMockUI(); + const customElement = document.createElement("button"); + + recaptchaVerificationHandler(mockUI, customElement); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + customElement, + expect.any(Object) + ); + }); + }); + + describe("RecaptchaVerificationOptions", () => { + it("should accept all valid size options", () => { + const mockUI = createMockUI(); + const sizes: Array = ["normal", "invisible", "compact"]; + + sizes.forEach(size => { + const options: RecaptchaVerificationOptions = { size }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + mockElement, + expect.objectContaining({ size }) + ); + expect(result).toBeDefined(); + }); + }); + + it("should accept all valid theme options", () => { + const mockUI = createMockUI(); + const themes: Array = ["light", "dark"]; + + themes.forEach(theme => { + const options: RecaptchaVerificationOptions = { theme }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + mockElement, + expect.objectContaining({ theme }) + ); + expect(result).toBeDefined(); + }); + }); + + it("should accept numeric tabindex", () => { + const mockUI = createMockUI(); + const options: RecaptchaVerificationOptions = { tabindex: 10 }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + mockElement, + expect.objectContaining({ tabindex: 10 }) + ); + expect(result).toBeDefined(); + }); + + it("should accept zero tabindex", () => { + const mockUI = createMockUI(); + const options: RecaptchaVerificationOptions = { tabindex: 0 }; + const result = recaptchaVerificationHandler(mockUI, mockElement, options); + + expect(RecaptchaVerifier).toHaveBeenCalledWith( + mockUI.auth, + mockElement, + expect.objectContaining({ tabindex: 0 }) + ); + expect(result).toBeDefined(); + }); + }); + + describe("Integration scenarios", () => { + it("should work with all options combined", () => { + const mockUI = createMockUI(); + const allOptions: RecaptchaVerificationOptions = { + size: "normal", + theme: "dark", + tabindex: 3, + }; + + const result = recaptchaVerificationHandler(mockUI, mockElement, allOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "normal", + theme: "dark", + tabindex: 3, + }); + expect(result).toBeDefined(); + }); + + it("should handle empty options object", () => { + const mockUI = createMockUI(); + const emptyOptions: RecaptchaVerificationOptions = {}; + const result = recaptchaVerificationHandler(mockUI, mockElement, emptyOptions); + + expect(RecaptchaVerifier).toHaveBeenCalledWith(mockUI.auth, mockElement, { + size: "invisible", + theme: "light", + tabindex: 0, + }); + expect(result).toBeDefined(); + }); + + it("should return the same instance on multiple calls with same parameters", () => { + const mockUI = createMockUI(); + const options: RecaptchaVerificationOptions = { size: "compact" }; + + const result1 = recaptchaVerificationHandler(mockUI, mockElement, options); + const result2 = recaptchaVerificationHandler(mockUI, mockElement, options); + + // Each call should create a new RecaptchaVerifier instance + expect(RecaptchaVerifier).toHaveBeenCalledTimes(2); + expect(result1).toBeDefined(); + expect(result2).toBeDefined(); + }); + }); +}); diff --git a/packages/core/src/behaviors/recaptcha.ts b/packages/core/src/behaviors/recaptcha.ts new file mode 100644 index 00000000..70a24b7a --- /dev/null +++ b/packages/core/src/behaviors/recaptcha.ts @@ -0,0 +1,20 @@ +import { RecaptchaVerifier } from "firebase/auth"; +import { FirebaseUIConfiguration } from "~/config"; + +export type RecaptchaVerificationOptions = { + size?: "normal" | "invisible" | "compact"; + theme?: "light" | "dark"; + tabindex?: number; +}; + +export const recaptchaVerificationHandler = ( + ui: FirebaseUIConfiguration, + element: HTMLElement, + options?: RecaptchaVerificationOptions +) => { + return new RecaptchaVerifier(ui.auth, element, { + size: options?.size ?? "invisible", + theme: options?.theme ?? "light", + tabindex: options?.tabindex ?? 0, + }); +}; diff --git a/packages/core/src/behaviors/utils.test.ts b/packages/core/src/behaviors/utils.test.ts new file mode 100644 index 00000000..c574a712 --- /dev/null +++ b/packages/core/src/behaviors/utils.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + callableBehavior, + redirectBehavior, + initBehavior, + type CallableBehavior, + type RedirectBehavior, + type InitBehavior, + type CallableHandler, + type RedirectHandler, + type InitHandler +} from "./utils"; +import type { UserCredential } from "firebase/auth"; +import type { FirebaseUIConfiguration } from "~/config"; + +describe("Behaviors Utils", () => { + describe("callableBehavior", () => { + it("should return a callable behavior with correct type", () => { + const handler = vi.fn(); + const behavior = callableBehavior(handler); + + expect(behavior).toEqual({ + type: "callable", + handler + }); + expect(behavior.type).toBe("callable"); + expect(behavior.handler).toBe(handler); + }); + + it("should preserve handler function type", () => { + const handler: CallableHandler = vi.fn(); + const behavior = callableBehavior(handler); + + expect(behavior.handler).toBe(handler); + }); + + it("should work with different handler signatures", () => { + const handler1 = vi.fn((arg1: string) => arg1); + const handler2 = vi.fn((arg1: number, arg2: boolean) => ({ arg1, arg2 })); + + const behavior1 = callableBehavior(handler1); + const behavior2 = callableBehavior(handler2); + + expect(behavior1.type).toBe("callable"); + expect(behavior2.type).toBe("callable"); + expect(behavior1.handler).toBe(handler1); + expect(behavior2.handler).toBe(handler2); + }); + + it("should preserve specific function types and provide type inference", () => { + // Test that the handler function preserves its specific signature + const specificHandler = (ui: FirebaseUIConfiguration, element: HTMLElement, options?: { size?: string }) => { + return { ui, element, options }; + }; + + const behavior = callableBehavior(specificHandler); + + // The handler should maintain its specific signature + expect(behavior.handler).toBe(specificHandler); + expect(behavior.type).toBe("callable"); + + // Test that we can call the handler with the correct arguments + const mockUI = {} as FirebaseUIConfiguration; + const mockElement = document.createElement('div'); + const result = behavior.handler(mockUI, mockElement, { size: 'normal' }); + + expect(result).toEqual({ + ui: mockUI, + element: mockElement, + options: { size: 'normal' } + }); + }); + }); + + describe("redirectBehavior", () => { + it("should return a redirect behavior with correct type", () => { + const handler = vi.fn(); + const behavior = redirectBehavior(handler); + + expect(behavior).toEqual({ + type: "redirect", + handler + }); + expect(behavior.type).toBe("redirect"); + expect(behavior.handler).toBe(handler); + }); + + it("should preserve handler function type", () => { + const handler: RedirectHandler = vi.fn(); + const behavior = redirectBehavior(handler); + + expect(behavior.handler).toBe(handler); + }); + + it("should work with async handlers", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const behavior = redirectBehavior(handler); + + expect(behavior.type).toBe("redirect"); + expect(behavior.handler).toBe(handler); + + const mockUI = {} as FirebaseUIConfiguration; + const mockResult = {} as UserCredential; + + await behavior.handler(mockUI, mockResult); + expect(handler).toHaveBeenCalledWith(mockUI, mockResult); + }); + }); + + describe("initBehavior", () => { + it("should return an init behavior with correct type", () => { + const handler = vi.fn(); + const behavior = initBehavior(handler); + + expect(behavior).toEqual({ + type: "init", + handler + }); + expect(behavior.type).toBe("init"); + expect(behavior.handler).toBe(handler); + }); + + it("should preserve handler function type", () => { + const handler: InitHandler = vi.fn(); + const behavior = initBehavior(handler); + + expect(behavior.handler).toBe(handler); + }); + + it("should work with async handlers", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const behavior = initBehavior(handler); + + expect(behavior.type).toBe("init"); + expect(behavior.handler).toBe(handler); + + const mockUI = {} as FirebaseUIConfiguration; + + await behavior.handler(mockUI); + expect(handler).toHaveBeenCalledWith(mockUI); + }); + + it("should work with sync handlers", () => { + const handler = vi.fn(); + const behavior = initBehavior(handler); + + expect(behavior.type).toBe("init"); + expect(behavior.handler).toBe(handler); + + const mockUI = {} as FirebaseUIConfiguration; + + behavior.handler(mockUI); + expect(handler).toHaveBeenCalledWith(mockUI); + }); + }); + + describe("Behavior Types", () => { + it("should have correct type structure for CallableBehavior", () => { + const handler = vi.fn(); + const behavior: CallableBehavior = callableBehavior(handler); + + expect(behavior).toHaveProperty("type", "callable"); + expect(behavior).toHaveProperty("handler"); + expect(typeof behavior.handler).toBe("function"); + }); + + it("should have correct type structure for RedirectBehavior", () => { + const handler = vi.fn(); + const behavior: RedirectBehavior = redirectBehavior(handler); + + expect(behavior).toHaveProperty("type", "redirect"); + expect(behavior).toHaveProperty("handler"); + expect(typeof behavior.handler).toBe("function"); + }); + + it("should have correct type structure for InitBehavior", () => { + const handler = vi.fn(); + const behavior: InitBehavior = initBehavior(handler); + + expect(behavior).toHaveProperty("type", "init"); + expect(behavior).toHaveProperty("handler"); + expect(typeof behavior.handler).toBe("function"); + }); + }); + + describe("Handler Type Compatibility", () => { + it("should accept handlers with correct signatures", () => { + const callableHandler: CallableHandler = vi.fn(); + expect(() => callableBehavior(callableHandler)).not.toThrow(); + + const redirectHandler: RedirectHandler = vi.fn(); + expect(() => redirectBehavior(redirectHandler)).not.toThrow(); + + const initHandler: InitHandler = vi.fn(); + expect(() => initBehavior(initHandler)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/src/behaviors/utils.ts b/packages/core/src/behaviors/utils.ts new file mode 100644 index 00000000..13a87cd7 --- /dev/null +++ b/packages/core/src/behaviors/utils.ts @@ -0,0 +1,33 @@ +import type { UserCredential } from "firebase/auth"; +import type { FirebaseUIConfiguration } from "~/config"; + +export type CallableHandler any = (...args: any[]) => any> = T; +export type InitHandler = (ui: FirebaseUIConfiguration) => Promise | void; +export type RedirectHandler = (ui: FirebaseUIConfiguration, result: UserCredential | null) => Promise | void; + +export type CallableBehavior = { + type: "callable"; + handler: T; +}; + +export type RedirectBehavior = { + type: "redirect"; + handler: T; +}; + +export type InitBehavior = { + type: "init"; + handler: T; +}; + +export function callableBehavior(handler: T): CallableBehavior { + return { type: "callable" as const, handler }; +} + +export function redirectBehavior(handler: T): RedirectBehavior { + return { type: "redirect" as const, handler }; +} + +export function initBehavior(handler: T): InitBehavior { + return { type: "init" as const, handler }; +} diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts index 7a1b9438..613f886c 100644 --- a/packages/core/src/config.test.ts +++ b/packages/core/src/config.test.ts @@ -1,12 +1,26 @@ import { FirebaseApp } from "firebase/app"; import { Auth } from "firebase/auth"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { initializeUI } from "./config"; import { enUs, registerLocale } from "@firebase-ui/translations"; -import { autoUpgradeAnonymousUsers, defaultBehaviors } from "./behaviors"; +import { autoUpgradeAnonymousUsers, autoAnonymousLogin, defaultBehaviors } from "./behaviors"; -describe('initializeUI', () => { - it('should return a valid deep store with default values', () => { +// Mock Firebase Auth +vi.mock("firebase/auth", () => ({ + getAuth: vi.fn(), + getRedirectResult: vi.fn().mockResolvedValue(null), + signInAnonymously: vi.fn(), + linkWithCredential: vi.fn(), + linkWithRedirect: vi.fn(), + RecaptchaVerifier: vi.fn(), +})); + +describe("initializeUI", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a valid deep store with default values", () => { const config = { app: {} as FirebaseApp, auth: {} as Auth, @@ -17,12 +31,13 @@ describe('initializeUI', () => { expect(ui.get()).toBeDefined(); expect(ui.get().app).toBe(config.app); expect(ui.get().auth).toBe(config.auth); - expect(ui.get().behaviors).toEqual(defaultBehaviors); + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); expect(ui.get().state).toEqual("idle"); expect(ui.get().locale).toEqual(enUs); }); - it('should merge behaviors with defaultBehaviors', () => { + it("should merge behaviors with defaultBehaviors", () => { const config = { app: {} as FirebaseApp, auth: {} as Auth, @@ -32,16 +47,19 @@ describe('initializeUI', () => { const ui = initializeUI(config); expect(ui).toBeDefined(); expect(ui.get()).toBeDefined(); - - // Should have default behaviors + + // Default behaviors expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); - - // Should have custom behaviors + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("handler"); + + // Custom behaviors expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); }); - it('should set state and update state when called', () => { + it("should set state and update state when called", () => { const config = { app: {} as FirebaseApp, auth: {} as Auth, @@ -55,40 +73,43 @@ describe('initializeUI', () => { expect(ui.get().state).toEqual("idle"); }); - it('should set state and update locale when called', () => { - const testLocale1 = registerLocale('test1', {}); - const testLocale2 = registerLocale('test2', {}); - + it("should set state and update locale when called", () => { + const testLocale1 = registerLocale("test1", {}); + const testLocale2 = registerLocale("test2", {}); + const config = { app: {} as FirebaseApp, auth: {} as Auth, }; const ui = initializeUI(config); - expect(ui.get().locale.locale).toEqual('en-US'); + expect(ui.get().locale.locale).toEqual("en-US"); ui.get().setLocale(testLocale1); - expect(ui.get().locale.locale).toEqual('test1'); + expect(ui.get().locale.locale).toEqual("test1"); ui.get().setLocale(testLocale2); - expect(ui.get().locale.locale).toEqual('test2'); + expect(ui.get().locale.locale).toEqual("test2"); }); - it('should include defaultBehaviors even when no custom behaviors are provided', () => { + it("should include defaultBehaviors even when no custom behaviors are provided", () => { const config = { app: {} as FirebaseApp, auth: {} as Auth, }; const ui = initializeUI(config); - expect(ui.get().behaviors).toEqual(defaultBehaviors); expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); }); - it('should allow overriding default behaviors', () => { + it("should allow overriding default behaviors", () => { const customRecaptchaVerification = { - recaptchaVerification: () => { - // Custom implementation - return {} as any; - } + recaptchaVerification: { + type: "callable" as const, + handler: vi.fn(() => { + // Custom implementation + return {} as any; + }), + }, }; const config = { @@ -99,16 +120,22 @@ describe('initializeUI', () => { const ui = initializeUI(config); expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); - expect(ui.get().behaviors.recaptchaVerification).toBe(customRecaptchaVerification.recaptchaVerification); + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + expect(ui.get().behaviors.recaptchaVerification.handler).toBe( + customRecaptchaVerification.recaptchaVerification.handler + ); }); - it('should merge multiple behavior objects correctly', () => { + it("should merge multiple behavior objects correctly", () => { const behavior1 = autoUpgradeAnonymousUsers(); const behavior2 = { - recaptchaVerification: () => { - // Custom recaptcha implementation - return {} as any; - } + recaptchaVerification: { + type: "callable" as const, + handler: vi.fn(() => { + // Custom recaptcha implementation + return {} as any; + }), + }, }; const config = { @@ -118,16 +145,190 @@ describe('initializeUI', () => { }; const ui = initializeUI(config); - - // Should have default behaviors + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); - - // Should have autoUpgrade behaviors + expect(ui.get().behaviors.recaptchaVerification).toHaveProperty("type", "callable"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); - - // Should have custom recaptcha implementation - expect(ui.get().behaviors.recaptchaVerification).toBe(behavior2.recaptchaVerification); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + expect(ui.get().behaviors.recaptchaVerification.handler).toBe(behavior2.recaptchaVerification.handler); }); -}); + it("should handle init behaviors correctly", () => { + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [autoAnonymousLogin()], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("autoAnonymousLogin"); + }); + + it("should handle redirect behaviors correctly", () => { + const mockAuth = { + currentUser: null, + } as any; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [autoUpgradeAnonymousUsers()], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + }); + + it("should handle mixed behavior types", () => { + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [autoAnonymousLogin(), autoUpgradeAnonymousUsers()], + }; + + const ui = initializeUI(config); + + expect(ui.get().behaviors).toHaveProperty("autoAnonymousLogin"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousUserRedirectHandler"); + + // Default.. + expect(ui.get().behaviors).toHaveProperty("recaptchaVerification"); + }); + + it("should execute init behaviors when window is defined", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const mockInitHandler = vi.fn().mockResolvedValue(undefined); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customInit: { + type: "init" as const, + handler: mockInitHandler, + }, + }, + ], + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the noop promises are resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAuth.authStateReady).toHaveBeenCalledTimes(1); + expect(mockInitHandler).toHaveBeenCalledTimes(1); + expect(mockInitHandler).toHaveBeenCalledWith(ui.get()); + + delete (global as any).window; + }); + + it("should execute redirect behaviors when window is defined", async () => { + Object.defineProperty(global, "window", { + value: {}, + writable: true, + configurable: true, + }); + + const mockAuth = { + currentUser: null, + } as any; + + const mockRedirectHandler = vi.fn().mockResolvedValue(undefined); + const mockRedirectResult = { user: { uid: "test-123" } }; + + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + vi.mocked(getRedirectResult).mockResolvedValue(mockRedirectResult as any); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customRedirect: { + type: "redirect" as const, + handler: mockRedirectHandler, + }, + }, + ], + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the noop promises are resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(getRedirectResult).toHaveBeenCalledTimes(1); + expect(getRedirectResult).toHaveBeenCalledWith(mockAuth); + expect(mockRedirectHandler).toHaveBeenCalledTimes(1); + expect(mockRedirectHandler).toHaveBeenCalledWith(ui.get(), mockRedirectResult); + + delete (global as any).window; + }); + + it("should not execute behaviors when window is undefined", async () => { + const mockAuth = { + authStateReady: vi.fn().mockResolvedValue(undefined), + currentUser: null, + } as any; + + const { getRedirectResult } = await import("firebase/auth"); + vi.mocked(getRedirectResult).mockClear(); + + const config = { + app: {} as FirebaseApp, + auth: mockAuth, + behaviors: [ + { + customInit: { + type: "init" as const, + handler: vi.fn(), + }, + customRedirect: { + type: "redirect" as const, + handler: vi.fn(), + }, + }, + ], + }; + + const ui = initializeUI(config); + + // Process next tick to make sure the noop promises are resolved + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockAuth.authStateReady).not.toHaveBeenCalled(); + expect(getRedirectResult).not.toHaveBeenCalled(); + + expect(ui.get().state).toBe("idle"); + }); +}); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 9d1f3cb2..75574995 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -16,23 +16,17 @@ import { enUs, RegisteredLocale } from "@firebase-ui/translations"; import type { FirebaseApp } from "firebase/app"; -import { Auth, getAuth } from "firebase/auth"; +import { Auth, getAuth, getRedirectResult } from "firebase/auth"; import { deepMap, DeepMapStore, map } from "nanostores"; -import { - Behavior, - type BehaviorHandlers, - type BehaviorKey, - defaultBehaviors, - getBehavior, - hasBehavior, -} from "./behaviors"; +import { Behavior, defaultBehaviors } from "./behaviors"; +import type { InitBehavior, RedirectBehavior } from "./behaviors/utils"; import { FirebaseUIState } from "./state"; export type FirebaseUIConfigurationOptions = { app: FirebaseApp; auth?: Auth; locale?: RegisteredLocale; - behaviors?: Partial>[]; + behaviors?: Behavior[]; }; export type FirebaseUIConfiguration = { @@ -42,7 +36,7 @@ export type FirebaseUIConfiguration = { state: FirebaseUIState; setState: (state: FirebaseUIState) => void; locale: RegisteredLocale; - behaviors: Partial>; + behaviors: Behavior; }; export const $config = map>>({}); @@ -51,15 +45,12 @@ export type FirebaseUI = DeepMapStore; export function initializeUI(config: FirebaseUIConfigurationOptions, name: string = "[DEFAULT]"): FirebaseUI { // Reduce the behaviors to a single object. - const behaviors = config.behaviors?.reduce>>( - (acc, behavior) => { - return { - ...acc, - ...behavior, - }; - }, - defaultBehaviors - ); + const behaviors = config.behaviors?.reduce((acc, behavior) => { + return { + ...acc, + ...behavior, + }; + }, defaultBehaviors as Behavior); $config.setKey( name, @@ -71,23 +62,48 @@ export function initializeUI(config: FirebaseUIConfigurationOptions, name: strin const current = $config.get()[name]!; current.setKey(`locale`, locale); }, - state: behaviors?.autoAnonymousLogin ? "loading" : "idle", + state: "idle", setState: (state: FirebaseUIState) => { const current = $config.get()[name]!; current.setKey(`state`, state); }, - behaviors: behaviors ?? defaultBehaviors, + // Since we've got config.behaviors?.reduce above, we need to default to defaultBehaviors + // if no behaviors are provided, as they wont be in the reducer. + behaviors: behaviors ?? (defaultBehaviors as Behavior), }) ); - const ui = $config.get()[name]!; + const store = $config.get()[name]!; + const ui = store.get(); + + // If we're client-side, execute the init and redirect behaviors. + if (typeof window !== "undefined") { + const initBehaviors: InitBehavior[] = []; + const redirectBehaviors: RedirectBehavior[] = []; + + for (const behavior of Object.values(ui.behaviors)) { + if (behavior.type === "redirect") { + redirectBehaviors.push(behavior); + } else if (behavior.type === "init") { + initBehaviors.push(behavior); + } + } + + if (initBehaviors.length > 0) { + store.setKey("state", "loading"); + ui.auth.authStateReady().then(() => { + Promise.all(initBehaviors.map((behavior) => behavior.handler(ui))).then(() => { + store.setKey("state", "idle"); + }); + }); + } - // TODO(ehesp): Should this belong here - if not, where should it be? - if (hasBehavior(ui.get(), "autoAnonymousLogin")) { - getBehavior(ui.get(), "autoAnonymousLogin")(ui.get()); - } else { - ui.setKey("state", "idle"); + if (redirectBehaviors.length > 0) { + getRedirectResult(ui.auth).then((result) => { + Promise.all(redirectBehaviors.map((behavior) => behavior.handler(ui, result))); + }); + } } - return ui; + return store; } diff --git a/packages/core/src/country-data.test.ts b/packages/core/src/country-data.test.ts index 6890b5ec..260244e8 100644 --- a/packages/core/src/country-data.test.ts +++ b/packages/core/src/country-data.test.ts @@ -103,20 +103,29 @@ describe("CountryData", () => { }); it("should handle case insensitive country codes", () => { + // @ts-expect-error - we want to test case insensitivity expect(getCountryByCode("us")).toBeDefined(); + // @ts-expect-error - we want to test case insensitivity expect(getCountryByCode("Us")).toBeDefined(); + // @ts-expect-error - we want to test case insensitivity expect(getCountryByCode("uS")).toBeDefined(); + expect(getCountryByCode("US")).toBeDefined(); - + // @ts-expect-error - we want to test case insensitivity const result = getCountryByCode("us"); expect(result?.code).toBe("US"); }); it("should return undefined for invalid country code", () => { + // @ts-expect-error - we want to test invalid country code expect(getCountryByCode("XX")).toBeUndefined(); + // @ts-expect-error - we want to test invalid country code expect(getCountryByCode("INVALID")).toBeUndefined(); + // @ts-expect-error - we want to test case insensitivity expect(getCountryByCode("")).toBeUndefined(); + // @ts-expect-error - we want to test invalid country code expect(getCountryByCode("U")).toBeUndefined(); + // @ts-expect-error - we want to test invalid country code expect(getCountryByCode("USA")).toBeUndefined(); }); @@ -127,41 +136,41 @@ describe("CountryData", () => { describe("formatPhoneNumberWithCountry", () => { it("should format phone number with country dial code", () => { - expect(formatPhoneNumberWithCountry("1234567890", "+1")).toBe("+11234567890"); - expect(formatPhoneNumberWithCountry("1234567890", "+44")).toBe("+441234567890"); - expect(formatPhoneNumberWithCountry("1234567890", "+81")).toBe("+811234567890"); + expect(formatPhoneNumberWithCountry("1234567890", "US")).toBe("+11234567890"); + expect(formatPhoneNumberWithCountry("1234567890", "GB")).toBe("+441234567890"); + expect(formatPhoneNumberWithCountry("1234567890", "JP")).toBe("+811234567890"); }); it("should handle phone numbers with spaces", () => { - expect(formatPhoneNumberWithCountry("123 456 7890", "+1")).toBe("+1123 456 7890"); - expect(formatPhoneNumberWithCountry(" 1234567890 ", "+1")).toBe("+11234567890"); + expect(formatPhoneNumberWithCountry("123 456 7890", "US")).toBe("+1123 456 7890"); + expect(formatPhoneNumberWithCountry(" 1234567890 ", "US")).toBe("+11234567890"); }); it("should handle empty phone numbers", () => { - expect(formatPhoneNumberWithCountry("", "+1")).toBe("+1"); - expect(formatPhoneNumberWithCountry(" ", "+1")).toBe("+1"); + expect(formatPhoneNumberWithCountry("", "US")).toBe("+1"); + expect(formatPhoneNumberWithCountry(" ", "US")).toBe("+1"); }); it("should handle phone numbers with dashes and parentheses", () => { - expect(formatPhoneNumberWithCountry("(123) 456-7890", "+1")).toBe("+1(123) 456-7890"); - expect(formatPhoneNumberWithCountry("123-456-7890", "+1")).toBe("+1123-456-7890"); + expect(formatPhoneNumberWithCountry("(123) 456-7890", "US")).toBe("+1(123) 456-7890"); + expect(formatPhoneNumberWithCountry("123-456-7890", "US")).toBe("+1123-456-7890"); }); it("should handle international numbers with existing dial codes", () => { - expect(formatPhoneNumberWithCountry("+44 20 7946 0958", "+1")).toBe("+120 7946 0958"); - expect(formatPhoneNumberWithCountry("+81 3 1234 5678", "+44")).toBe("+443 1234 5678"); + expect(formatPhoneNumberWithCountry("+44 20 7946 0958", "US")).toBe("+120 7946 0958"); + expect(formatPhoneNumberWithCountry("+81 3 1234 5678", "GB")).toBe("+443 1234 5678"); }); it("should handle edge cases", () => { - expect(formatPhoneNumberWithCountry("1234567890", "+1234")).toBe("+12341234567890"); - expect(formatPhoneNumberWithCountry("1234567890", "+7")).toBe("+71234567890"); + expect(formatPhoneNumberWithCountry("1234567890", "MC")).toBe("+3771234567890"); + expect(formatPhoneNumberWithCountry("1234567890", "RU")).toBe("+71234567890"); }); }); describe("Edge cases and error handling", () => { it("should handle very long phone numbers", () => { const longNumber = "12345678901234567890"; - expect(formatPhoneNumberWithCountry(longNumber, "+1")).toBe("+112345678901234567890"); + expect(formatPhoneNumberWithCountry(longNumber, "US")).toBe("+112345678901234567890"); }); it("should handle countries with multiple dial codes", () => {