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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
<string name="notification_device_status_title">Ваша прылада адключылася ад сеткі!</string>
|
||||
<string name="notification_device_status_text">%s цяпер неактыўны. Націсніце на гэта апавяшчэнне, каб адчыніць канфігурацыю прылады</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="text_policy_label">Уваходзячы ў сістэму, вы згаджаецеся з нашымі %1$s и %2$s</string>
|
||||
<string name="text_what_does_it_mean"><u>Што гэта значыць?</u></string>
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
<string name="notification_device_status_title">Ваше устройство отключилось от сети!</string>
|
||||
<string name="notification_device_status_text">%s теперь неактивен. Нажмите на это уведомление, чтобы открыть конфигурацию устройства</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="text_policy_label">Входя в систему, вы соглашаетесь с нашими %1$s и %2$s</string>
|
||||
<string name="text_what_does_it_mean"><u>Что это значит?</u></string>
|
||||
|
||||
@@ -142,6 +142,9 @@
|
||||
|
||||
<!-- Snackbars and toasts -->
|
||||
<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="snackbar_not_registered">Device requires registration</string>
|
||||
|
||||
|
||||
11
sdk/src/main/graphql/LoginLocal.graphql
Normal file
11
sdk/src/main/graphql/LoginLocal.graphql
Normal file
@@ -0,0 +1,11 @@
|
||||
mutation LoginLocal($input: LocalAuthInput!) {
|
||||
loginLocal(input: $input) {
|
||||
token
|
||||
name
|
||||
email
|
||||
roles
|
||||
_id
|
||||
regDate
|
||||
emailVerified
|
||||
}
|
||||
}
|
||||
11
sdk/src/main/graphql/Register.graphql
Normal file
11
sdk/src/main/graphql/Register.graphql
Normal file
@@ -0,0 +1,11 @@
|
||||
mutation Register($input: RegisterInput!) {
|
||||
register(input: $input) {
|
||||
token
|
||||
name
|
||||
email
|
||||
roles
|
||||
_id
|
||||
regDate
|
||||
emailVerified
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.db3.airmq.sdk.auth
|
||||
|
||||
internal object AirMqAuthPreferences {
|
||||
const val FILE_NAME = "airmq_auth"
|
||||
}
|
||||
@@ -7,5 +7,7 @@ interface AuthService {
|
||||
suspend fun getUser(): User?
|
||||
suspend fun isAuthenticated(): Boolean
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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))
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<ApolloClient>()
|
||||
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<ApolloClient>()
|
||||
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<ApolloClient>()
|
||||
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<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) {
|
||||
val mockCall = mockk<com.apollographql.apollo.ApolloCall<AuthGoogleNewMutation.Data>>()
|
||||
every { apolloClient.mutation(any<AuthGoogleNewMutation>()) } 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<Unit> = runCatching {
|
||||
profile = LocalEmailAuthProfile(userId, email, displayName)
|
||||
}
|
||||
|
||||
override fun getProfile(): LocalEmailAuthProfile? = profile
|
||||
|
||||
override fun clearProfile(): Result<Unit> = runCatching {
|
||||
profile = null
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user