diff --git a/packages/core/package.json b/packages/core/package.json index 74dbecbe..9f5768df 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,10 +27,10 @@ "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"", "clean": "rimraf dist", - "test:unit": "vitest run tests/unit", - "test:unit:watch": "vitest tests/unit", - "test:integration": "vitest run tests/integration", - "test:integration:watch": "vitest tests/integration", + "test:unit": "vitest run src", + "test:unit:watch": "vitest tests", + "test:integration": "vitest run tests", + "test:integration:watch": "vitest integration", "test": "vitest run", "publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'", "release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/" @@ -60,6 +60,7 @@ "tsup": "catalog:", "typescript": "catalog:", "vite": "catalog:", + "vitest-tsconfig-paths": "catalog:", "vitest": "catalog:" } } diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts new file mode 100644 index 00000000..53649a10 --- /dev/null +++ b/packages/core/src/auth.test.ts @@ -0,0 +1,802 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { signInWithEmailAndPassword, createUserWithEmailAndPassword, signInWithPhoneNumber, confirmPhoneNumber, sendPasswordResetEmail, sendSignInLinkToEmail, signInWithEmailLink, signInAnonymously, signInWithProvider, completeEmailLinkSignIn, } from "./auth"; +import type { FirebaseUIConfiguration } from "./config"; + +// Mock the external dependencies +vi.mock("firebase/auth", () => ({ + signInWithCredential: vi.fn(), + createUserWithEmailAndPassword: vi.fn(), + signInWithPhoneNumber: vi.fn(), + sendPasswordResetEmail: vi.fn(), + sendSignInLinkToEmail: vi.fn(), + signInAnonymously: vi.fn(), + signInWithRedirect: vi.fn(), + isSignInWithEmailLink: vi.fn(), + EmailAuthProvider: { + credential: vi.fn(), + credentialWithLink: vi.fn(), + }, + PhoneAuthProvider: { + credential: vi.fn(), + }, + linkWithCredential: vi.fn(), +})); + +vi.mock("./behaviors", () => ({ + hasBehavior: vi.fn(), + getBehavior: vi.fn(), +})); + +vi.mock("./errors", () => ({ + handleFirebaseError: vi.fn(), +})); + +// Import the mocked functions +import { signInWithCredential, EmailAuthProvider, PhoneAuthProvider, createUserWithEmailAndPassword as _createUserWithEmailAndPassword, signInWithPhoneNumber as _signInWithPhoneNumber, sendPasswordResetEmail as _sendPasswordResetEmail, sendSignInLinkToEmail as _sendSignInLinkToEmail, signInAnonymously as _signInAnonymously, signInWithRedirect, isSignInWithEmailLink as _isSignInWithEmailLink, UserCredential, Auth, ConfirmationResult, AuthProvider } from "firebase/auth"; +import { hasBehavior, getBehavior } from "./behaviors"; +import { handleFirebaseError } from "./errors"; +import { FirebaseError } from "firebase/app"; + +import { createMockUI } from "~/tests/utils"; + +// TODO(ehesp): Add tests for handlePendingCredential. + +describe("signInWithEmailAndPassword", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "password" } as UserCredential); + + const result = await signInWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + // Calls pending pre-signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(signInWithCredential).toHaveBeenCalledTimes(1); + + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("password"); + }); + + it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + + const result = await signInWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("password"); + + // Only the `finally` block is called here. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + }); + + it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + + await signInWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(signInWithCredential).toHaveBeenCalledTimes(1); + + // Calls pending pre-signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it('should call handleFirebaseError if an error is thrown', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError('foo/bar', 'Foo bar'); + + vi.mocked(signInWithCredential).mockRejectedValue(error); + + await signInWithEmailAndPassword(mockUI, email, password); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]); + }); +}); + +describe("createUserWithEmailAndPassword", () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call createUserWithEmailAndPassword with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential); + vi.mocked(_createUserWithEmailAndPassword).mockResolvedValue({ providerId: "password" } as UserCredential); + + const result = await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + // Calls pending pre-createUserWithEmailAndPassword call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); + expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); + + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("password"); + }); + + it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("password"); + + // Only the `finally` block is called here. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + }); + + it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + const credential = EmailAuthProvider.credential(email, password); + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await createUserWithEmailAndPassword(mockUI, email, password); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password); + expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1); + + // Calls pending pre-createUserWithEmailAndPassword call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it('should call handleFirebaseError if an error is thrown', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const password = "password123"; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError('foo/bar', 'Foo bar'); + + vi.mocked(_createUserWithEmailAndPassword).mockRejectedValue(error); + + await createUserWithEmailAndPassword(mockUI, email, password); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]); + }); +}); + +describe("signInWithPhoneNumber", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInWithPhoneNumber successfully", async () => { + const mockUI = createMockUI(); + const phoneNumber = "+1234567890"; + const mockRecaptchaVerifier = {} as any; + const mockConfirmationResult = { + verificationId: "test-verification-id", + confirm: vi.fn(), + } as any; + + vi.mocked(_signInWithPhoneNumber).mockResolvedValue(mockConfirmationResult); + + const result = await signInWithPhoneNumber(mockUI, phoneNumber, mockRecaptchaVerifier); + + // Verify state management + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + // Verify the Firebase function was called with correct parameters + expect(_signInWithPhoneNumber).toHaveBeenCalledWith(mockUI.auth, phoneNumber, mockRecaptchaVerifier); + expect(_signInWithPhoneNumber).toHaveBeenCalledTimes(1); + + // Verify the result + expect(result).toEqual(mockConfirmationResult); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const phoneNumber = "+1234567890"; + const mockRecaptchaVerifier = {} as any; + const error = new FirebaseError('auth/invalid-phone-number', 'Invalid phone number'); + + vi.mocked(_signInWithPhoneNumber).mockRejectedValue(error); + + await signInWithPhoneNumber(mockUI, phoneNumber, mockRecaptchaVerifier); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should handle recaptcha verification errors", async () => { + const mockUI = createMockUI(); + const phoneNumber = "+1234567890"; + const mockRecaptchaVerifier = {} as any; + const error = new Error("reCAPTCHA verification failed"); + + vi.mocked(_signInWithPhoneNumber).mockRejectedValue(error); + + await signInWithPhoneNumber(mockUI, phoneNumber, mockRecaptchaVerifier); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("confirmPhoneNumber", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInWithCredential with no behavior", async () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth + }); + const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); + + const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode); + + // Since currentUser is null, the behavior should not called. + expect(hasBehavior).toHaveBeenCalledTimes(0); + + // Calls pending pre-signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(signInWithCredential).toHaveBeenCalledTimes(1); + + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("phone"); + }); + + it("should call autoUpgradeAnonymousCredential behavior when user is anonymous", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth + }); + const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "phone" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("phone"); + + // Only the `finally` block is called here. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + }); + + it("should not call behavior when user is not anonymous", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: false } } as Auth + }); + const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); + + const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode); + + // Behavior should not be called when user is not anonymous + expect(hasBehavior).not.toHaveBeenCalled(); + + // Should proceed with normal sign-in flow + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(result.providerId).toBe("phone"); + }); + + it("should not call behavior when user is null", async () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth + }); + const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential); + + const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode); + + // Behavior should not be called when user is null + expect(hasBehavior).not.toHaveBeenCalled(); + + // Should proceed with normal sign-in flow + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + expect(result.providerId).toBe("phone"); + }); + + it("should fall back to normal sign-in when behavior returns undefined", async () => { + const mockUI = createMockUI({ + auth: { currentUser: { isAnonymous: true } } as Auth + }); + const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; + const verificationCode = "123456"; + + const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await confirmPhoneNumber(mockUI, confirmationResult, verificationCode); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(signInWithCredential).toHaveBeenCalledTimes(1); + + // Calls pending pre-signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI({ + auth: { currentUser: null } as Auth + }); + const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult; + const verificationCode = "123456"; + + const error = new FirebaseError('auth/invalid-verification-code', 'Invalid verification code'); + + vi.mocked(signInWithCredential).mockRejectedValue(error); + + await confirmPhoneNumber(mockUI, confirmationResult, verificationCode); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + +describe("sendPasswordResetEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call sendPasswordResetEmail successfully", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + + vi.mocked(_sendPasswordResetEmail).mockResolvedValue(undefined); + + await sendPasswordResetEmail(mockUI, email); + + // Verify state management + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + // Verify the Firebase function was called with correct parameters + expect(_sendPasswordResetEmail).toHaveBeenCalledWith(mockUI.auth, email); + expect(_sendPasswordResetEmail).toHaveBeenCalledTimes(1); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const error = new FirebaseError('auth/user-not-found', 'User not found'); + + vi.mocked(_sendPasswordResetEmail).mockRejectedValue(error); + + await sendPasswordResetEmail(mockUI, email); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); +}); + + describe("sendSignInLinkToEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock window.location.href + Object.defineProperty(window, 'location', { + value: { href: 'https://example.com' }, + writable: true + }); + }); + + afterEach(() => { + // Clean up localStorage after each test + window.localStorage.clear(); + }); + + it("should update state and call sendSignInLinkToEmail successfully", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + + await sendSignInLinkToEmail(mockUI, email); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + const expectedActionCodeSettings = { + url: 'https://example.com', + handleCodeInApp: true, + }; + expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); + expect(_sendSignInLinkToEmail).toHaveBeenCalledTimes(1); + + // Verify email is stored in localStorage + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const error = new FirebaseError('auth/invalid-email', 'Invalid email address'); + + vi.mocked(_sendSignInLinkToEmail).mockRejectedValue(error); + + await sendSignInLinkToEmail(mockUI, email); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + // Verify email is NOT stored in localStorage on error + expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); + }); + + it("should use current window location for action code settings", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + + Object.defineProperty(window, 'location', { + value: { href: 'https://myapp.com/auth' }, + writable: true + }); + + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + + await sendSignInLinkToEmail(mockUI, email); + + const expectedActionCodeSettings = { + url: 'https://myapp.com/auth', + handleCodeInApp: true, + }; + expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings); + }); + + it("should overwrite existing email in localStorage", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const existingEmail = "old@example.com"; + + window.localStorage.setItem("emailForSignIn", existingEmail); + + vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined); + + await sendSignInLinkToEmail(mockUI, email); + + expect(window.localStorage.getItem("emailForSignIn")).toBe(email); + }); + }); + + describe("signInWithEmailLink", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInWithCredential with no behavior", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "emailLink" } as UserCredential); + + const result = await signInWithEmailLink(mockUI, email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + // Calls pending pre-signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(signInWithCredential).toHaveBeenCalledTimes(1); + + // Assert that the result is a valid UserCredential. + expect(result.providerId).toBe("emailLink"); + }); + + it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue({ providerId: "emailLink" } as UserCredential); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + const result = await signInWithEmailLink(mockUI, email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + expect(result.providerId).toBe("emailLink"); + + // Only the `finally` block is called here. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]); + }); + + it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + const credential = EmailAuthProvider.credentialWithLink(email, link); + vi.mocked(hasBehavior).mockReturnValue(true); + vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await signInWithEmailLink(mockUI, email, link); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential"); + + expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential); + + expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential); + expect(signInWithCredential).toHaveBeenCalledTimes(1); + + // Calls pending pre-signInWithCredential call, then idle after. + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const email = "test@example.com"; + const link = "https://example.com/auth?oobCode=abc123"; + + vi.mocked(hasBehavior).mockReturnValue(false); + + const error = new FirebaseError('auth/invalid-action-code', 'Invalid action code'); + + vi.mocked(signInWithCredential).mockRejectedValue(error); + + await signInWithEmailLink(mockUI, email, link); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + }); + + describe("signInAnonymously", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should update state and call signInAnonymously successfully", async () => { + const mockUI = createMockUI(); + const mockUserCredential = { + user: { uid: "anonymous-uid", isAnonymous: true }, + providerId: "anonymous", + operationType: "signIn", + } as UserCredential; + + vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential); + + const result = await signInAnonymously(mockUI); + + // Verify state management + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + // Verify the Firebase function was called with correct parameters + expect(_signInAnonymously).toHaveBeenCalledWith(mockUI.auth); + expect(_signInAnonymously).toHaveBeenCalledTimes(1); + + // Verify the result + expect(result).toEqual(mockUserCredential); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const error = new FirebaseError('auth/operation-not-allowed', 'Anonymous sign-in is not enabled'); + + vi.mocked(_signInAnonymously).mockRejectedValue(error); + + await signInAnonymously(mockUI); + + // Verify error handling + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + // Verify state management still happens + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + }); + + describe("signInWithProvider", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should call signInWithRedirect with no behavior", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(signInWithRedirect).mockResolvedValue(undefined as never); + + await signInWithProvider(mockUI, provider); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(signInWithRedirect).toHaveBeenCalledWith(mockUI.auth, provider); + expect(signInWithRedirect).toHaveBeenCalledTimes(1); + }); + + it("should call autoUpgradeAnonymousProvider behavior if enabled", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockResolvedValue(undefined); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + vi.mocked(signInWithRedirect).mockResolvedValue(undefined as never); + + await signInWithProvider(mockUI, provider); + + expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider"); + expect(mockBehavior).toHaveBeenCalledWith(mockUI, provider); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + + expect(signInWithRedirect).toHaveBeenCalledWith(mockUI.auth, provider); + }); + + it("should call handleFirebaseError if an error is thrown", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const error = new FirebaseError('auth/operation-not-allowed', 'Google sign-in is not enabled'); + + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(signInWithRedirect).mockRejectedValue(error); + + await signInWithProvider(mockUI, provider); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + + it("should handle behavior errors", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const error = new Error("Behavior error"); + + vi.mocked(hasBehavior).mockReturnValue(true); + const mockBehavior = vi.fn().mockRejectedValue(error); + vi.mocked(getBehavior).mockReturnValue(mockBehavior); + + await signInWithProvider(mockUI, provider); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]); + + expect(signInWithRedirect).not.toHaveBeenCalled(); + }); + + it("should handle errors from signInWithRedirect", async () => { + const mockUI = createMockUI(); + const provider = { providerId: "google.com" } as AuthProvider; + const error = new FirebaseError("auth/operation-not-allowed", "Operation not allowed"); + + vi.mocked(hasBehavior).mockReturnValue(false); + vi.mocked(signInWithRedirect).mockRejectedValue(error); + + await signInWithProvider(mockUI, provider); + + expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error); + + expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]); + }); + }); + +// TODO(ehesp): Test completeEmailLinkSignIn - it depends on an internal function +// which you can't mock: https://vitest.dev/guide/mocking.html#mocking-pitfalls \ No newline at end of file diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index c0a93bd4..a7e2dfe0 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -63,7 +63,7 @@ export async function signInWithEmailAndPassword( if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) { const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential); - + if (result) { return handlePendingCredential(ui, result); } @@ -168,6 +168,7 @@ export async function sendSignInLinkToEmail(ui: FirebaseUIConfiguration, email: ui.setState("pending"); await _sendSignInLinkToEmail(ui.auth, email, actionCodeSettings); + // TODO: Should this be a behavior ("storageStrategy")? window.localStorage.setItem("emailForSignIn", email); } catch (error) { handleFirebaseError(ui, error); @@ -248,7 +249,7 @@ export async function completeEmailLinkSignIn( ui.setState("pending"); const result = await signInWithEmailLink(ui, email, currentUrl); - ui.setState("idle"); + ui.setState("idle"); // TODO(ehesp): Do we need this here? return handlePendingCredential(ui, result); } catch (error) { handleFirebaseError(ui, error); diff --git a/packages/core/src/behaviors.test.ts b/packages/core/src/behaviors.test.ts new file mode 100644 index 00000000..fad81aa9 --- /dev/null +++ b/packages/core/src/behaviors.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createMockUI } from "~/tests/utils"; +import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior } from "./behaviors"; +import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider } from "firebase/auth"; + +vi.mock("firebase/auth", () => ({ + signInAnonymously: vi.fn(), + linkWithCredential: vi.fn(), + linkWithRedirect: 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([]); + }); + }); +}); diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts new file mode 100644 index 00000000..8391f575 --- /dev/null +++ b/packages/core/src/config.test.ts @@ -0,0 +1,70 @@ +import { FirebaseApp } from "firebase/app"; +import { Auth } from "firebase/auth"; +import { describe, it, expect } from "vitest"; +import { initializeUI } from "./config"; +import { enUs, registerLocale } from "@firebase-ui/translations"; +import { autoUpgradeAnonymousUsers } from "./behaviors"; + +describe('initializeUI', () => { + it('should return a valid deep store with default values', () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui).toBeDefined(); + expect(ui.get()).toBeDefined(); + expect(ui.get().app).toBe(config.app); + expect(ui.get().auth).toBe(config.auth); + expect(ui.get().behaviors).toEqual({}); + expect(ui.get().state).toEqual("idle"); + expect(ui.get().locale).toEqual(enUs); + }); + + it('should merge behaviors', () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + behaviors: [autoUpgradeAnonymousUsers()], + }; + + const ui = initializeUI(config); + expect(ui).toBeDefined(); + expect(ui.get()).toBeDefined(); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential"); + expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider"); + }); + + it('should set state and update state when called', () => { + const config = { + app: {} as FirebaseApp, + auth: {} as Auth, + }; + + const ui = initializeUI(config); + expect(ui.get().state).toEqual("idle"); + ui.get().setState("loading"); + expect(ui.get().state).toEqual("loading"); + ui.get().setState("idle"); + expect(ui.get().state).toEqual("idle"); + }); + + 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'); + ui.get().setLocale(testLocale1); + expect(ui.get().locale.locale).toEqual('test1'); + ui.get().setLocale(testLocale2); + expect(ui.get().locale.locale).toEqual('test2'); + }); +}); + diff --git a/packages/core/src/country-data.test.ts b/packages/core/src/country-data.test.ts new file mode 100644 index 00000000..6890b5ec --- /dev/null +++ b/packages/core/src/country-data.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from "vitest"; +import { + countryData, + getCountryByDialCode, + getCountryByCode, + formatPhoneNumberWithCountry +} from "./country-data"; + +describe("CountryData", () => { + describe("CountryData interface", () => { + it("should have correct structure for all countries", () => { + countryData.forEach((country) => { + expect(country).toHaveProperty("name"); + expect(country).toHaveProperty("dialCode"); + expect(country).toHaveProperty("code"); + expect(country).toHaveProperty("emoji"); + + expect(typeof country.name).toBe("string"); + expect(typeof country.dialCode).toBe("string"); + expect(typeof country.code).toBe("string"); + expect(typeof country.emoji).toBe("string"); + + expect(country.name.length).toBeGreaterThan(0); + expect(country.dialCode).toMatch(/^\+\d+$/); + expect(country.code).toMatch(/^[A-Z]{2}$/); + expect(country.emoji.length).toBeGreaterThan(0); + }); + }); + }); + + describe("countryData array", () => { + it("should have valid dial codes", () => { + countryData.forEach((country) => { + expect(country.dialCode).toMatch(/^\+\d{1,4}$/); + expect(country.dialCode.length).toBeGreaterThanOrEqual(2); // +1 + expect(country.dialCode.length).toBeLessThanOrEqual(5); // +1234 + }); + }); + + it("should have valid country codes (ISO 3166-1 alpha-2)", () => { + countryData.forEach((country) => { + expect(country.code).toMatch(/^[A-Z]{2}$/); + }); + }); + + it("should have valid emojis", () => { + countryData.forEach((country) => { + // Emojis should be flag emojis (typically 2 characters in UTF-16) + expect(country.emoji.length).toBeGreaterThan(0); + // Most flag emojis are 4 bytes in UTF-8, but some might be different + expect(country.emoji).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u); + }); + }); + }); + + describe("getCountryByDialCode", () => { + it("should return correct country for valid dial code", () => { + const usCountry = getCountryByDialCode("+1"); + expect(usCountry).toBeDefined(); + expect(usCountry?.code).toBe("US"); + expect(usCountry?.name).toBe("United States"); + + const ukCountry = getCountryByDialCode("+44"); + expect(ukCountry).toBeDefined(); + expect(ukCountry?.code).toBe("GB"); + expect(ukCountry?.name).toBe("United Kingdom"); + + const japanCountry = getCountryByDialCode("+81"); + expect(japanCountry).toBeDefined(); + expect(japanCountry?.code).toBe("JP"); + expect(japanCountry?.name).toBe("Japan"); + }); + + it("should return undefined for invalid dial code", () => { + expect(getCountryByDialCode("+999")).toBeUndefined(); + expect(getCountryByDialCode("invalid")).toBeUndefined(); + expect(getCountryByDialCode("")).toBeUndefined(); + }); + + it("should handle dial codes with multiple countries", () => { + const countries = countryData.filter(country => country.dialCode === "+1"); + expect(countries.length).toBeGreaterThan(1); + + // Should return the first match (US) + const result = getCountryByDialCode("+1"); + expect(result?.code).toBe("US"); + }); + }); + + describe("getCountryByCode", () => { + it("should return correct country for valid country code", () => { + const usCountry = getCountryByCode("US"); + expect(usCountry).toBeDefined(); + expect(usCountry?.code).toBe("US"); + expect(usCountry?.name).toBe("United States"); + expect(usCountry?.dialCode).toBe("+1"); + + const ukCountry = getCountryByCode("GB"); + expect(ukCountry).toBeDefined(); + expect(ukCountry?.code).toBe("GB"); + expect(ukCountry?.name).toBe("United Kingdom"); + expect(ukCountry?.dialCode).toBe("+44"); + }); + + it("should handle case insensitive country codes", () => { + expect(getCountryByCode("us")).toBeDefined(); + expect(getCountryByCode("Us")).toBeDefined(); + expect(getCountryByCode("uS")).toBeDefined(); + expect(getCountryByCode("US")).toBeDefined(); + + const result = getCountryByCode("us"); + expect(result?.code).toBe("US"); + }); + + it("should return undefined for invalid country code", () => { + expect(getCountryByCode("XX")).toBeUndefined(); + expect(getCountryByCode("INVALID")).toBeUndefined(); + expect(getCountryByCode("")).toBeUndefined(); + expect(getCountryByCode("U")).toBeUndefined(); + expect(getCountryByCode("USA")).toBeUndefined(); + }); + + it("should handle special characters in country codes", () => { + expect(getCountryByCode("XK")).toBeDefined(); // Kosovo + }); + }); + + 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"); + }); + + it("should handle phone numbers with spaces", () => { + expect(formatPhoneNumberWithCountry("123 456 7890", "+1")).toBe("+1123 456 7890"); + expect(formatPhoneNumberWithCountry(" 1234567890 ", "+1")).toBe("+11234567890"); + }); + + it("should handle empty phone numbers", () => { + expect(formatPhoneNumberWithCountry("", "+1")).toBe("+1"); + expect(formatPhoneNumberWithCountry(" ", "+1")).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"); + }); + + 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"); + }); + + it("should handle edge cases", () => { + expect(formatPhoneNumberWithCountry("1234567890", "+1234")).toBe("+12341234567890"); + expect(formatPhoneNumberWithCountry("1234567890", "+7")).toBe("+71234567890"); + }); + }); + + describe("Edge cases and error handling", () => { + it("should handle very long phone numbers", () => { + const longNumber = "12345678901234567890"; + expect(formatPhoneNumberWithCountry(longNumber, "+1")).toBe("+112345678901234567890"); + }); + + it("should handle countries with multiple dial codes", () => { + const kosovoCountries = countryData.filter(country => country.code === "XK"); + expect(kosovoCountries.length).toBeGreaterThan(1); + + const result1 = getCountryByDialCode("+377"); + const result2 = getCountryByDialCode("+381"); + const result3 = getCountryByDialCode("+386"); + + expect(result1?.code).toBe("XK"); + expect(result2?.code).toBe("XK"); + expect(result3?.code).toBe("XK"); + }); + }); +}); diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts new file mode 100644 index 00000000..0cf66527 --- /dev/null +++ b/packages/core/src/errors.test.ts @@ -0,0 +1,243 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { FirebaseError } from "firebase/app"; +import { AuthCredential } from "firebase/auth"; +import { FirebaseUIError, handleFirebaseError } from "./errors"; +import { createMockUI } from "~/tests/utils"; +import { ERROR_CODE_MAP } from "@firebase-ui/translations"; + +// Mock the translations module +vi.mock("./translations", () => ({ + getTranslation: vi.fn(), +})); + +import { getTranslation } from "./translations"; + +let mockSessionStorage: { [key: string]: string }; + +beforeEach(() => { + vi.clearAllMocks(); + + // Mock sessionStorage + mockSessionStorage = {}; + Object.defineProperty(window, 'sessionStorage', { + value: { + setItem: vi.fn((key: string, value: string) => { + mockSessionStorage[key] = value; + }), + getItem: vi.fn((key: string) => mockSessionStorage[key] || null), + removeItem: vi.fn((key: string) => { + delete mockSessionStorage[key]; + }), + clear: vi.fn(() => { + Object.keys(mockSessionStorage).forEach(key => delete mockSessionStorage[key]); + }), + }, + writable: true, + }); +}); + +describe("FirebaseUIError", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create a FirebaseUIError with translated message", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + const error = new FirebaseUIError(mockUI, mockFirebaseError); + + expect(error).toBeInstanceOf(FirebaseError); + expect(error.code).toBe("auth/user-not-found"); + expect(error.message).toBe(expectedTranslation); + expect(getTranslation).toHaveBeenCalledWith( + mockUI, + "errors", + ERROR_CODE_MAP["auth/user-not-found"] + ); + }); + + it("should handle unknown error codes gracefully", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/unknown-error", "Unknown error"); + const expectedTranslation = "Unknown error (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + const error = new FirebaseUIError(mockUI, mockFirebaseError); + + expect(error.code).toBe("auth/unknown-error"); + expect(error.message).toBe(expectedTranslation); + expect(getTranslation).toHaveBeenCalledWith( + mockUI, + "errors", + ERROR_CODE_MAP["auth/unknown-error" as keyof typeof ERROR_CODE_MAP] + ); + }); +}); + +describe("handleFirebaseError", () => { + it("should throw non-Firebase errors as-is", () => { + const mockUI = createMockUI(); + const nonFirebaseError = new Error("Regular error"); + + expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow("Regular error"); + }); + + it("should throw non-Firebase errors with different types", () => { + const mockUI = createMockUI(); + const stringError = "String error"; + const numberError = 42; + const nullError = null; + const undefinedError = undefined; + + expect(() => handleFirebaseError(mockUI, stringError)).toThrow("String error"); + expect(() => handleFirebaseError(mockUI, numberError)).toThrow(); + expect(() => handleFirebaseError(mockUI, nullError)).toThrow(); + expect(() => handleFirebaseError(mockUI, undefinedError)).toThrow(); + }); + + it("should throw FirebaseUIError for Firebase errors", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + try { + handleFirebaseError(mockUI, mockFirebaseError); + } catch (error) { + // Should be an instance of both FirebaseUIError and FirebaseError + expect(error).toBeInstanceOf(FirebaseUIError); + expect(error).toBeInstanceOf(FirebaseError); + expect((error as FirebaseUIError).code).toBe("auth/user-not-found"); + expect((error as FirebaseUIError).message).toBe(expectedTranslation); + } + }); + + it("should store credential in sessionStorage for account-exists-with-different-credential", () => { + const mockUI = createMockUI(); + const mockCredential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" }) + } as unknown as AuthCredential; + + const mockFirebaseError = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + credential: mockCredential + } as FirebaseError & { credential: AuthCredential }; + + const expectedTranslation = "Account exists with different credential (translated)"; + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + "pendingCred", + JSON.stringify(mockCredential.toJSON()) + ); + expect(mockCredential.toJSON).toHaveBeenCalled(); + }); + + it("should not store credential for other error types", () => { + const mockUI = createMockUI(); + const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found"); + const expectedTranslation = "User not found (translated)"; + + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); + + it("should handle account-exists-with-different-credential without credential", () => { + const mockUI = createMockUI(); + const mockFirebaseError = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential" + } as FirebaseError; + + const expectedTranslation = "Account exists with different credential (translated)"; + vi.mocked(getTranslation).mockReturnValue(expectedTranslation); + + expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError); + + // Should not try to store credential if it doesn't exist + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); +}); + +describe("isFirebaseError utility", () => { + it("should identify FirebaseError objects", () => { + const firebaseError = new FirebaseError("auth/user-not-found", "User not found"); + + // We can't directly test the private function, but we can test it through handleFirebaseError + const mockUI = createMockUI(); + vi.mocked(getTranslation).mockReturnValue("translated message"); + + expect(() => handleFirebaseError(mockUI, firebaseError)).toThrow(FirebaseUIError); + }); + + it("should reject non-FirebaseError objects", () => { + const mockUI = createMockUI(); + const nonFirebaseError = { code: "test", message: "test" }; // Missing proper structure + + expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow(); + }); + + it("should reject objects without code and message", () => { + const mockUI = createMockUI(); + const invalidObject = { someProperty: "value" }; + + expect(() => handleFirebaseError(mockUI, invalidObject)).toThrow(); + }); +}); + +describe("errorContainsCredential utility", () => { + it("should identify FirebaseError with credential", () => { + const mockUI = createMockUI(); + const mockCredential = { + providerId: "google.com", + toJSON: vi.fn().mockReturnValue({ providerId: "google.com" }) + } as unknown as AuthCredential; + + const firebaseErrorWithCredential = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential", + credential: mockCredential + } as FirebaseError & { credential: AuthCredential }; + + vi.mocked(getTranslation).mockReturnValue("translated message"); + + expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError); + + // Should have stored the credential + expect(window.sessionStorage.setItem).toHaveBeenCalledWith( + "pendingCred", + JSON.stringify(mockCredential.toJSON()) + ); + }); + + it("should handle FirebaseError without credential", () => { + const mockUI = createMockUI(); + const firebaseErrorWithoutCredential = { + code: "auth/account-exists-with-different-credential", + message: "Account exists with different credential" + } as FirebaseError; + + vi.mocked(getTranslation).mockReturnValue("translated message"); + + expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError); + + // Should not have stored any credential + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + }); +}); + diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 8687234b..c538a0c4 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -15,51 +15,45 @@ */ import { ERROR_CODE_MAP, ErrorCode } from "@firebase-ui/translations"; -import { getTranslation } from "./translations"; +import { FirebaseError } from "firebase/app"; +import { AuthCredential } from "firebase/auth"; import { FirebaseUIConfiguration } from "./config"; -export class FirebaseUIError extends Error { - code: string; - - constructor(error: any, ui: FirebaseUIConfiguration) { - const errorCode: ErrorCode = error?.customData?.message?.match?.(/\(([^)]+)\)/)?.at(1) || error?.code || "unknown"; - const translationKey = ERROR_CODE_MAP[errorCode] || "unknownError"; - const message = getTranslation(ui, "errors", translationKey); +import { getTranslation } from "./translations"; +export class FirebaseUIError extends FirebaseError { + constructor(ui: FirebaseUIConfiguration, error: FirebaseError) { + const message = getTranslation(ui, "errors", ERROR_CODE_MAP[error.code as ErrorCode]); + super(error.code, message); - super(message); - this.name = "FirebaseUIError"; - this.code = errorCode; + // Ensures that `instanceof FirebaseUIError` works, alongside `instanceof FirebaseError` + Object.setPrototypeOf(this, FirebaseUIError.prototype); } } export function handleFirebaseError( ui: FirebaseUIConfiguration, - error: any, - opts?: { - enableHandleExistingCredential?: boolean; - } + error: unknown, ): never { - // TODO(ehesp): Type error as unknown, check instance of FirebaseError - if (error?.code === "auth/account-exists-with-different-credential") { - if (opts?.enableHandleExistingCredential && error.credential) { - window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential)); - } else { - window.sessionStorage.removeItem("pendingCred"); - } - - throw new FirebaseUIError( - { - code: "auth/account-exists-with-different-credential", - customData: { - email: error.customData?.email, - }, - }, - ui, - ); + // If it's not a Firebase error, then we just throw it and preserve the original error. + if (!isFirebaseError(error)) { + throw error; } - // TODO: Debug why instanceof FirebaseError is not working - if (error?.name === "FirebaseError") { - throw new FirebaseUIError(error, ui); + // TODO(ehesp): Type error as unknown, check instance of FirebaseError + // TODO(ehesp): Support via behavior + if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) { + window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON())); } - throw new FirebaseUIError({ code: "unknown" }, ui); + + throw new FirebaseUIError(ui, error); +} + +// Utility to obtain whether something is a FirebaseError +function isFirebaseError(error: unknown): error is FirebaseError { + // Calling instanceof FirebaseError is not working - not sure why yet. + return !!error && typeof error === "object" && "code" in error && "message" in error; +} + +// Utility to obtain whether something is a FirebaseError that contains a credential - doesn't seemed to be typed? +function errorContainsCredential(error: FirebaseError): error is FirebaseError & { credential: AuthCredential } { + return 'credential' in error; } diff --git a/packages/core/src/schemas.test.ts b/packages/core/src/schemas.test.ts new file mode 100644 index 00000000..f3991408 --- /dev/null +++ b/packages/core/src/schemas.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect } from "vitest"; +import { createMockUI } from "~/tests/utils"; +import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, createPhoneAuthFormSchema, createSignInAuthFormSchema, createSignUpAuthFormSchema } from "./schemas"; +import { registerLocale } from "@firebase-ui/translations"; +import { RecaptchaVerifier } from "firebase/auth"; + +describe("createSignInAuthFormSchema", () => { + it("should create a sign in auth form schema with valid error messages", () => { + const testLocale = registerLocale('test', { + errors: { + invalidEmail: "createSignInAuthFormSchema + invalidEmail", + weakPassword: "createSignInAuthFormSchema + weakPassword", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createSignInAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: '', + password: '', + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(2); + + expect(result.error?.issues[0]?.message).toBe("createSignInAuthFormSchema + invalidEmail"); + expect(result.error?.issues[1]?.message).toBe("createSignInAuthFormSchema + weakPassword"); + }); +}); + +describe("createSignUpAuthFormSchema", () => { + it("should create a sign up auth form schema with valid error messages", () => { + const testLocale = registerLocale('test', { + errors: { + invalidEmail: "createSignUpAuthFormSchema + invalidEmail", + weakPassword: "createSignUpAuthFormSchema + weakPassword", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createSignUpAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: '', + password: '', + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(2); + + expect(result.error?.issues[0]?.message).toBe("createSignUpAuthFormSchema + invalidEmail"); + expect(result.error?.issues[1]?.message).toBe("createSignUpAuthFormSchema + weakPassword"); + }); +}); + +describe("createForgotPasswordAuthFormSchema", () => { + it("should create a forgot password form schema with valid error messages", () => { + const testLocale = registerLocale('test', { + errors: { + invalidEmail: "createForgotPasswordAuthFormSchema + invalidEmail", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createForgotPasswordAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: '', + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(1); + + expect(result.error?.issues[0]?.message).toBe("createForgotPasswordAuthFormSchema + invalidEmail"); + }); +}); + +describe("createEmailLinkAuthFormSchema", () => { + it("should create a forgot password form schema with valid error messages", () => { + const testLocale = registerLocale('test', { + errors: { + invalidEmail: "createEmailLinkAuthFormSchema + invalidEmail", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createEmailLinkAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + email: '', + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.length).toBe(1); + + expect(result.error?.issues[0]?.message).toBe("createEmailLinkAuthFormSchema + invalidEmail"); + }); +}); + +describe("createPhoneAuthFormSchema", () => { + it("should create a phone auth form schema and show missing phone number error", () => { + const testLocale = registerLocale('test', { + errors: { + missingPhoneNumber: "createPhoneAuthFormSchema + missingPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + phoneNumber: '', + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + expect(result.error?.issues[0]?.message).toBe("createPhoneAuthFormSchema + missingPhoneNumber"); + }); + + it("should create a phone auth form schema and show an error if the phone number is too long", () => { + const testLocale = registerLocale('test', { + errors: { + invalidPhoneNumber: "createPhoneAuthFormSchema + invalidPhoneNumber", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthFormSchema(mockUI); + + // Cause the schema to fail... + // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated? + const result = schema.safeParse({ + phoneNumber: '12345678901', + verificationCode: '123', + recaptchaVerifier: null, + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + + expect(result.error?.issues[0]?.message).toBe("createPhoneAuthFormSchema + invalidPhoneNumber"); + }); + + it("should create a phone auth form schema and show an error if the verification code is too short", () => { + const testLocale = registerLocale('test', { + errors: { + invalidVerificationCode: "createPhoneAuthFormSchema + invalidVerificationCode", + }, + }); + + const mockUI = createMockUI({ + locale: testLocale, + }); + + const schema = createPhoneAuthFormSchema(mockUI); + + const result = schema.safeParse({ + phoneNumber: '1234567890', + verificationCode: '123', + recaptchaVerifier: {} as RecaptchaVerifier, // Workaround for RecaptchaVerifier failing with Node env. + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.error?.issues.some(issue => issue.message === "createPhoneAuthFormSchema + invalidVerificationCode")).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 46bcdbeb..14df3d76 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -54,7 +54,7 @@ export function createPhoneAuthFormSchema(ui: FirebaseUIConfiguration) { phoneNumber: z .string() .min(1, { message: getTranslation(ui, "errors", "missingPhoneNumber") }) - .min(10, { message: getTranslation(ui, "errors", "invalidPhoneNumber") }), + .max(10, { message: getTranslation(ui, "errors", "invalidPhoneNumber") }), verificationCode: z.string().refine((val) => !val || val.length >= 6, { message: getTranslation(ui, "errors", "invalidVerificationCode"), }), diff --git a/packages/core/src/translations.test.ts b/packages/core/src/translations.test.ts new file mode 100644 index 00000000..a7d3d356 --- /dev/null +++ b/packages/core/src/translations.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; + +// Mock the translations module first +vi.mock("@firebase-ui/translations", async (original) => ({ + ...(await original()), + getTranslation: vi.fn(), +})); + +import { getTranslation as _getTranslation, registerLocale } from "@firebase-ui/translations"; +import { getTranslation } from "./translations"; +import { createMockUI } from "~/tests/utils"; + +describe("getTranslation", () => { + it("should return the correct translation", () => { + const testLocale = registerLocale("test", { + errors: { + userNotFound: "test + userNotFound", + }, + }); + + vi.mocked(_getTranslation).mockReturnValue("test + userNotFound"); + + const mockUI = createMockUI({ locale: testLocale }); + const translation = getTranslation(mockUI, "errors", "userNotFound"); + + expect(translation).toBe("test + userNotFound"); + expect(_getTranslation).toHaveBeenCalledWith(testLocale, "errors", "userNotFound"); + }); +}); \ No newline at end of file diff --git a/packages/core/tests/integration/auth.integration.test.ts b/packages/core/tests/auth.integration.test.ts similarity index 97% rename from packages/core/tests/integration/auth.integration.test.ts rename to packages/core/tests/auth.integration.test.ts index f3b86168..e89a5898 100644 --- a/packages/core/tests/integration/auth.integration.test.ts +++ b/packages/core/tests/auth.integration.test.ts @@ -24,12 +24,12 @@ import { sendSignInLinkToEmail, signInAnonymously, sendPasswordResetEmail, - signInWithOAuth, + signInWithProvider, completeEmailLinkSignIn, confirmPhoneNumber as _confirmPhoneNumber, -} from "../../src/auth"; -import { FirebaseUIError } from "../../src/errors"; -import { initializeUI, FirebaseUI } from "../../src/config"; +} from "../src/auth"; +import { FirebaseUIError } from "../src/errors"; +import { initializeUI, FirebaseUI } from "../src/config"; describe("Firebase UI Auth Integration", () => { let auth: Auth; @@ -141,7 +141,7 @@ describe("Firebase UI Auth Integration", () => { it("should handle enableAutoUpgradeAnonymous flag for OAuth", async () => { const provider = new GoogleAuthProvider(); await signInAnonymously(ui.get()); - await expect(signInWithOAuth(ui.get(), provider)).rejects.toThrow(); + await expect(signInWithProvider(ui.get(), provider)).rejects.toThrow(); }); }); diff --git a/packages/core/tests/unit/auth.test.ts b/packages/core/tests/unit/auth.test.ts deleted file mode 100644 index 6d1d4313..00000000 --- a/packages/core/tests/unit/auth.test.ts +++ /dev/null @@ -1,500 +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 { describe, it, expect, vi, beforeEach } from "vitest"; -import { - Auth, - EmailAuthProvider, - PhoneAuthProvider, - createUserWithEmailAndPassword as fbCreateUserWithEmailAndPassword, - getAuth, - isSignInWithEmailLink as fbIsSignInWithEmailLink, - linkWithCredential, - linkWithRedirect, - sendPasswordResetEmail as fbSendPasswordResetEmail, - sendSignInLinkToEmail as fbSendSignInLinkToEmail, - signInAnonymously as fbSignInAnonymously, - signInWithCredential, - signInWithPhoneNumber as fbSignInWithPhoneNumber, - signInWithRedirect, -} from "firebase/auth"; -import { - signInWithEmailAndPassword, - createUserWithEmailAndPassword, - signInWithPhoneNumber, - confirmPhoneNumber, - sendPasswordResetEmail, - sendSignInLinkToEmail, - signInWithEmailLink, - signInAnonymously, - signInWithProvider, - completeEmailLinkSignIn, -} from "../../src/auth"; -import { FirebaseUIConfiguration } from "../../src/config"; -import { enUs } from "@firebase-ui/translations"; - -// Mock all Firebase Auth functions -vi.mock("firebase/auth", async () => { - const actual = await vi.importActual("firebase/auth"); - return { - ...(actual as object), - getAuth: vi.fn(), - signInWithCredential: vi.fn(), - createUserWithEmailAndPassword: vi.fn(), - signInWithPhoneNumber: vi.fn(), - sendPasswordResetEmail: vi.fn(), - sendSignInLinkToEmail: vi.fn(), - isSignInWithEmailLink: vi.fn(), - signInAnonymously: vi.fn(), - linkWithCredential: vi.fn(), - linkWithRedirect: vi.fn(), - signInWithRedirect: vi.fn(), - EmailAuthProvider: { - credential: vi.fn(), - credentialWithLink: vi.fn(), - }, - PhoneAuthProvider: { - credential: vi.fn(), - }, - }; -}); - -describe("Firebase UI Auth", () => { - let mockAuth: Auth; - let mockUi: FirebaseUIConfiguration; - - const mockCredential = { type: "password", token: "mock-token" }; - const mockUserCredential = { user: { uid: "mock-uid" } }; - const mockConfirmationResult = { verificationId: "mock-verification-id" }; - const _mockError = { name: "FirebaseError", code: "auth/user-not-found" }; - const mockProvider = { providerId: "google.com" }; - - beforeEach(() => { - vi.clearAllMocks(); - mockAuth = { currentUser: null } as Auth; - window.localStorage.clear(); - window.sessionStorage.clear(); - (EmailAuthProvider.credential as any).mockReturnValue(mockCredential); - (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential); - (PhoneAuthProvider.credential as any).mockReturnValue(mockCredential); - - // Create a mock FirebaseUIConfiguration - mockUi = { - app: { name: "test" } as any, - auth: mockAuth, - setLocale: vi.fn(), - state: "idle", - setState: vi.fn(), - locale: enUs, - behaviors: {}, - }; - }); - - describe("signInWithEmailAndPassword", () => { - it("should sign in with email and password", async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(EmailAuthProvider.credential).toHaveBeenCalledWith("test@test.com", "password"); - expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential); - expect(result).toBe(mockUserCredential); - }); - - it("should upgrade anonymous user when enabled", async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalledWith(mockUi, mockCredential); - expect(result).toBe(mockUserCredential); - }); - }); - - describe("createUserWithEmailAndPassword", () => { - it("should create user with email and password", async () => { - (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, "test@test.com", "password"); - expect(result).toBe(mockUserCredential); - }); - - it("should upgrade anonymous user when enabled", async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalledWith(mockUi, mockCredential); - expect(result).toBe(mockUserCredential); - }); - }); - - describe("signInWithPhoneNumber", () => { - it("should initiate phone number sign in", async () => { - (fbSignInWithPhoneNumber as any).mockResolvedValue(mockConfirmationResult); - const mockRecaptcha = { type: "recaptcha" }; - - const result = await signInWithPhoneNumber(mockUi, "+1234567890", mockRecaptcha as any); - - expect(fbSignInWithPhoneNumber).toHaveBeenCalledWith(mockAuth, "+1234567890", mockRecaptcha); - expect(result).toBe(mockConfirmationResult); - }); - }); - - describe("confirmPhoneNumber", () => { - it("should confirm phone number sign in", async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await confirmPhoneNumber(mockUi, { verificationId: "mock-id" } as any, "123456"); - - expect(PhoneAuthProvider.credential).toHaveBeenCalledWith("mock-id", "123456"); - expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential); - expect(result).toBe(mockUserCredential); - }); - - it("should upgrade anonymous user when enabled", async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await confirmPhoneNumber(mockUi, { verificationId: "mock-id" } as any, "123456"); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalled(); - expect(result).toBe(mockUserCredential); - }); - }); - - describe("sendPasswordResetEmail", () => { - it("should send password reset email", async () => { - (fbSendPasswordResetEmail as any).mockResolvedValue(undefined); - - await sendPasswordResetEmail(mockUi, "test@test.com"); - - expect(fbSendPasswordResetEmail).toHaveBeenCalledWith(mockAuth, "test@test.com"); - }); - }); - - describe("sendSignInLinkToEmail", () => { - it("should send sign in link to email", async () => { - (fbSendSignInLinkToEmail as any).mockResolvedValue(undefined); - - const expectedActionCodeSettings = { - url: window.location.href, - handleCodeInApp: true, - }; - - await sendSignInLinkToEmail(mockUi, "test@test.com"); - - expect(fbSendSignInLinkToEmail).toHaveBeenCalledWith(mockAuth, "test@test.com", expectedActionCodeSettings); - expect(mockUi.setState).toHaveBeenCalledWith("sending-sign-in-link-to-email"); - expect(mockUi.setState).toHaveBeenCalledWith("idle"); - expect(window.localStorage.getItem("emailForSignIn")).toBe("test@test.com"); - }); - }); - - describe("signInWithEmailLink", () => { - it("should sign in with email link", async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailLink(mockUi, "test@test.com", "mock-link"); - - expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith("test@test.com", "mock-link"); - expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential); - expect(result).toBe(mockUserCredential); - }); - - it("should upgrade anonymous user when enabled", async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - window.localStorage.setItem("emailLinkAnonymousUpgrade", "true"); - (linkWithCredential as any).mockResolvedValue(mockUserCredential); - - mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential); - - const result = await signInWithEmailLink(mockUi, "test@test.com", "mock-link"); - - expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalled(); - expect(result).toBe(mockUserCredential); - }); - }); - - describe("signInAnonymously", () => { - it("should sign in anonymously", async () => { - (fbSignInAnonymously as any).mockResolvedValue(mockUserCredential); - - const result = await signInAnonymously(mockUi); - - expect(fbSignInAnonymously).toHaveBeenCalledWith(mockAuth); - expect(result).toBe(mockUserCredential); - }); - - it("should handle operation not allowed error", async () => { - const operationNotAllowedError = { name: "FirebaseError", code: "auth/operation-not-allowed" }; - (fbSignInAnonymously as any).mockRejectedValue(operationNotAllowedError); - - await expect(signInAnonymously(mockUi)).rejects.toThrow(); - }); - - it("should handle admin restricted operation error", async () => { - const adminRestrictedError = { name: "FirebaseError", code: "auth/admin-restricted-operation" }; - (fbSignInAnonymously as any).mockRejectedValue(adminRestrictedError); - - await expect(signInAnonymously(mockUi)).rejects.toThrow(); - }); - }); - - describe("Anonymous User Upgrade", () => { - it("should handle upgrade with existing email", async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - const emailExistsError = { name: "FirebaseError", code: "auth/email-already-in-use" }; - (fbCreateUserWithEmailAndPassword as any).mockRejectedValue(emailExistsError); - - await expect(createUserWithEmailAndPassword(mockUi, "existing@test.com", "password")).rejects.toThrow(); - }); - - it("should handle upgrade of non-anonymous user", async () => { - mockAuth = { currentUser: { isAnonymous: false } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, "test@test.com", "password"); - expect(result).toBe(mockUserCredential); - }); - - it("should handle null user during upgrade", async () => { - mockAuth = { currentUser: null } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential); - - const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, "test@test.com", "password"); - expect(result).toBe(mockUserCredential); - }); - }); - - describe("signInWithProvider", () => { - it("should sign in with OAuth provider", async () => { - (signInWithRedirect as any).mockResolvedValue(undefined); - - await signInWithProvider(mockUi, mockProvider as any); - - expect(signInWithRedirect).toHaveBeenCalledWith(mockAuth, mockProvider); - }); - - it("should upgrade anonymous user when enabled", async () => { - mockAuth = { currentUser: { isAnonymous: true } } as Auth; - (getAuth as any).mockReturnValue(mockAuth); - (linkWithRedirect as any).mockResolvedValue(undefined); - - mockUi.behaviors.autoUpgradeAnonymousProvider = vi.fn(); - - await signInWithProvider(mockUi, mockProvider as any); - - expect(mockUi.behaviors.autoUpgradeAnonymousProvider).toHaveBeenCalledWith(mockUi, mockProvider); - }); - }); - - describe("completeEmailLinkSignIn", () => { - it("should complete email link sign in when valid", async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - window.localStorage.setItem("emailForSignIn", "test@test.com"); - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - const result = await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code"); - - expect(fbIsSignInWithEmailLink).toHaveBeenCalled(); - expect(result).toBe(mockUserCredential); - }); - - it("should clean up all storage items after sign in attempt", async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - window.localStorage.setItem("emailForSignIn", "test@test.com"); - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - - await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code"); - - expect(window.localStorage.getItem("emailForSignIn")).toBeNull(); - }); - - it("should return null when not a valid sign in link", async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(false); - - const result = await completeEmailLinkSignIn(mockUi, "https://example.com?invalidlink=true"); - - expect(result).toBeNull(); - }); - - it("should return null when no email in storage", async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - window.localStorage.clear(); - - const result = await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code"); - - expect(result).toBeNull(); - }); - - it("should clean up storage even when sign in fails", async () => { - // Patch localStorage for testing - const mockLocalStorage = { - getItem: vi.fn().mockReturnValue("test@test.com"), - removeItem: vi.fn(), - setItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }; - Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); - - // Make isSignInWithEmailLink return true - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - - // Make signInWithCredential throw an error - const error = new Error("Sign in failed"); - (signInWithCredential as any).mockRejectedValue(error); - - // Mock handleFirebaseError to throw our actual error instead - vi.mock("../../src/errors", async () => { - const actual = await vi.importActual("../../src/errors"); - return { - ...(actual as object), - handleFirebaseError: vi.fn().mockImplementation((ui, e) => { - throw e; - }), - }; - }); - - // Use rejects matcher with our specific error - await expect(completeEmailLinkSignIn(mockUi, "https://example.com?oob=code")).rejects.toThrow("Sign in failed"); - - // Check localStorage was cleared - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("emailForSignIn"); - }); - }); - - describe("Pending Credential Handling", () => { - it("should handle pending credential during email sign in", async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - window.sessionStorage.setItem("pendingCred", JSON.stringify(mockCredential)); - (linkWithCredential as any).mockResolvedValue({ ...mockUserCredential, linked: true }); - - const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, mockCredential); - expect((result as any).linked).toBe(true); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); - }); - - it("should handle invalid pending credential gracefully", async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - window.sessionStorage.setItem("pendingCred", "invalid-json"); - - const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(result).toBe(mockUserCredential); - }); - - it("should handle linking failure gracefully", async () => { - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - window.sessionStorage.setItem("pendingCred", JSON.stringify(mockCredential)); - (linkWithCredential as any).mockRejectedValue(new Error("Linking failed")); - - const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password"); - - expect(result).toBe(mockUserCredential); - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); - }); - }); - - describe("Storage Management", () => { - it("should clean up all storage items after successful email link sign in", async () => { - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - - // Patch localStorage for testing - const mockLocalStorage = { - getItem: vi.fn().mockReturnValue("test@test.com"), - removeItem: vi.fn(), - setItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }; - Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); - - // Create mocks to ensure a successful sign in - (signInWithCredential as any).mockResolvedValue(mockUserCredential); - (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential); - - const result = await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code"); - - expect(result).not.toBeNull(); - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("emailForSignIn"); - }); - - it("should clean up storage even when sign in fails", async () => { - // Patch localStorage for testing - const mockLocalStorage = { - getItem: vi.fn().mockReturnValue("test@test.com"), - removeItem: vi.fn(), - setItem: vi.fn(), - clear: vi.fn(), - key: vi.fn(), - length: 0, - }; - Object.defineProperty(window, "localStorage", { value: mockLocalStorage }); - - // Make isSignInWithEmailLink return true - (fbIsSignInWithEmailLink as any).mockReturnValue(true); - - // Make signInWithCredential throw an error - const error = new Error("Sign in failed"); - (signInWithCredential as any).mockRejectedValue(error); - - // Mock handleFirebaseError to throw our actual error instead - vi.mock("../../src/errors", async () => { - const actual = await vi.importActual("../../src/errors"); - return { - ...(actual as object), - handleFirebaseError: vi.fn().mockImplementation((ui, e) => { - throw e; - }), - }; - }); - - // Use rejects matcher with our specific error - await expect(completeEmailLinkSignIn(mockUi, "https://example.com?oob=code")).rejects.toThrow("Sign in failed"); - - // Check localStorage was cleared - expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("emailForSignIn"); - }); - }); -}); diff --git a/packages/core/tests/unit/config.test.ts b/packages/core/tests/unit/config.test.ts deleted file mode 100644 index deff8fd3..00000000 --- a/packages/core/tests/unit/config.test.ts +++ /dev/null @@ -1,146 +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 { describe, it, expect, vi } from "vitest"; -import { initializeUI, $config } from "../../src/config"; -import { enUs } from "@firebase-ui/translations"; -import { onAuthStateChanged as _onAuthStateChanged } from "firebase/auth"; - -vi.mock("firebase/auth", () => ({ - getAuth: vi.fn(), - onAuthStateChanged: vi.fn(), -})); - -describe("Config", () => { - describe("initializeUI", () => { - it("should initialize config with default name", () => { - const config = { - app: { - name: "test", - options: {}, - automaticDataCollectionEnabled: false, - }, - }; - - const store = initializeUI(config); - expect(store.get()).toEqual({ - app: config.app, - getAuth: expect.any(Function), - locale: enUs, - setLocale: expect.any(Function), - state: "idle", - setState: expect.any(Function), - behaviors: {}, - recaptchaMode: "normal", - }); - expect($config.get()["[DEFAULT]"]).toBe(store); - }); - - it("should initialize config with custom name", () => { - const config = { - app: { - name: "test", - options: {}, - automaticDataCollectionEnabled: false, - }, - }; - - const store = initializeUI(config, "custom"); - expect(store.get()).toEqual({ - app: config.app, - getAuth: expect.any(Function), - locale: enUs, - setLocale: expect.any(Function), - state: "idle", - setState: expect.any(Function), - behaviors: {}, - recaptchaMode: "normal", - }); - expect($config.get()["custom"]).toBe(store); - }); - - it("should setup auto anonymous login when enabled", () => { - const config = { - app: { - name: "test", - options: {}, - automaticDataCollectionEnabled: false, - }, - behaviors: [ - { - autoAnonymousLogin: vi.fn().mockImplementation(async (ui) => { - ui.setState("idle"); - return {}; - }), - }, - ], - }; - - const store = initializeUI(config); - expect(store.get().behaviors.autoAnonymousLogin).toBeDefined(); - expect(store.get().behaviors.autoAnonymousLogin).toHaveBeenCalled(); - expect(store.get().state).toBe("idle"); - }); - - it("should not setup auto anonymous login when disabled", () => { - const config = { - app: { - name: "test", - options: {}, - automaticDataCollectionEnabled: false, - }, - }; - - const store = initializeUI(config); - expect(store.get().behaviors.autoAnonymousLogin).toBeUndefined(); - }); - - it("should handle both auto features being enabled", () => { - const config = { - app: { - name: "test", - options: {}, - automaticDataCollectionEnabled: false, - }, - behaviors: [ - { - autoAnonymousLogin: vi.fn().mockImplementation(async (ui) => { - ui.setState("idle"); - return {}; - }), - autoUpgradeAnonymousCredential: vi.fn(), - }, - ], - }; - - const store = initializeUI(config); - expect(store.get()).toEqual({ - app: config.app, - getAuth: expect.any(Function), - locale: enUs, - setLocale: expect.any(Function), - state: "idle", - setState: expect.any(Function), - behaviors: { - autoAnonymousLogin: expect.any(Function), - autoUpgradeAnonymousCredential: expect.any(Function), - }, - recaptchaMode: "normal", - }); - expect(store.get().behaviors.autoAnonymousLogin).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/core/tests/unit/errors.test.ts b/packages/core/tests/unit/errors.test.ts deleted file mode 100644 index c8c6b9b7..00000000 --- a/packages/core/tests/unit/errors.test.ts +++ /dev/null @@ -1,260 +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 { describe, it, expect, vi as _vi } from "vitest"; -import { FirebaseUIError, handleFirebaseError } from "../../src/errors"; -import { enUs } from "@firebase-ui/translations"; -import { FirebaseUIConfiguration } from "../../src/config"; - -const mockUi = { - locale: enUs, -} as FirebaseUIConfiguration; - -describe("FirebaseUIError", () => { - describe("constructor", () => { - it("should extract error code from Firebase error message", () => { - const error = new FirebaseUIError({ - customData: { message: "Firebase: Error (auth/wrong-password)." }, - }, mockUi); - expect(error.code).toBe("auth/wrong-password"); - }); - - it("should use error code directly if available", () => { - const error = new FirebaseUIError({ code: "auth/user-not-found" }, mockUi); - expect(error.code).toBe("auth/user-not-found"); - }); - - it("should fallback to unknown if no code is found", () => { - const error = new FirebaseUIError({}, mockUi); - expect(error.code).toBe("unknown"); - }); - - // TODO: Create util for another language - it.skip("should use custom translations if provided", () => { - const translations = { - "es-ES": { - errors: { - userNotFound: "Usuario no encontrado", - }, - }, - }; - const error = new FirebaseUIError({ code: "auth/user-not-found" }, mockUi); - expect(error.message).toBe("Usuario no encontrado"); - }); - - it("should fallback to default translation if language is not found", () => { - const error = new FirebaseUIError({ code: "auth/user-not-found" }, mockUi); - expect(error.message).toBe("No account found with this email address"); - }); - - it("should handle malformed error objects gracefully", () => { - const error = new FirebaseUIError(null, mockUi); - expect(error.code).toBe("unknown"); - expect(error.message).toBe("An unexpected error occurred"); - }); - - it("should set error name to FirebaseUIError", () => { - const error = new FirebaseUIError({}, mockUi); - expect(error.name).toBe("FirebaseUIError"); - }); - }); - - describe("handleFirebaseError", () => { - // const mockUi = { - // translations: { - // "es-ES": { - // errors: { - // userNotFound: "Usuario no encontrado", - // }, - // }, - // }, - // locale: "es-ES", - // }; - - // TODO: Create util for another language - it.skip("should throw FirebaseUIError for Firebase errors", () => { - const firebaseError = { - name: "FirebaseError", - code: "auth/user-not-found", - }; - - expect(() => { - handleFirebaseError(mockUi as any, firebaseError); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, firebaseError); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe("auth/user-not-found"); - expect(e.message).toBe("Usuario no encontrado"); - } - }); - - it("should throw FirebaseUIError with unknown code for non-Firebase errors", () => { - const error = new Error("Random error"); - - expect(() => { - handleFirebaseError(mockUi as any, error); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe("unknown"); - } - }); - - // TODO: Create util for another language - it.skip("should pass translations and language to FirebaseUIError", () => { - const firebaseError = { - name: "FirebaseError", - code: "auth/user-not-found", - }; - - expect(() => { - handleFirebaseError(mockUi as any, firebaseError); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, firebaseError); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.message).toBe("Usuario no encontrado"); - } - }); - - it("should handle null/undefined errors", () => { - expect(() => { - handleFirebaseError(mockUi as any, null); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, null); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe("unknown"); - } - }); - - it("should preserve the error code in thrown error", () => { - const firebaseError = { - name: "FirebaseError", - code: "auth/wrong-password", - }; - - expect(() => { - handleFirebaseError(mockUi as any, firebaseError); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, firebaseError); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe("auth/wrong-password"); - } - }); - - describe("account exists with different credential handling", () => { - it("should store credential and throw error when enableHandleExistingCredential is true", () => { - const mockCredential = { type: "google.com" }; - const error = { - code: "auth/account-exists-with-different-credential", - credential: mockCredential, - customData: { email: "test@test.com" }, - }; - - expect(() => { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe("auth/account-exists-with-different-credential"); - expect(window.sessionStorage.getItem("pendingCred")).toBe(JSON.stringify(mockCredential)); - } - }); - - it("should not store credential when enableHandleExistingCredential is false", () => { - const mockCredential = { type: "google.com" }; - const error = { - code: "auth/account-exists-with-different-credential", - credential: mockCredential, - }; - - expect(() => { - handleFirebaseError(mockUi as any, error); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error); - } catch (_e) { - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); - } - }); - - it("should not store credential when no credential in error", () => { - const error = { - code: "auth/account-exists-with-different-credential", - }; - - expect(() => { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true }); - } catch (_e) { - expect(window.sessionStorage.getItem("pendingCred")).toBeNull(); - } - }); - - // TODO: Create util for another language - it.skip("should include email in error and use translations when provided", () => { - const error = { - code: "auth/account-exists-with-different-credential", - customData: { email: "test@test.com" }, - }; - - const customUi = { - translations: { - "es-ES": { - errors: { - accountExistsWithDifferentCredential: "La cuenta ya existe con otras credenciales", - }, - }, - }, - locale: "es-ES", - }; - - expect(() => { - handleFirebaseError(customUi as any, error, { enableHandleExistingCredential: true }); - }).toThrow(FirebaseUIError); - - try { - handleFirebaseError(customUi as any, error, { enableHandleExistingCredential: true }); - } catch (e) { - expect(e).toBeInstanceOf(FirebaseUIError); - expect(e.code).toBe("auth/account-exists-with-different-credential"); - expect(e.message).toBe("La cuenta ya existe con otras credenciales"); - } - }); - }); - }); -}); diff --git a/packages/core/tests/unit/translations.test.ts b/packages/core/tests/unit/translations.test.ts deleted file mode 100644 index eff1de24..00000000 --- a/packages/core/tests/unit/translations.test.ts +++ /dev/null @@ -1,146 +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 { describe, it, expect, vi as _vi } from "vitest"; -import { getTranslation } from "../../src/translations"; -import { enUs } from "@firebase-ui/translations"; - -describe("getTranslation", () => { - it("should return default English translation when no custom translations provided", () => { - const mockUi = { - locale: enUs, - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("No account found with this email address"); - }); - - // TODO: Create util for another language - it.skip("should use custom translation when provided", () => { - const mockUi = { - translations: { - "es-ES": { - errors: { - userNotFound: "Usuario no encontrado", - }, - }, - }, - locale: "es-ES", - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("Usuario no encontrado"); - }); - - it.skip("should use custom translation in specified language", () => { - const mockUi = { - translations: { - "es-ES": { - errors: { - userNotFound: "Usuario no encontrado", - }, - }, - // "en-US": english.translations, - }, - locale: "es-ES", - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("Usuario no encontrado"); - }); - - // TODO: Create util for another language - it.skip("should fallback to English when specified language is not available", () => { - const mockUi = { - translations: { - // "en-US": english.translations, - }, - locale: "fr-FR", - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("No account found with this email address"); - }); - - it.skip("should fallback to default English when no custom translations match", () => { - const mockUi = { - translations: { - "es-ES": { - errors: {}, - }, - }, - locale: "es-ES", - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("No account found with this email address"); - }); - - it.skip("should work with different translation categories", () => { - const mockUi = { - translations: { - // "en-US": english.translations, - }, - locale: "en-US", - }; - - const errorTranslation = getTranslation(mockUi as any, "errors", "userNotFound"); - const labelTranslation = getTranslation(mockUi as any, "labels", "signIn"); - - expect(errorTranslation).toBe("No account found with this email address"); - expect(labelTranslation).toBe("Sign In"); - }); - - it.skip("should handle partial custom translations", () => { - const mockUi = { - translations: { - "es-ES": { - errors: { - userNotFound: "Usuario no encontrado", - }, - }, - // "en-US": english.translations, - }, - locale: "es-ES", - }; - - const translation1 = getTranslation(mockUi as any, "errors", "userNotFound"); - const translation2 = getTranslation(mockUi as any, "errors", "unknownError"); - - expect(translation1).toBe("Usuario no encontrado"); - expect(translation2).toBe("An unexpected error occurred"); - }); - - it.skip("should handle empty custom translations object", () => { - const mockUi = { - translations: {}, - locale: "en-US", - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("No account found with this email address"); - }); - - it.skip("should handle undefined custom translations", () => { - const mockUi = { - translations: undefined, - locale: enUs, - }; - - const translation = getTranslation(mockUi as any, "errors", "userNotFound"); - expect(translation).toBe("No account found with this email address"); - }); -}); diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts new file mode 100644 index 00000000..5c4360f9 --- /dev/null +++ b/packages/core/tests/utils.ts @@ -0,0 +1,19 @@ +import { vi } from "vitest"; + +import type { FirebaseApp } from "firebase/app"; +import type { Auth } from "firebase/auth"; +import { enUs } from "@firebase-ui/translations"; +import { FirebaseUIConfiguration } from "../src/config"; + +export function createMockUI(overrides?: Partial): FirebaseUIConfiguration { + return { + app: {} as FirebaseApp, + auth: {} as Auth, + setLocale: vi.fn(), + state: "idle", + setState: vi.fn(), + locale: enUs, + behaviors: {}, + ...overrides, + }; +} \ No newline at end of file diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 108ca38f..da2439da 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -31,6 +31,7 @@ "baseUrl": ".", "paths": { "~/*": ["./src/*"], + "~/tests/*": ["./tests/*"], "@firebase-ui/translations": ["../translations/src/index.ts"] } }, diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts index fbb4fb2b..b633fe9f 100644 --- a/packages/core/vitest.config.ts +++ b/packages/core/vitest.config.ts @@ -15,14 +15,13 @@ */ import { defineConfig } from "vitest/config"; +import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ test: { - // Use the same environment as the package + name: '@firebase-ui/core', environment: "jsdom", - // Include TypeScript files - include: ["tests/**/*.{test,spec}.{js,ts}"], - // Exclude build output and node_modules exclude: ["node_modules/**/*", "dist/**/*"], }, + plugins: [tsconfigPaths()], });