@@ -29,6 +29,8 @@ import com.google.firebase.FirebaseOptions
2929import com.google.firebase.auth.AuthResult
3030import com.google.firebase.auth.FirebaseUser
3131import com.google.firebase.auth.actionCodeSettings
32+ import org.json.JSONArray
33+ import org.json.JSONObject
3234import org.junit.After
3335import org.junit.Before
3436import org.junit.Rule
@@ -41,6 +43,8 @@ import org.robolectric.annotation.Config
4143import java.net.HttpURLConnection
4244import java.net.URL
4345
46+ private const val AUTH_STATE_WAIT_TIMEOUT_MS = 5_000L
47+
4448@Config(sdk = [34 ])
4549@RunWith(RobolectricTestRunner ::class )
4650class EmailAuthScreenTest {
@@ -52,6 +56,7 @@ class EmailAuthScreenTest {
5256 private lateinit var stringProvider: AuthUIStringProvider
5357
5458 lateinit var authUI: FirebaseAuthUI
59+ private lateinit var emulatorApi: EmulatorAuthApi
5560
5661 @Before
5762 fun setUp () {
@@ -67,7 +72,7 @@ class EmailAuthScreenTest {
6772 }
6873
6974 // Initialize default FirebaseApp
70- FirebaseApp .initializeApp(
75+ val firebaseApp = FirebaseApp .initializeApp(
7176 applicationContext,
7277 FirebaseOptions .Builder ()
7378 .setApiKey(" fake-api-key" )
@@ -79,6 +84,13 @@ class EmailAuthScreenTest {
7984 authUI = FirebaseAuthUI .getInstance()
8085 authUI.auth.useEmulator(" 127.0.0.1" , 9099 )
8186
87+ emulatorApi = EmulatorAuthApi (
88+ projectId = firebaseApp.options.projectId
89+ ? : throw IllegalStateException (" Project ID is required for emulator interactions" ),
90+ emulatorHost = " 127.0.0.1" ,
91+ emulatorPort = 9099
92+ )
93+
8294 // Clear emulator data
8395 clearEmulatorData()
8496 }
@@ -165,7 +177,7 @@ class EmailAuthScreenTest {
165177
166178 // Wait for auth state to transition to RequiresEmailVerification
167179 println (" TEST: Waiting for auth state change... Current state: $currentAuthState " )
168- composeTestRule.waitUntil {
180+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS ) {
169181 shadowOf(Looper .getMainLooper()).idle()
170182 println (" TEST: Auth state during wait: $currentAuthState " )
171183 currentAuthState is AuthState .RequiresEmailVerification
@@ -239,7 +251,7 @@ class EmailAuthScreenTest {
239251
240252 // Wait for auth state to transition to Success (since email is verified)
241253 println (" TEST: Waiting for auth state change... Current state: $currentAuthState " )
242- composeTestRule.waitUntil(timeoutMillis = 5000 ) {
254+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS ) {
243255 shadowOf(Looper .getMainLooper()).idle()
244256 println (" TEST: Auth state during wait: $currentAuthState " )
245257 currentAuthState is AuthState .Success
@@ -319,7 +331,7 @@ class EmailAuthScreenTest {
319331
320332 // Wait for auth state to transition to RequiresEmailVerification
321333 println (" TEST: Waiting for auth state change... Current state: $currentAuthState " )
322- composeTestRule.waitUntil {
334+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS ) {
323335 shadowOf(Looper .getMainLooper()).idle()
324336 println (" TEST: Auth state during wait: $currentAuthState " )
325337 currentAuthState is AuthState .RequiresEmailVerification
@@ -391,7 +403,7 @@ class EmailAuthScreenTest {
391403
392404 // Wait for auth state to transition to PasswordResetLinkSent
393405 println (" TEST: Waiting for auth state change... Current state: $currentAuthState " )
394- composeTestRule.waitUntil {
406+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS ) {
395407 shadowOf(Looper .getMainLooper()).idle()
396408 println (" TEST: Auth state during wait: $currentAuthState " )
397409 currentAuthState is AuthState .PasswordResetLinkSent
@@ -479,7 +491,7 @@ class EmailAuthScreenTest {
479491
480492 // Wait for auth state to transition to EmailSignInLinkSent
481493 println (" TEST: Waiting for auth state change... Current state: $currentAuthState " )
482- composeTestRule.waitUntil {
494+ composeTestRule.waitUntil(timeoutMillis = AUTH_STATE_WAIT_TIMEOUT_MS ) {
483495 shadowOf(Looper .getMainLooper()).idle()
484496 println (" TEST: Auth state during wait: $currentAuthState " )
485497 currentAuthState is AuthState .EmailSignInLinkSent
@@ -576,36 +588,14 @@ class EmailAuthScreenTest {
576588 * This function calls the emulator's clear data endpoint to remove all accounts,
577589 * OOB codes, and other authentication data. This ensures test isolation by providing
578590 * a clean slate for each test.
579- *
580- * @param emulatorHost The emulator host (default: "127.0.0.1")
581- * @param emulatorPort The emulator port (default: 9099)
582- *
583- * @throws Exception if the clear operation fails
584591 */
585- private fun clearEmulatorData (
586- projectId : String = "fake-project-id",
587- emulatorHost : String = "127.0.0.1",
588- emulatorPort : Int = 9099
589- ) {
590- val clearUrl =
591- URL (" http://$emulatorHost :$emulatorPort /emulator/v1/projects/$projectId /accounts" )
592- val clearConnection = clearUrl.openConnection() as HttpURLConnection
593-
594- try {
595- clearConnection.requestMethod = " DELETE"
596- clearConnection.connectTimeout = 5000
597- clearConnection.readTimeout = 5000
598-
599- val responseCode = clearConnection.responseCode
600- if (responseCode !in 200 .. 299 ) {
601- println (" WARNING: Failed to clear emulator data: HTTP $responseCode " )
602- } else {
603- println (" TEST: Cleared emulator data" )
592+ private fun clearEmulatorData () {
593+ if (::emulatorApi.isInitialized) {
594+ try {
595+ emulatorApi.clearAccounts()
596+ } catch (e: Exception ) {
597+ println (" WARNING: Exception while clearing emulator data: ${e.message} " )
604598 }
605- } catch (e: Exception ) {
606- println (" WARNING: Exception while clearing emulator data: ${e.message} " )
607- } finally {
608- clearConnection.disconnect()
609599 }
610600 }
611601
@@ -649,16 +639,9 @@ class EmailAuthScreenTest {
649639 * the real email verification flow that would occur in production.
650640 *
651641 * @param user The FirebaseUser whose email should be verified
652- * @param emulatorHost The emulator host (default: "127.0.0.1")
653- * @param emulatorPort The emulator port (default: 9099)
654- *
655642 * @throws Exception if the verification flow fails
656643 */
657- private fun verifyEmailInEmulator (
658- user : FirebaseUser ,
659- emulatorHost : String = "127.0.0.1",
660- emulatorPort : Int = 9099
661- ) {
644+ private fun verifyEmailInEmulator (user : FirebaseUser ) {
662645 println (" TEST: Starting email verification for user ${user.uid} " )
663646
664647 // Step 1: Send verification email to generate an OOB code
@@ -669,54 +652,105 @@ class EmailAuthScreenTest {
669652 shadowOf(Looper .getMainLooper()).idle()
670653 Thread .sleep(100 )
671654
672- // Step 2: Retrieve OOB codes from the emulator
673- val projectId = authUI.app.options.projectId
674- ? : throw IllegalStateException (" Project ID is required" )
675- println (" TEST: Using project ID: $projectId " )
655+ // Step 2: Retrieve the VERIFY_EMAIL OOB code for this user from the emulator
656+ val email = requireNotNull(user.email) { " User email is required for OOB code lookup" }
657+ val oobCode = emulatorApi.fetchVerifyEmailCode(email)
658+
659+ println (" TEST: Found OOB code: $oobCode " )
660+
661+ // Step 3: Apply the action code to verify the email
662+ authUI.auth.applyActionCode(oobCode).awaitWithLooper()
663+ println (" TEST: Applied action code" )
664+
665+ // Step 4: Reload the user to refresh their email verification status
666+ authUI.auth.currentUser?.reload()?.awaitWithLooper()
667+ shadowOf(Looper .getMainLooper()).idle()
668+
669+ println (" TEST: Email verified successfully for user ${user.uid} " )
670+ println (" TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified} " )
671+ }
676672
677- val oobUrl =
678- URL (" http://$emulatorHost :$emulatorPort /emulator/v1/projects/$projectId /oobCodes" )
679- val oobConnection = oobUrl.openConnection() as HttpURLConnection
673+ }
680674
681- val oobCodesJson = try {
682- oobConnection.requestMethod = " GET"
683- oobConnection.connectTimeout = 5000
684- oobConnection.readTimeout = 5000
675+ private class EmulatorAuthApi (
676+ private val projectId : String ,
677+ emulatorHost : String ,
678+ emulatorPort : Int
679+ ) {
685680
686- val responseCode = oobConnection.responseCode
687- if (responseCode != 200 ) {
681+ private val httpClient = HttpClient (host = emulatorHost, port = emulatorPort)
682+
683+ fun clearAccounts () {
684+ httpClient.delete(" /emulator/v1/projects/$projectId /accounts" ) { connection ->
685+ val responseCode = connection.responseCode
686+ if (responseCode !in 200 .. 299 ) {
687+ println (" WARNING: Failed to clear emulator data: HTTP $responseCode " )
688+ } else {
689+ println (" TEST: Cleared emulator data" )
690+ }
691+ }
692+ }
693+
694+ fun fetchVerifyEmailCode (email : String ): String {
695+ val oobCodes = fetchOobCodes()
696+ return (0 until oobCodes.length())
697+ .asSequence()
698+ .mapNotNull { index -> oobCodes.optJSONObject(index) }
699+ .firstOrNull { json ->
700+ json.optString(" email" ) == email &&
701+ json.optString(" requestType" ) == " VERIFY_EMAIL"
702+ }
703+ ?.optString(" oobCode" )
704+ ?.takeIf { it.isNotBlank() }
705+ ? : throw Exception (" No VERIFY_EMAIL OOB code found for user email: $email " )
706+ }
707+
708+ private fun fetchOobCodes (): JSONArray {
709+ val payload = httpClient.get(" /emulator/v1/projects/$projectId /oobCodes" ) { connection ->
710+ val responseCode = connection.responseCode
711+ if (responseCode != HttpURLConnection .HTTP_OK ) {
688712 throw Exception (" Failed to get OOB codes: HTTP $responseCode " )
689713 }
690714
691- oobConnection.inputStream.bufferedReader().readText()
692- } finally {
693- oobConnection.disconnect()
715+ connection.inputStream.bufferedReader().use { it.readText() }
694716 }
695717
696- println (" TEST: OOB codes response: $oobCodesJson " )
718+ return JSONObject (payload).optJSONArray(" oobCodes" ) ? : JSONArray ()
719+ }
720+ }
697721
698- // Step 3: Parse the response to find the VERIFY_EMAIL code for this user's email
699- // Response format: {"oobCodes": [{"email": "...", "oobCode": "...", "oobLink": "...", "requestType": "..."}]}
700- // We need to find an entry with both matching email AND requestType: "VERIFY_EMAIL"
701- val verifyEmailPattern =
702- """ "email":" ${user.email} ","requestType":"VERIFY_EMAIL","oobCode":"([^"]+)" """ .toRegex()
722+ private class HttpClient (
723+ private val host : String ,
724+ private val port : Int ,
725+ private val timeoutMs : Int = DEFAULT_TIMEOUT_MS
726+ ) {
703727
704- val oobCodeMatch = verifyEmailPattern.find(oobCodesJson)
705- val oobCode = oobCodeMatch?.groupValues?.get( 1 )
706- ? : throw Exception ( " No VERIFY_EMAIL OOB code found for user email: ${user.email} " )
728+ companion object {
729+ const val DEFAULT_TIMEOUT_MS = 5_000
730+ }
707731
708- println (" TEST: Found OOB code: $oobCode " )
732+ fun delete (path : String , block : (HttpURLConnection ) -> Unit ) {
733+ execute(path = path, method = " DELETE" , block = block)
734+ }
709735
710- // Step 4: Apply the action code to verify the email
711- authUI.auth.applyActionCode(oobCode).awaitWithLooper( )
712- println ( " TEST: Applied action code " )
736+ fun < T > get ( path : String , block : ( HttpURLConnection ) -> T ): T {
737+ return execute(path = path, method = " GET " , block = block )
738+ }
713739
714- // Step 5: Reload the user to refresh their email verification status
715- authUI.auth.currentUser?.reload()?.awaitWithLooper()
716- shadowOf(Looper .getMainLooper()).idle()
740+ private fun <T > execute (path : String , method : String , block : (HttpURLConnection ) -> T ): T {
741+ val connection = buildUrl(path).openConnection() as HttpURLConnection
742+ connection.requestMethod = method
743+ connection.connectTimeout = timeoutMs
744+ connection.readTimeout = timeoutMs
717745
718- println (" TEST: Email verified successfully for user ${user.uid} " )
719- println (" TEST: User isEmailVerified: ${authUI.auth.currentUser?.isEmailVerified} " )
746+ return try {
747+ block(connection)
748+ } finally {
749+ connection.disconnect()
750+ }
720751 }
721752
722- }
753+ private fun buildUrl (path : String ): URL {
754+ return URL (" http://$host :$port$path " )
755+ }
756+ }
0 commit comments