From 9869ad2476afa0106c21241cdbbe9cc6bd91c353 Mon Sep 17 00:00:00 2001 From: beetzung Date: Mon, 6 Apr 2026 19:30:31 +0200 Subject: [PATCH] feat(auth): email sign-in and register via GraphQL Add loginLocal and register Apollo mutations, LocalEmailAuthStore for profile snapshot when not using Firebase, extend AuthService/FirebaseAuthService with session handoff vs Google, wire EmailLoginViewModel, refresh Manage on resume, and expand unit tests. Made-with: Cursor --- .../features/login/EmailLoginViewModel.kt | 57 ++++++++++--- .../db3/airmq/features/manage/ManageScreen.kt | 14 ++++ .../airmq/features/manage/ManageViewModel.kt | 2 +- app/src/main/res/values-be-rBY/strings.xml | 3 + app/src/main/res/values-ru-rRU/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + sdk/src/main/graphql/LoginLocal.graphql | 11 +++ sdk/src/main/graphql/Register.graphql | 11 +++ .../airmq/sdk/auth/AirMqAuthPreferences.kt | 5 ++ .../org/db3/airmq/sdk/auth/AuthService.kt | 2 + .../org/db3/airmq/sdk/auth/AuthServiceImpl.kt | 83 +++++++++++++++++-- .../db3/airmq/sdk/auth/LocalEmailAuthStore.kt | 13 +++ .../auth/SharedPreferencesApiTokenStore.kt | 3 +- .../SharedPreferencesLocalEmailAuthStore.kt | 48 +++++++++++ .../kotlin/org/db3/airmq/sdk/di/SDKModule.kt | 6 ++ .../airmq/sdk/auth/FirebaseAuthServiceTest.kt | 81 ++++++++++++++++-- 16 files changed, 319 insertions(+), 26 deletions(-) create mode 100644 sdk/src/main/graphql/LoginLocal.graphql create mode 100644 sdk/src/main/graphql/Register.graphql create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt create mode 100644 sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt diff --git a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt index 47a07ce..beddcbd 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/login/EmailLoginViewModel.kt @@ -1,7 +1,9 @@ package org.db3.airmq.features.login import android.content.Context +import android.util.Patterns import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -11,14 +13,17 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import org.db3.airmq.R import org.db3.airmq.features.login.EmailLoginScreenContract.Action import org.db3.airmq.features.login.EmailLoginScreenContract.Event import org.db3.airmq.features.login.EmailLoginScreenContract.State +import org.db3.airmq.sdk.auth.AuthService @HiltViewModel class EmailLoginViewModel @Inject constructor( - @ApplicationContext private val appContext: Context + @ApplicationContext private val appContext: Context, + private val authService: AuthService ) : ViewModel() { private val _uiState = MutableStateFlow(State()) @@ -35,19 +40,45 @@ class EmailLoginViewModel @Inject constructor( is Event.PasswordChanged -> { _uiState.value = _uiState.value.copy(password = event.value) } - Event.SignInClicked -> { - _uiState.value = _uiState.value.copy(isLoading = true) - _actions.tryEmit( - Action.ShowMessage(appContext.getString(R.string.coming_soon)) - ) - _uiState.value = _uiState.value.copy(isLoading = false) - } - Event.RegisterClicked -> { - _actions.tryEmit( - Action.ShowMessage(appContext.getString(R.string.coming_soon)) - ) - } + Event.SignInClicked -> submitEmailAuth(register = false) + Event.RegisterClicked -> submitEmailAuth(register = true) Event.BackClicked -> Unit } } + + private fun submitEmailAuth(register: Boolean) { + val email = _uiState.value.email.trim() + val password = _uiState.value.password + if (email.isEmpty() || password.isEmpty()) { + _actions.tryEmit( + Action.ShowMessage(appContext.getString(R.string.toast_email_fields_required)) + ) + return + } + if (!Patterns.EMAIL_ADDRESS.matcher(email).matches()) { + _actions.tryEmit( + Action.ShowMessage(appContext.getString(R.string.toast_invalid_email)) + ) + return + } + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isLoading = true) + try { + val result = if (register) { + authService.registerWithEmail(email, password, name = "") + } else { + authService.loginWithEmailPassword(email, password) + } + if (result.isSuccess) { + _actions.tryEmit(Action.OpenManage) + } else { + val message = result.exceptionOrNull()?.message + ?: appContext.getString(R.string.toast_email_auth_failed) + _actions.tryEmit(Action.ShowMessage(message)) + } + } finally { + _uiState.value = _uiState.value.copy(isLoading = false) + } + } + } } diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt index 0b3c1b6..d3aac9d 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt @@ -25,9 +25,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -63,6 +67,16 @@ fun ManageScreen( viewModel: ManageViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + viewModel.refreshAuthState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } LaunchedEffect(viewModel) { viewModel.actions.collectLatest { action -> when (action) { diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt index 1095c6c..72d8ed9 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt @@ -71,7 +71,7 @@ class ManageViewModel @Inject constructor( private fun initialState(): State = anonymousState() - private fun refreshAuthState() { + fun refreshAuthState() { viewModelScope.launch { val session = authService.getUser() if (session?.isAuthenticated == true) { diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 10b9ce4..6cf8598 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -82,6 +82,9 @@ Ваша прылада адключылася ад сеткі! %s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады Памылка аўтарызацыі Google + Увядзіце email і пароль + Увядзіце карэктны email + Не ўдалося ўвайсці Патрабуецца рэгістрацыя Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s Што гэта значыць? diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index dd6df1e..670c848 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -82,6 +82,9 @@ Ваше устройство отключилось от сети! %s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства Ошибка авторизации Google + Введите email и пароль + Введите корректный email + Не удалось войти Требуется регистрация Входя в систему, вы соглашаетесь с нашими %1$s и %2$s Что это значит? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e524e3e..1efbfbf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,6 +142,9 @@ Google authorisation failed + Enter email and password + Enter a valid email address + Sign-in failed Copied Device requires registration diff --git a/sdk/src/main/graphql/LoginLocal.graphql b/sdk/src/main/graphql/LoginLocal.graphql new file mode 100644 index 0000000..48da0b7 --- /dev/null +++ b/sdk/src/main/graphql/LoginLocal.graphql @@ -0,0 +1,11 @@ +mutation LoginLocal($input: LocalAuthInput!) { + loginLocal(input: $input) { + token + name + email + roles + _id + regDate + emailVerified + } +} diff --git a/sdk/src/main/graphql/Register.graphql b/sdk/src/main/graphql/Register.graphql new file mode 100644 index 0000000..05f7421 --- /dev/null +++ b/sdk/src/main/graphql/Register.graphql @@ -0,0 +1,11 @@ +mutation Register($input: RegisterInput!) { + register(input: $input) { + token + name + email + roles + _id + regDate + emailVerified + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt new file mode 100644 index 0000000..dd7b4eb --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AirMqAuthPreferences.kt @@ -0,0 +1,5 @@ +package org.db3.airmq.sdk.auth + +internal object AirMqAuthPreferences { + const val FILE_NAME = "airmq_auth" +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt index df1368f..70ed1a7 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthService.kt @@ -7,5 +7,7 @@ interface AuthService { suspend fun getUser(): User? suspend fun isAuthenticated(): Boolean suspend fun signIn(provider: AuthProvider, token: String): Result + suspend fun loginWithEmailPassword(email: String, password: String): Result + suspend fun registerWithEmail(email: String, password: String, name: String): Result suspend fun signOut(): Result } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt index 1370275..eece682 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/AuthServiceImpl.kt @@ -1,25 +1,40 @@ package org.db3.airmq.sdk.auth import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.Optional import javax.inject.Inject import javax.inject.Singleton import org.db3.airmq.sdk.AuthGoogleNewMutation +import org.db3.airmq.sdk.LoginLocalMutation +import org.db3.airmq.sdk.RegisterMutation import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.User +import org.db3.airmq.sdk.type.LocalAuthInput +import org.db3.airmq.sdk.type.RegisterInput @Singleton class FirebaseAuthService @Inject constructor( private val firebaseSessionManager: FirebaseSessionManager, private val apolloClient: ApolloClient, - private val apiTokenStore: ApiTokenStore + private val apiTokenStore: ApiTokenStore, + private val localEmailAuthStore: LocalEmailAuthStore ) : AuthService { - override suspend fun getUser(): User? = firebaseSessionManager.getUser() + override suspend fun getUser(): User? { + firebaseSessionManager.getUser()?.let { return it } + if (apiTokenStore.getToken().isNullOrBlank()) return null + val profile = localEmailAuthStore.getProfile() ?: return null + return User( + userId = profile.userId, + email = profile.email, + displayName = profile.displayName, + isAuthenticated = true + ) + } override suspend fun isAuthenticated(): Boolean { - val hasFirebaseUser = firebaseSessionManager.isSignedIn() - val hasApiToken = !apiTokenStore.getToken().isNullOrBlank() - return hasFirebaseUser && hasApiToken + if (apiTokenStore.getToken().isNullOrBlank()) return false + return getUser() != null } override suspend fun signIn(provider: AuthProvider, token: String): Result = runCatching { @@ -29,18 +44,76 @@ class FirebaseAuthService @Inject constructor( } } + override suspend fun loginWithEmailPassword(email: String, password: String): Result = runCatching { + val response = apolloClient + .mutation(LoginLocalMutation(LocalAuthInput(email = email, password = password))) + .execute() + response.exception?.let { throw it } + response.errors?.firstOrNull()?.let { gqlError -> + throw IllegalStateException(gqlError.message) + } + val auth = response.data?.loginLocal + ?: error("Login response missing data.") + commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name) + } + + override suspend fun registerWithEmail(email: String, password: String, name: String): Result = runCatching { + val input = RegisterInput( + email = email, + password = password, + name = Optional.present(name) + ) + val response = apolloClient + .mutation(RegisterMutation(input)) + .execute() + response.exception?.let { throw it } + response.errors?.firstOrNull()?.let { gqlError -> + throw IllegalStateException(gqlError.message) + } + val auth = response.data?.register + ?: error("Register response missing data.") + commitEmailAuthSession(auth.token, auth._id, auth.email, auth.name) + } + override suspend fun signOut(): Result = runCatching { firebaseSessionManager.signOut() apiTokenStore.clearToken().getOrThrow() + localEmailAuthStore.clearProfile().getOrThrow() } private suspend fun signInWithGoogle(token: String): User { + localEmailAuthStore.clearProfile().getOrThrow() val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token) val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken) apiTokenStore.saveToken(apiToken).getOrThrow() return firebaseSessionUser.user } + private suspend fun commitEmailAuthSession( + token: String?, + userId: String?, + email: String?, + displayName: String? + ): User { + val resolvedToken = token?.takeIf { it.isNotBlank() } + ?: error("Backend auth succeeded without API token.") + val resolvedUserId = userId?.takeIf { it.isNotBlank() } + ?: error("Backend auth succeeded without user id.") + firebaseSessionManager.signOut() + apiTokenStore.saveToken(resolvedToken).getOrThrow() + localEmailAuthStore.saveProfile( + userId = resolvedUserId, + email = email, + displayName = displayName + ).getOrThrow() + return User( + userId = resolvedUserId, + email = email, + displayName = displayName, + isAuthenticated = true + ) + } + private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String { val response = apolloClient .mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken)) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt new file mode 100644 index 0000000..14425f2 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/LocalEmailAuthStore.kt @@ -0,0 +1,13 @@ +package org.db3.airmq.sdk.auth + +data class LocalEmailAuthProfile( + val userId: String, + val email: String?, + val displayName: String? +) + +interface LocalEmailAuthStore { + fun saveProfile(userId: String, email: String?, displayName: String?): Result + fun getProfile(): LocalEmailAuthProfile? + fun clearProfile(): Result +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt index 00c0a93..645fd2d 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesApiTokenStore.kt @@ -11,7 +11,7 @@ class SharedPreferencesApiTokenStore @Inject constructor( ) : ApiTokenStore { private val sharedPreferences = - context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE) override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null) @@ -25,7 +25,6 @@ class SharedPreferencesApiTokenStore @Inject constructor( } private companion object { - private const val PREFERENCES_NAME = "airmq_auth" private const val KEY_API_TOKEN = "api_token" } } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt new file mode 100644 index 0000000..4ec01c4 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/auth/SharedPreferencesLocalEmailAuthStore.kt @@ -0,0 +1,48 @@ +package org.db3.airmq.sdk.auth + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SharedPreferencesLocalEmailAuthStore @Inject constructor( + @ApplicationContext context: Context +) : LocalEmailAuthStore { + + private val sharedPreferences = + context.getSharedPreferences(AirMqAuthPreferences.FILE_NAME, Context.MODE_PRIVATE) + + override fun saveProfile(userId: String, email: String?, displayName: String?): Result = runCatching { + require(userId.isNotBlank()) { "User id cannot be blank." } + sharedPreferences.edit() + .putString(KEY_LOCAL_EMAIL_USER_ID, userId) + .putString(KEY_LOCAL_EMAIL_ADDRESS, email) + .putString(KEY_LOCAL_EMAIL_DISPLAY_NAME, displayName) + .apply() + } + + override fun getProfile(): LocalEmailAuthProfile? { + val userId = sharedPreferences.getString(KEY_LOCAL_EMAIL_USER_ID, null) + ?.takeIf { it.isNotBlank() } ?: return null + return LocalEmailAuthProfile( + userId = userId, + email = sharedPreferences.getString(KEY_LOCAL_EMAIL_ADDRESS, null), + displayName = sharedPreferences.getString(KEY_LOCAL_EMAIL_DISPLAY_NAME, null) + ) + } + + override fun clearProfile(): Result = runCatching { + sharedPreferences.edit() + .remove(KEY_LOCAL_EMAIL_USER_ID) + .remove(KEY_LOCAL_EMAIL_ADDRESS) + .remove(KEY_LOCAL_EMAIL_DISPLAY_NAME) + .apply() + } + + private companion object { + private const val KEY_LOCAL_EMAIL_USER_ID = "local_email_user_id" + private const val KEY_LOCAL_EMAIL_ADDRESS = "local_email_address" + private const val KEY_LOCAL_EMAIL_DISPLAY_NAME = "local_email_display_name" + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt index 4b58934..25eecf9 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt @@ -13,7 +13,9 @@ import org.db3.airmq.sdk.auth.AuthService import org.db3.airmq.sdk.auth.FirebaseAuthService import org.db3.airmq.sdk.auth.FirebaseSessionManager import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl +import org.db3.airmq.sdk.auth.LocalEmailAuthStore import org.db3.airmq.sdk.auth.SharedPreferencesApiTokenStore +import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl import org.db3.airmq.sdk.map.MapServiceImpl @@ -54,6 +56,10 @@ abstract class SDKBindModule { @Singleton abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore + @Binds + @Singleton + abstract fun bindLocalEmailAuthStore(impl: SharedPreferencesLocalEmailAuthStore): LocalEmailAuthStore + @Binds @Singleton abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager diff --git a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt index 3952fcf..2f22068 100644 --- a/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt +++ b/sdk/src/test/kotlin/org/db3/airmq/sdk/auth/FirebaseAuthServiceTest.kt @@ -10,6 +10,8 @@ import org.db3.airmq.sdk.auth.model.AuthProvider import org.db3.airmq.sdk.auth.model.User import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -20,7 +22,8 @@ class FirebaseAuthServiceTest { val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed")) val apolloClient = mockk() val tokenStore = FakeApiTokenStore() - val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore) + val localEmailStore = FakeLocalEmailAuthStore() + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token") @@ -39,8 +42,9 @@ class FirebaseAuthServiceTest { ) val apolloClient = mockk() val tokenStore = FakeApiTokenStore() + val localEmailStore = FakeLocalEmailAuthStore() mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed")) - val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore) + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token") @@ -50,11 +54,18 @@ class FirebaseAuthServiceTest { } @Test - fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest { - val sessionManager = FakeFirebaseSessionManager(isSignedIn = true) + fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest { + val sessionManager = FakeFirebaseSessionManager( + isSignedIn = true, + signInResult = FirebaseSessionUser( + user = User("uid-fb", "fb@test.dev", "FB", true), + firebaseAccessToken = "ft" + ) + ) val apolloClient = mockk() val tokenStore = FakeApiTokenStore(storedToken = "api-token") - val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore) + val localEmailStore = FakeLocalEmailAuthStore() + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) assertTrue(service.isAuthenticated()) @@ -62,6 +73,52 @@ class FirebaseAuthServiceTest { assertFalse(service.isAuthenticated()) } + @Test + fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest { + val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore(storedToken = "api-token") + val localEmailStore = FakeLocalEmailAuthStore().apply { + saveProfile("backend-id", "e@mail.test", "Name").getOrThrow() + } + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) + + assertTrue(service.isAuthenticated()) + } + + @Test + fun getUser_returnsLocalProfileWhenNoFirebaseUser() = runTest { + val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore(storedToken = "api-token") + val localEmailStore = FakeLocalEmailAuthStore().apply { + saveProfile("bid", "local@test.dev", "Local User").getOrThrow() + } + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) + + val user = service.getUser() + assertNotNull(user) + assertEquals("bid", user!!.userId) + assertEquals("local@test.dev", user.email) + assertEquals("Local User", user.displayName) + assertTrue(user.isAuthenticated) + } + + @Test + fun signOut_clearsTokenAndLocalProfile() = runTest { + val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null) + val apolloClient = mockk() + val tokenStore = FakeApiTokenStore(storedToken = "api-token") + val localEmailStore = FakeLocalEmailAuthStore().apply { + saveProfile("id", "a@b.c", null).getOrThrow() + } + val service = FirebaseAuthService(sessionManager, apolloClient, tokenStore, localEmailStore) + + assertTrue(service.signOut().isSuccess) + assertNull(tokenStore.storedToken) + assertNull(localEmailStore.getProfile()) + } + private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) { val mockCall = mockk>() every { apolloClient.mutation(any()) } returns mockCall @@ -103,3 +160,17 @@ private class FakeApiTokenStore( return Result.success(Unit) } } + +private class FakeLocalEmailAuthStore( + private var profile: LocalEmailAuthProfile? = null +) : LocalEmailAuthStore { + override fun saveProfile(userId: String, email: String?, displayName: String?): Result = runCatching { + profile = LocalEmailAuthProfile(userId, email, displayName) + } + + override fun getProfile(): LocalEmailAuthProfile? = profile + + override fun clearProfile(): Result = runCatching { + profile = null + } +}