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
This commit is contained in:
2026-04-06 19:30:31 +02:00
parent df4e6f9c56
commit 9869ad2476
16 changed files with 319 additions and 26 deletions

View File

@@ -1,7 +1,9 @@
package org.db3.airmq.features.login package org.db3.airmq.features.login
import android.content.Context import android.content.Context
import android.util.Patterns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
@@ -11,14 +13,17 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.db3.airmq.R import org.db3.airmq.R
import org.db3.airmq.features.login.EmailLoginScreenContract.Action import org.db3.airmq.features.login.EmailLoginScreenContract.Action
import org.db3.airmq.features.login.EmailLoginScreenContract.Event import org.db3.airmq.features.login.EmailLoginScreenContract.Event
import org.db3.airmq.features.login.EmailLoginScreenContract.State import org.db3.airmq.features.login.EmailLoginScreenContract.State
import org.db3.airmq.sdk.auth.AuthService
@HiltViewModel @HiltViewModel
class EmailLoginViewModel @Inject constructor( class EmailLoginViewModel @Inject constructor(
@ApplicationContext private val appContext: Context @ApplicationContext private val appContext: Context,
private val authService: AuthService
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(State()) private val _uiState = MutableStateFlow(State())
@@ -35,19 +40,45 @@ class EmailLoginViewModel @Inject constructor(
is Event.PasswordChanged -> { is Event.PasswordChanged -> {
_uiState.value = _uiState.value.copy(password = event.value) _uiState.value = _uiState.value.copy(password = event.value)
} }
Event.SignInClicked -> { Event.SignInClicked -> submitEmailAuth(register = false)
_uiState.value = _uiState.value.copy(isLoading = true) Event.RegisterClicked -> submitEmailAuth(register = 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.BackClicked -> Unit 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)
}
}
}
} }

View File

@@ -25,9 +25,13 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
@@ -63,6 +67,16 @@ fun ManageScreen(
viewModel: ManageViewModel = hiltViewModel() viewModel: ManageViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() 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) { LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action -> viewModel.actions.collectLatest { action ->
when (action) { when (action) {

View File

@@ -71,7 +71,7 @@ class ManageViewModel @Inject constructor(
private fun initialState(): State = anonymousState() private fun initialState(): State = anonymousState()
private fun refreshAuthState() { fun refreshAuthState() {
viewModelScope.launch { viewModelScope.launch {
val session = authService.getUser() val session = authService.getUser()
if (session?.isAuthenticated == true) { if (session?.isAuthenticated == true) {

View File

@@ -82,6 +82,9 @@
<string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string> <string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string>
<string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</string> <string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</string>
<string name="toast_oauth_failed">Памылка аўтарызацыі Google</string> <string name="toast_oauth_failed">Памылка аўтарызацыі Google</string>
<string name="toast_email_fields_required">Увядзіце email і пароль</string>
<string name="toast_invalid_email">Увядзіце карэктны email</string>
<string name="toast_email_auth_failed">Не ўдалося ўвайсці</string>
<string name="snackbar_not_registered">Патрабуецца рэгістрацыя</string> <string name="snackbar_not_registered">Патрабуецца рэгістрацыя</string>
<string name="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string> <string name="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string>
<string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string> <string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string>

View File

@@ -82,6 +82,9 @@
<string name="notification_device_status_title">Ваше устройство отключилось от сети!</string> <string name="notification_device_status_title">Ваше устройство отключилось от сети!</string>
<string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</string> <string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</string>
<string name="toast_oauth_failed">Ошибка авторизации Google</string> <string name="toast_oauth_failed">Ошибка авторизации Google</string>
<string name="toast_email_fields_required">Введите email и пароль</string>
<string name="toast_invalid_email">Введите корректный email</string>
<string name="toast_email_auth_failed">Не удалось войти</string>
<string name="snackbar_not_registered">Требуется регистрация</string> <string name="snackbar_not_registered">Требуется регистрация</string>
<string name="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string> <string name="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string>
<string name="text_what_does_it_mean"><u>Что это значит?</u></string> <string name="text_what_does_it_mean"><u>Что это значит?</u></string>

View File

@@ -142,6 +142,9 @@
<!-- Snackbars and toasts --> <!-- Snackbars and toasts -->
<string name="toast_oauth_failed" tools:ignore="UnusedResources">Google authorisation failed</string> <string name="toast_oauth_failed" tools:ignore="UnusedResources">Google authorisation failed</string>
<string name="toast_email_fields_required">Enter email and password</string>
<string name="toast_invalid_email">Enter a valid email address</string>
<string name="toast_email_auth_failed">Sign-in failed</string>
<string name="toast_copied">Copied</string> <string name="toast_copied">Copied</string>
<string name="snackbar_not_registered">Device requires registration</string> <string name="snackbar_not_registered">Device requires registration</string>

View File

@@ -0,0 +1,11 @@
mutation LoginLocal($input: LocalAuthInput!) {
loginLocal(input: $input) {
token
name
email
roles
_id
regDate
emailVerified
}
}

View File

@@ -0,0 +1,11 @@
mutation Register($input: RegisterInput!) {
register(input: $input) {
token
name
email
roles
_id
regDate
emailVerified
}
}

View File

@@ -0,0 +1,5 @@
package org.db3.airmq.sdk.auth
internal object AirMqAuthPreferences {
const val FILE_NAME = "airmq_auth"
}

View File

@@ -7,5 +7,7 @@ interface AuthService {
suspend fun getUser(): User? suspend fun getUser(): User?
suspend fun isAuthenticated(): Boolean suspend fun isAuthenticated(): Boolean
suspend fun signIn(provider: AuthProvider, token: String): Result<User> suspend fun signIn(provider: AuthProvider, token: String): Result<User>
suspend fun loginWithEmailPassword(email: String, password: String): Result<User>
suspend fun registerWithEmail(email: String, password: String, name: String): Result<User>
suspend fun signOut(): Result<Unit> suspend fun signOut(): Result<Unit>
} }

View File

@@ -1,25 +1,40 @@
package org.db3.airmq.sdk.auth package org.db3.airmq.sdk.auth
import com.apollographql.apollo.ApolloClient import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.Optional
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import org.db3.airmq.sdk.AuthGoogleNewMutation 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.AuthProvider
import org.db3.airmq.sdk.auth.model.User import org.db3.airmq.sdk.auth.model.User
import org.db3.airmq.sdk.type.LocalAuthInput
import org.db3.airmq.sdk.type.RegisterInput
@Singleton @Singleton
class FirebaseAuthService @Inject constructor( class FirebaseAuthService @Inject constructor(
private val firebaseSessionManager: FirebaseSessionManager, private val firebaseSessionManager: FirebaseSessionManager,
private val apolloClient: ApolloClient, private val apolloClient: ApolloClient,
private val apiTokenStore: ApiTokenStore private val apiTokenStore: ApiTokenStore,
private val localEmailAuthStore: LocalEmailAuthStore
) : AuthService { ) : 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 { override suspend fun isAuthenticated(): Boolean {
val hasFirebaseUser = firebaseSessionManager.isSignedIn() if (apiTokenStore.getToken().isNullOrBlank()) return false
val hasApiToken = !apiTokenStore.getToken().isNullOrBlank() return getUser() != null
return hasFirebaseUser && hasApiToken
} }
override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching { override suspend fun signIn(provider: AuthProvider, token: String): Result<User> = runCatching {
@@ -29,18 +44,76 @@ class FirebaseAuthService @Inject constructor(
} }
} }
override suspend fun loginWithEmailPassword(email: String, password: String): Result<User> = 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<User> = 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<Unit> = runCatching { override suspend fun signOut(): Result<Unit> = runCatching {
firebaseSessionManager.signOut() firebaseSessionManager.signOut()
apiTokenStore.clearToken().getOrThrow() apiTokenStore.clearToken().getOrThrow()
localEmailAuthStore.clearProfile().getOrThrow()
} }
private suspend fun signInWithGoogle(token: String): User { private suspend fun signInWithGoogle(token: String): User {
localEmailAuthStore.clearProfile().getOrThrow()
val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token) val firebaseSessionUser = firebaseSessionManager.signInWithGoogle(token)
val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken) val apiToken = exchangeFirebaseTokenWithBackend(firebaseSessionUser.firebaseAccessToken)
apiTokenStore.saveToken(apiToken).getOrThrow() apiTokenStore.saveToken(apiToken).getOrThrow()
return firebaseSessionUser.user 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 { private suspend fun exchangeFirebaseTokenWithBackend(firebaseAccessToken: String): String {
val response = apolloClient val response = apolloClient
.mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken)) .mutation(AuthGoogleNewMutation(accessToken = firebaseAccessToken))

View File

@@ -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<Unit>
fun getProfile(): LocalEmailAuthProfile?
fun clearProfile(): Result<Unit>
}

View File

@@ -11,7 +11,7 @@ class SharedPreferencesApiTokenStore @Inject constructor(
) : ApiTokenStore { ) : ApiTokenStore {
private val sharedPreferences = 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) override fun getToken(): String? = sharedPreferences.getString(KEY_API_TOKEN, null)
@@ -25,7 +25,6 @@ class SharedPreferencesApiTokenStore @Inject constructor(
} }
private companion object { private companion object {
private const val PREFERENCES_NAME = "airmq_auth"
private const val KEY_API_TOKEN = "api_token" private const val KEY_API_TOKEN = "api_token"
} }
} }

View File

@@ -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<Unit> = 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<Unit> = 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"
}
}

View File

@@ -13,7 +13,9 @@ import org.db3.airmq.sdk.auth.AuthService
import org.db3.airmq.sdk.auth.FirebaseAuthService import org.db3.airmq.sdk.auth.FirebaseAuthService
import org.db3.airmq.sdk.auth.FirebaseSessionManager import org.db3.airmq.sdk.auth.FirebaseSessionManager
import org.db3.airmq.sdk.auth.FirebaseSessionManagerImpl 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.SharedPreferencesApiTokenStore
import org.db3.airmq.sdk.auth.SharedPreferencesLocalEmailAuthStore
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository import org.db3.airmq.sdk.dashboard.DashboardMetricsRepository
import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl import org.db3.airmq.sdk.dashboard.DashboardMetricsRepositoryImpl
import org.db3.airmq.sdk.map.MapServiceImpl import org.db3.airmq.sdk.map.MapServiceImpl
@@ -54,6 +56,10 @@ abstract class SDKBindModule {
@Singleton @Singleton
abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore abstract fun bindApiTokenStore(impl: SharedPreferencesApiTokenStore): ApiTokenStore
@Binds
@Singleton
abstract fun bindLocalEmailAuthStore(impl: SharedPreferencesLocalEmailAuthStore): LocalEmailAuthStore
@Binds @Binds
@Singleton @Singleton
abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager abstract fun bindFirebaseSessionManager(impl: FirebaseSessionManagerImpl): FirebaseSessionManager

View File

@@ -10,6 +10,8 @@ import org.db3.airmq.sdk.auth.model.AuthProvider
import org.db3.airmq.sdk.auth.model.User import org.db3.airmq.sdk.auth.model.User
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -20,7 +22,8 @@ class FirebaseAuthServiceTest {
val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed")) val sessionManager = FakeFirebaseSessionManager(signInError = IllegalStateException("firebase failed"))
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore() 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") val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
@@ -39,8 +42,9 @@ class FirebaseAuthServiceTest {
) )
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore() val tokenStore = FakeApiTokenStore()
val localEmailStore = FakeLocalEmailAuthStore()
mockAuthGoogleNewError(apolloClient, IllegalStateException("backend failed")) 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") val result = service.signIn(provider = AuthProvider.GOOGLE, token = "google-id-token")
@@ -50,11 +54,18 @@ class FirebaseAuthServiceTest {
} }
@Test @Test
fun isAuthenticated_trueOnlyWhenFirebaseAndApiTokenPresent() = runTest { fun isAuthenticated_trueWhenFirebaseUserAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = true) val sessionManager = FakeFirebaseSessionManager(
isSignedIn = true,
signInResult = FirebaseSessionUser(
user = User("uid-fb", "fb@test.dev", "FB", true),
firebaseAccessToken = "ft"
)
)
val apolloClient = mockk<ApolloClient>() val apolloClient = mockk<ApolloClient>()
val tokenStore = FakeApiTokenStore(storedToken = "api-token") 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()) assertTrue(service.isAuthenticated())
@@ -62,6 +73,52 @@ class FirebaseAuthServiceTest {
assertFalse(service.isAuthenticated()) assertFalse(service.isAuthenticated())
} }
@Test
fun isAuthenticated_trueWhenLocalEmailProfileAndApiTokenPresent() = runTest {
val sessionManager = FakeFirebaseSessionManager(isSignedIn = false, signInResult = null)
val apolloClient = mockk<ApolloClient>()
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<ApolloClient>()
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<ApolloClient>()
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) { private fun mockAuthGoogleNewError(apolloClient: ApolloClient, error: Throwable) {
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>() val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } returns mockCall
@@ -103,3 +160,17 @@ private class FakeApiTokenStore(
return Result.success(Unit) return Result.success(Unit)
} }
} }
private class FakeLocalEmailAuthStore(
private var profile: LocalEmailAuthProfile? = null
) : LocalEmailAuthStore {
override fun saveProfile(userId: String, email: String?, displayName: String?): Result<Unit> = runCatching {
profile = LocalEmailAuthProfile(userId, email, displayName)
}
override fun getProfile(): LocalEmailAuthProfile? = profile
override fun clearProfile(): Result<Unit> = runCatching {
profile = null
}
}