From 134270d8d3c5e85c627a09b21d8f49c968bcaa65 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 15 Sep 2025 09:19:44 +0200 Subject: [PATCH 1/2] feat: Core: FirebaseAuthUI Singleton & DI --- .../ui/auth/compose/FirebaseAuthUI.kt | 175 +++++++++++ .../ui/auth/compose/FirebaseAuthUITest.kt | 281 ++++++++++++++++++ 2 files changed, 456 insertions(+) create mode 100644 auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt create mode 100644 auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt new file mode 100644 index 000000000..7b4caa5ae --- /dev/null +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * 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. + */ + +package com.firebase.ui.auth.compose + +import androidx.annotation.RestrictTo +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import java.util.concurrent.ConcurrentHashMap + +/** + * The central class that coordinates all authentication operations for Firebase Auth UI Compose. + * This class manages UI state and provides methods for signing in, signing up, and managing + * user accounts. + * + *

Usage

+ * + * **Default app instance:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * ``` + * + * **Custom app instance:** + * ```kotlin + * val customApp = Firebase.app("secondary") + * val authUI = FirebaseAuthUI.getInstance(customApp) + * ``` + * + * **Multi-tenancy with custom auth:** + * ```kotlin + * val customAuth = Firebase.auth(customApp).apply { + * tenantId = "my-tenant-id" + * } + * val authUI = FirebaseAuthUI.create(customApp, customAuth) + * ``` + * + * @property app The [FirebaseApp] instance used for authentication + * @property auth The [FirebaseAuth] instance used for authentication operations + * + * @since 10.0.0 + */ +class FirebaseAuthUI private constructor( + val app: FirebaseApp, + val auth: FirebaseAuth +) { + companion object { + /** Cache for singleton instances per FirebaseApp. Thread-safe via ConcurrentHashMap. */ + private val instanceCache = ConcurrentHashMap() + + /** Special key for the default app instance to distinguish from named instances. */ + private const val DEFAULT_APP_KEY = "[DEFAULT]" + + /** + * Returns a cached singleton instance for the default Firebase app. + * + * This method ensures that the same instance is returned for the default app across the + * entire application lifecycle. The instance is lazily created on first access and cached + * for subsequent calls. + * + * **Example:** + * ```kotlin + * val authUI = FirebaseAuthUI.getInstance() + * val user = authUI.auth.currentUser + * ``` + * + * @return The cached [FirebaseAuthUI] instance for the default app + * @throws IllegalStateException if Firebase has not been initialized. Call + * `FirebaseApp.initializeApp(Context)` before using this method. + */ + @JvmStatic + fun getInstance(): FirebaseAuthUI { + val defaultApp = try { + FirebaseApp.getInstance() + } catch (e: IllegalStateException) { + throw IllegalStateException( + "Default FirebaseApp is not initialized. " + + "Make sure to call FirebaseApp.initializeApp(Context) first.", + e + ) + } + + return instanceCache.getOrPut(DEFAULT_APP_KEY) { + FirebaseAuthUI(defaultApp, Firebase.auth) + } + } + + /** + * Returns a cached instance for a specific Firebase app. + * + * Each [FirebaseApp] gets its own distinct instance that is cached for subsequent calls + * with the same app. This allows for multiple Firebase projects to be used within the + * same application. + * + * **Example:** + * ```kotlin + * val secondaryApp = Firebase.app("secondary") + * val authUI = FirebaseAuthUI.getInstance(secondaryApp) + * ``` + * + * @param app The [FirebaseApp] instance to use + * @return The cached [FirebaseAuthUI] instance for the specified app + */ + @JvmStatic + fun getInstance(app: FirebaseApp): FirebaseAuthUI { + val cacheKey = app.name + return instanceCache.getOrPut(cacheKey) { + FirebaseAuthUI(app, Firebase.auth(app)) + } + } + + /** + * Creates a new instance with custom configuration, useful for multi-tenancy. + * + * This method always returns a new instance and does **not** use caching, allowing for + * custom [FirebaseAuth] configurations such as tenant IDs or custom authentication states. + * Use this when you need fine-grained control over the authentication instance. + * + * **Example - Multi-tenancy:** + * ```kotlin + * val app = Firebase.app("tenant-app") + * val auth = Firebase.auth(app).apply { + * tenantId = "customer-tenant-123" + * } + * val authUI = FirebaseAuthUI.create(app, auth) + * ``` + * + * @param app The [FirebaseApp] instance to use + * @param auth The [FirebaseAuth] instance with custom configuration + * @return A new [FirebaseAuthUI] instance with the provided dependencies + */ + @JvmStatic + fun create(app: FirebaseApp, auth: FirebaseAuth): FirebaseAuthUI { + return FirebaseAuthUI(app, auth) + } + + /** + * Clears all cached instances. This method is intended for testing purposes only. + * + * @suppress This is an internal API and should not be used in production code. + * @RestrictTo RestrictTo.Scope.TESTS + */ + @JvmStatic + @RestrictTo(RestrictTo.Scope.TESTS) + internal fun clearInstanceCache() { + instanceCache.clear() + } + + /** + * Returns the current number of cached instances. This method is intended for testing + * purposes only. + * + * @return The number of cached [FirebaseAuthUI] instances + * @suppress This is an internal API and should not be used in production code. + * @RestrictTo RestrictTo.Scope.TESTS + */ + @JvmStatic + @RestrictTo(RestrictTo.Scope.TESTS) + internal fun getCacheSize(): Int { + return instanceCache.size + } + } +} \ No newline at end of file diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt new file mode 100644 index 000000000..069d16269 --- /dev/null +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -0,0 +1,281 @@ +/* + * Copyright 2025 Google Inc. All Rights Reserved. + * + * 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. + */ + +package com.firebase.ui.auth.compose + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.mock +import org.mockito.Mockito.mockStatic +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for [FirebaseAuthUI] covering singleton behavior, multi-app support, + * and custom authentication injection for multi-tenancy scenarios. + * + * @suppress Internal test class + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class FirebaseAuthUITest { + + @Mock + private lateinit var mockFirebaseApp: FirebaseApp + + @Mock + private lateinit var mockFirebaseAuth: FirebaseAuth + + @Mock + private lateinit var mockSecondaryApp: FirebaseApp + + @Mock + private lateinit var mockSecondaryAuth: FirebaseAuth + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + + // Clear the instance cache before each test to ensure test isolation + FirebaseAuthUI.clearInstanceCache() + + // Setup mock app names + `when`(mockFirebaseApp.name).thenReturn("[DEFAULT]") + `when`(mockSecondaryApp.name).thenReturn("secondary") + } + + @After + fun tearDown() { + // Clean up after each test to prevent test pollution + FirebaseAuthUI.clearInstanceCache() + } + + // ============================================================================================= + // Singleton Behavior Tests + // ============================================================================================= + + @Test + fun `getInstance() returns same instance for default app`() { + // Mock the static FirebaseApp.getInstance() method + mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> + firebaseAppMock.`when` { FirebaseApp.getInstance() } + .thenReturn(mockFirebaseApp) + + // Mock Firebase.auth property + mockStatic(Firebase::class.java).use { firebaseMock -> + firebaseMock.`when` { Firebase.auth } + .thenReturn(mockFirebaseAuth) + + // Get instance twice + val instance1 = FirebaseAuthUI.getInstance() + val instance2 = FirebaseAuthUI.getInstance() + + // Verify they are the same instance (singleton pattern) + assertThat(instance1).isSameInstanceAs(instance2) + assertThat(instance1.app).isSameInstanceAs(mockFirebaseApp) + assertThat(instance1.auth).isSameInstanceAs(mockFirebaseAuth) + + // Verify only one instance is cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) + } + } + } + + @Test + fun `getInstance() throws descriptive exception when Firebase not initialized`() { + mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> + firebaseAppMock.`when` { FirebaseApp.getInstance() } + .thenThrow(IllegalStateException("Firebase not initialized")) + + // Verify exception is thrown with helpful message + try { + FirebaseAuthUI.getInstance() + // Should not reach here + assertThat(false).isTrue() + } catch (e: IllegalStateException) { + assertThat(e.message).contains("Default FirebaseApp is not initialized") + assertThat(e.message).contains("FirebaseApp.initializeApp(Context)") + assertThat(e.cause).isNotNull() + } + } + } + + // ============================================================================================= + // Multi-App Support Tests + // ============================================================================================= + + @Test + fun `getInstance(app) returns distinct instances per FirebaseApp`() { + mockStatic(Firebase::class.java).use { firebaseMock -> + // Setup different auth instances for different apps + firebaseMock.`when` { + Firebase.auth(mockFirebaseApp) + }.thenReturn(mockFirebaseAuth) + + firebaseMock.`when` { + Firebase.auth(mockSecondaryApp) + }.thenReturn(mockSecondaryAuth) + + // Get instances for different apps + val defaultInstance = FirebaseAuthUI.getInstance(mockFirebaseApp) + val secondaryInstance = FirebaseAuthUI.getInstance(mockSecondaryApp) + + // Verify they are different instances + assertThat(defaultInstance).isNotSameInstanceAs(secondaryInstance) + + // Verify correct apps and auth instances are used + assertThat(defaultInstance.app).isSameInstanceAs(mockFirebaseApp) + assertThat(defaultInstance.auth).isSameInstanceAs(mockFirebaseAuth) + assertThat(secondaryInstance.app).isSameInstanceAs(mockSecondaryApp) + assertThat(secondaryInstance.auth).isSameInstanceAs(mockSecondaryAuth) + + // Verify both instances are cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2) + } + } + + @Test + fun `getInstance(app) returns same instance for same app`() { + mockStatic(Firebase::class.java).use { firebaseMock -> + firebaseMock.`when` { + Firebase.auth(mockFirebaseApp) + }.thenReturn(mockFirebaseAuth) + + // Get instance twice for the same app + val instance1 = FirebaseAuthUI.getInstance(mockFirebaseApp) + val instance2 = FirebaseAuthUI.getInstance(mockFirebaseApp) + + // Verify they are the same instance (caching works) + assertThat(instance1).isSameInstanceAs(instance2) + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) + } + } + + // ============================================================================================= + // Custom Auth Injection Tests + // ============================================================================================= + + @Test + fun `create() returns new instance with provided dependencies`() { + // Create instances with custom auth + val instance1 = FirebaseAuthUI.create(mockFirebaseApp, mockFirebaseAuth) + val instance2 = FirebaseAuthUI.create(mockFirebaseApp, mockFirebaseAuth) + + // Verify they are different instances (no caching) + assertThat(instance1).isNotSameInstanceAs(instance2) + + // Verify correct dependencies are used + assertThat(instance1.app).isSameInstanceAs(mockFirebaseApp) + assertThat(instance1.auth).isSameInstanceAs(mockFirebaseAuth) + assertThat(instance2.app).isSameInstanceAs(mockFirebaseApp) + assertThat(instance2.auth).isSameInstanceAs(mockFirebaseAuth) + + // Verify cache is not used for create() + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(0) + } + + @Test + fun `create() allows custom auth injection for multi-tenancy`() { + // Create mock custom auth with tenant + val customAuth = mock(FirebaseAuth::class.java) + `when`(customAuth.tenantId).thenReturn("customer-tenant-123") + + // Create instance with custom auth + val instance = FirebaseAuthUI.create(mockFirebaseApp, customAuth) + + // Verify custom auth is used + assertThat(instance.auth).isSameInstanceAs(customAuth) + assertThat(instance.auth.tenantId).isEqualTo("customer-tenant-123") + } + + // ============================================================================================= + // Cache Isolation Tests + // ============================================================================================= + + @Test + fun `getInstance() and getInstance(app) use separate cache entries`() { + mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> + firebaseAppMock.`when` { FirebaseApp.getInstance() } + .thenReturn(mockFirebaseApp) + + mockStatic(Firebase::class.java).use { firebaseMock -> + firebaseMock.`when` { Firebase.auth } + .thenReturn(mockFirebaseAuth) + firebaseMock.`when` { + Firebase.auth(mockFirebaseApp) + }.thenReturn(mockFirebaseAuth) + + // Get default instance via getInstance() + val defaultInstance1 = FirebaseAuthUI.getInstance() + + // Get instance for default app via getInstance(app) + val defaultInstance2 = FirebaseAuthUI.getInstance(mockFirebaseApp) + + // They should be different cached instances even though they're for the same app + // because getInstance() uses a special cache key "[DEFAULT]" + assertThat(defaultInstance1).isNotSameInstanceAs(defaultInstance2) + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2) + } + } + } + + // ============================================================================================= + // Thread Safety Tests + // ============================================================================================= + + @Test + fun `getInstance() is thread-safe`() { + mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> + firebaseAppMock.`when` { FirebaseApp.getInstance() } + .thenReturn(mockFirebaseApp) + + mockStatic(Firebase::class.java).use { firebaseMock -> + firebaseMock.`when` { Firebase.auth } + .thenReturn(mockFirebaseAuth) + + val instances = mutableListOf() + val threads = List(10) { + Thread { + instances.add(FirebaseAuthUI.getInstance()) + } + } + + // Start all threads concurrently + threads.forEach { it.start() } + + // Wait for all threads to complete + threads.forEach { it.join() } + + // All instances should be the same (thread-safe singleton) + val firstInstance = instances.first() + instances.forEach { instance -> + assertThat(instance).isSameInstanceAs(firstInstance) + } + + // Only one instance should be cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) + } + } + } +} \ No newline at end of file From 97a9b0836e751bbf7c33e756fff414ddb4ff6973 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 15 Sep 2025 14:33:34 +0200 Subject: [PATCH 2/2] clean --- .../ui/auth/compose/FirebaseAuthUI.kt | 2 +- .../ui/auth/compose/FirebaseAuthUITest.kt | 355 ++++++++++-------- 2 files changed, 201 insertions(+), 156 deletions(-) diff --git a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt index 7b4caa5ae..79e8b2cfe 100644 --- a/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt +++ b/auth/src/main/java/com/firebase/ui/auth/compose/FirebaseAuthUI.kt @@ -61,7 +61,7 @@ class FirebaseAuthUI private constructor( private val instanceCache = ConcurrentHashMap() /** Special key for the default app instance to distinguish from named instances. */ - private const val DEFAULT_APP_KEY = "[DEFAULT]" + private const val DEFAULT_APP_KEY = "__FIREBASE_UI_DEFAULT__" /** * Returns a cached singleton instance for the default Firebase app. diff --git a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt index 069d16269..277d10a95 100644 --- a/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt +++ b/auth/src/test/java/com/firebase/ui/auth/compose/FirebaseAuthUITest.kt @@ -14,11 +14,11 @@ package com.firebase.ui.auth.compose +import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import com.google.firebase.FirebaseApp +import com.google.firebase.FirebaseOptions import com.google.firebase.auth.FirebaseAuth -import com.google.firebase.auth.ktx.auth -import com.google.firebase.ktx.Firebase import org.junit.After import org.junit.Before import org.junit.Test @@ -26,7 +26,6 @@ import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.`when` import org.mockito.Mockito.mock -import org.mockito.Mockito.mockStatic import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -41,34 +40,63 @@ import org.robolectric.annotation.Config @Config(manifest = Config.NONE) class FirebaseAuthUITest { - @Mock - private lateinit var mockFirebaseApp: FirebaseApp - @Mock private lateinit var mockFirebaseAuth: FirebaseAuth - @Mock - private lateinit var mockSecondaryApp: FirebaseApp - - @Mock - private lateinit var mockSecondaryAuth: FirebaseAuth + private lateinit var defaultApp: FirebaseApp + private lateinit var secondaryApp: FirebaseApp @Before fun setUp() { - MockitoAnnotations.openMocks(this) + MockitoAnnotations.initMocks(this) // Clear the instance cache before each test to ensure test isolation FirebaseAuthUI.clearInstanceCache() - // Setup mock app names - `when`(mockFirebaseApp.name).thenReturn("[DEFAULT]") - `when`(mockSecondaryApp.name).thenReturn("secondary") + // Clear any existing Firebase apps + val context = ApplicationProvider.getApplicationContext() + FirebaseApp.getApps(context).forEach { app -> + app.delete() + } + + // Initialize default FirebaseApp + defaultApp = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key") + .setApplicationId("fake-app-id") + .setProjectId("fake-project-id") + .build() + ) + + // Initialize secondary FirebaseApp + secondaryApp = FirebaseApp.initializeApp( + context, + FirebaseOptions.Builder() + .setApiKey("fake-api-key-2") + .setApplicationId("fake-app-id-2") + .setProjectId("fake-project-id-2") + .build(), + "secondary" + ) } @After fun tearDown() { // Clean up after each test to prevent test pollution FirebaseAuthUI.clearInstanceCache() + + // Clean up Firebase apps + try { + defaultApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } + try { + secondaryApp.delete() + } catch (_: Exception) { + // Ignore if already deleted + } } // ============================================================================================= @@ -77,48 +105,26 @@ class FirebaseAuthUITest { @Test fun `getInstance() returns same instance for default app`() { - // Mock the static FirebaseApp.getInstance() method - mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> - firebaseAppMock.`when` { FirebaseApp.getInstance() } - .thenReturn(mockFirebaseApp) - - // Mock Firebase.auth property - mockStatic(Firebase::class.java).use { firebaseMock -> - firebaseMock.`when` { Firebase.auth } - .thenReturn(mockFirebaseAuth) - - // Get instance twice - val instance1 = FirebaseAuthUI.getInstance() - val instance2 = FirebaseAuthUI.getInstance() - - // Verify they are the same instance (singleton pattern) - assertThat(instance1).isSameInstanceAs(instance2) - assertThat(instance1.app).isSameInstanceAs(mockFirebaseApp) - assertThat(instance1.auth).isSameInstanceAs(mockFirebaseAuth) - - // Verify only one instance is cached - assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) - } - } + // Get instance twice + val instance1 = FirebaseAuthUI.getInstance() + val instance2 = FirebaseAuthUI.getInstance() + + // Verify they are the same instance (singleton pattern) + assertThat(instance1).isEqualTo(instance2) + assertThat(instance1.app.name).isEqualTo(FirebaseApp.DEFAULT_APP_NAME) + + // Verify only one instance is cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) } @Test - fun `getInstance() throws descriptive exception when Firebase not initialized`() { - mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> - firebaseAppMock.`when` { FirebaseApp.getInstance() } - .thenThrow(IllegalStateException("Firebase not initialized")) - - // Verify exception is thrown with helpful message - try { - FirebaseAuthUI.getInstance() - // Should not reach here - assertThat(false).isTrue() - } catch (e: IllegalStateException) { - assertThat(e.message).contains("Default FirebaseApp is not initialized") - assertThat(e.message).contains("FirebaseApp.initializeApp(Context)") - assertThat(e.cause).isNotNull() - } - } + fun `getInstance() works with initialized Firebase app`() { + // Ensure we can get an instance when Firebase is properly initialized + val instance = FirebaseAuthUI.getInstance() + + // Verify the instance uses the default app + assertThat(instance.app).isEqualTo(defaultApp) + assertThat(instance.auth).isNotNull() } // ============================================================================================= @@ -127,49 +133,40 @@ class FirebaseAuthUITest { @Test fun `getInstance(app) returns distinct instances per FirebaseApp`() { - mockStatic(Firebase::class.java).use { firebaseMock -> - // Setup different auth instances for different apps - firebaseMock.`when` { - Firebase.auth(mockFirebaseApp) - }.thenReturn(mockFirebaseAuth) - - firebaseMock.`when` { - Firebase.auth(mockSecondaryApp) - }.thenReturn(mockSecondaryAuth) - - // Get instances for different apps - val defaultInstance = FirebaseAuthUI.getInstance(mockFirebaseApp) - val secondaryInstance = FirebaseAuthUI.getInstance(mockSecondaryApp) - - // Verify they are different instances - assertThat(defaultInstance).isNotSameInstanceAs(secondaryInstance) - - // Verify correct apps and auth instances are used - assertThat(defaultInstance.app).isSameInstanceAs(mockFirebaseApp) - assertThat(defaultInstance.auth).isSameInstanceAs(mockFirebaseAuth) - assertThat(secondaryInstance.app).isSameInstanceAs(mockSecondaryApp) - assertThat(secondaryInstance.auth).isSameInstanceAs(mockSecondaryAuth) - - // Verify both instances are cached - assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2) - } + // Get instances for different apps + val defaultInstance = FirebaseAuthUI.getInstance(defaultApp) + val secondaryInstance = FirebaseAuthUI.getInstance(secondaryApp) + + // Verify they are different instances + assertThat(defaultInstance).isNotEqualTo(secondaryInstance) + + // Verify correct apps are used + assertThat(defaultInstance.app).isEqualTo(defaultApp) + assertThat(secondaryInstance.app).isEqualTo(secondaryApp) + + // Verify both instances are cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2) } @Test fun `getInstance(app) returns same instance for same app`() { - mockStatic(Firebase::class.java).use { firebaseMock -> - firebaseMock.`when` { - Firebase.auth(mockFirebaseApp) - }.thenReturn(mockFirebaseAuth) - - // Get instance twice for the same app - val instance1 = FirebaseAuthUI.getInstance(mockFirebaseApp) - val instance2 = FirebaseAuthUI.getInstance(mockFirebaseApp) - - // Verify they are the same instance (caching works) - assertThat(instance1).isSameInstanceAs(instance2) - assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) - } + // Get instance twice for the same app + val instance1 = FirebaseAuthUI.getInstance(defaultApp) + val instance2 = FirebaseAuthUI.getInstance(defaultApp) + + // Verify they are the same instance (caching works) + assertThat(instance1).isEqualTo(instance2) + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) + } + + @Test + fun `getInstance(app) with secondary app returns correct instance`() { + // Get instance for secondary app + val instance = FirebaseAuthUI.getInstance(secondaryApp) + + // Verify correct app is used + assertThat(instance.app).isEqualTo(secondaryApp) + assertThat(instance.app.name).isEqualTo("secondary") } // ============================================================================================= @@ -179,17 +176,17 @@ class FirebaseAuthUITest { @Test fun `create() returns new instance with provided dependencies`() { // Create instances with custom auth - val instance1 = FirebaseAuthUI.create(mockFirebaseApp, mockFirebaseAuth) - val instance2 = FirebaseAuthUI.create(mockFirebaseApp, mockFirebaseAuth) + val instance1 = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) + val instance2 = FirebaseAuthUI.create(defaultApp, mockFirebaseAuth) // Verify they are different instances (no caching) - assertThat(instance1).isNotSameInstanceAs(instance2) + assertThat(instance1).isNotEqualTo(instance2) // Verify correct dependencies are used - assertThat(instance1.app).isSameInstanceAs(mockFirebaseApp) - assertThat(instance1.auth).isSameInstanceAs(mockFirebaseAuth) - assertThat(instance2.app).isSameInstanceAs(mockFirebaseApp) - assertThat(instance2.auth).isSameInstanceAs(mockFirebaseAuth) + assertThat(instance1.app).isEqualTo(defaultApp) + assertThat(instance1.auth).isEqualTo(mockFirebaseAuth) + assertThat(instance2.app).isEqualTo(defaultApp) + assertThat(instance2.auth).isEqualTo(mockFirebaseAuth) // Verify cache is not used for create() assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(0) @@ -202,42 +199,75 @@ class FirebaseAuthUITest { `when`(customAuth.tenantId).thenReturn("customer-tenant-123") // Create instance with custom auth - val instance = FirebaseAuthUI.create(mockFirebaseApp, customAuth) + val instance = FirebaseAuthUI.create(defaultApp, customAuth) // Verify custom auth is used - assertThat(instance.auth).isSameInstanceAs(customAuth) + assertThat(instance.auth).isEqualTo(customAuth) assertThat(instance.auth.tenantId).isEqualTo("customer-tenant-123") } + @Test + fun `create() with different auth instances returns different FirebaseAuthUI instances`() { + // Create two different mock auth instances + val auth1 = mock(FirebaseAuth::class.java) + val auth2 = mock(FirebaseAuth::class.java) + + // Create instances with different auth + val instance1 = FirebaseAuthUI.create(defaultApp, auth1) + val instance2 = FirebaseAuthUI.create(defaultApp, auth2) + + // Verify they are different instances + assertThat(instance1).isNotEqualTo(instance2) + assertThat(instance1.auth).isEqualTo(auth1) + assertThat(instance2.auth).isEqualTo(auth2) + } + // ============================================================================================= // Cache Isolation Tests // ============================================================================================= @Test - fun `getInstance() and getInstance(app) use separate cache entries`() { - mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> - firebaseAppMock.`when` { FirebaseApp.getInstance() } - .thenReturn(mockFirebaseApp) - - mockStatic(Firebase::class.java).use { firebaseMock -> - firebaseMock.`when` { Firebase.auth } - .thenReturn(mockFirebaseAuth) - firebaseMock.`when` { - Firebase.auth(mockFirebaseApp) - }.thenReturn(mockFirebaseAuth) - - // Get default instance via getInstance() - val defaultInstance1 = FirebaseAuthUI.getInstance() - - // Get instance for default app via getInstance(app) - val defaultInstance2 = FirebaseAuthUI.getInstance(mockFirebaseApp) - - // They should be different cached instances even though they're for the same app - // because getInstance() uses a special cache key "[DEFAULT]" - assertThat(defaultInstance1).isNotSameInstanceAs(defaultInstance2) - assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2) - } - } + fun `getInstance() and getInstance(app) use separate cache entries for default app`() { + // Get default instance via getInstance() + val defaultInstance1 = FirebaseAuthUI.getInstance() + + // Get instance for default app via getInstance(app) + val defaultInstance2 = FirebaseAuthUI.getInstance(defaultApp) + + // They should be different cached instances even though they're for the same app + // because getInstance() uses a special cache key "[DEFAULT]" + assertThat(defaultInstance1).isNotEqualTo(defaultInstance2) + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(2) + + // But they should use the same underlying FirebaseApp + assertThat(defaultInstance1.app).isEqualTo(defaultInstance2.app) + } + + @Test + fun `cache is properly isolated between different apps`() { + // Create instances for different apps + val instance1 = FirebaseAuthUI.getInstance() + val instance2 = FirebaseAuthUI.getInstance(defaultApp) + val instance3 = FirebaseAuthUI.getInstance(secondaryApp) + + // Verify all three instances are different + assertThat(instance1).isNotEqualTo(instance2) + assertThat(instance2).isNotEqualTo(instance3) + assertThat(instance1).isNotEqualTo(instance3) + + // Verify cache size + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(3) + + // Clear cache + FirebaseAuthUI.clearInstanceCache() + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(0) + + // Create new instances - should be different objects than before + val newInstance1 = FirebaseAuthUI.getInstance() + val newInstance2 = FirebaseAuthUI.getInstance(defaultApp) + + assertThat(newInstance1).isNotEqualTo(instance1) + assertThat(newInstance2).isNotEqualTo(instance2) } // ============================================================================================= @@ -246,36 +276,51 @@ class FirebaseAuthUITest { @Test fun `getInstance() is thread-safe`() { - mockStatic(FirebaseApp::class.java).use { firebaseAppMock -> - firebaseAppMock.`when` { FirebaseApp.getInstance() } - .thenReturn(mockFirebaseApp) - - mockStatic(Firebase::class.java).use { firebaseMock -> - firebaseMock.`when` { Firebase.auth } - .thenReturn(mockFirebaseAuth) - - val instances = mutableListOf() - val threads = List(10) { - Thread { - instances.add(FirebaseAuthUI.getInstance()) - } - } - - // Start all threads concurrently - threads.forEach { it.start() } - - // Wait for all threads to complete - threads.forEach { it.join() } - - // All instances should be the same (thread-safe singleton) - val firstInstance = instances.first() - instances.forEach { instance -> - assertThat(instance).isSameInstanceAs(firstInstance) - } - - // Only one instance should be cached - assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) + val instances = mutableListOf() + val threads = List(10) { + Thread { + instances.add(FirebaseAuthUI.getInstance()) } } + + // Start all threads concurrently + threads.forEach { it.start() } + + // Wait for all threads to complete + threads.forEach { it.join() } + + // All instances should be the same (thread-safe singleton) + val firstInstance = instances.first() + instances.forEach { instance -> + assertThat(instance).isEqualTo(firstInstance) + } + + // Only one instance should be cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) + } + + @Test + fun `getInstance(app) is thread-safe`() { + val instances = mutableListOf() + val threads = List(10) { + Thread { + instances.add(FirebaseAuthUI.getInstance(secondaryApp)) + } + } + + // Start all threads concurrently + threads.forEach { it.start() } + + // Wait for all threads to complete + threads.forEach { it.join() } + + // All instances should be the same (thread-safe singleton) + val firstInstance = instances.first() + instances.forEach { instance -> + assertThat(instance).isEqualTo(firstInstance) + } + + // Only one instance should be cached + assertThat(FirebaseAuthUI.getCacheSize()).isEqualTo(1) } } \ No newline at end of file