Coverage Summary for Class: PostAuthBootstrapUseCase (com.georgeci.moneysurfer.domain.usecase)

Class Method, % Branch, % Line, % Instruction, %
PostAuthBootstrapUseCase 100% (5/5) 87.5% (42/48) 100% (86/86) 98.3% (956/973)
PostAuthBootstrapUseCase$Companion
PostAuthBootstrapUseCase$hydrateExistingUser$1
PostAuthBootstrapUseCase$invoke$1
PostAuthBootstrapUseCase$isHydrated$1
PostAuthBootstrapUseCase$Result
PostAuthBootstrapUseCase$Result$CloudDataUnavailable 100% (1/1) 100% (2/2) 100% (7/7)
PostAuthBootstrapUseCase$Result$ExistingUser 100% (1/1) 100% (3/3) 100% (12/12)
PostAuthBootstrapUseCase$Result$FirstTime 100% (1/1) 100% (1/1) 100% (2/2)
Total 100% (8/8) 87.5% (42/48) 100% (92/92) 98.3% (977/994)


 package com.georgeci.moneysurfer.domain.usecase
 
 import arrow.core.Either
 import arrow.core.getOrElse
 import arrow.core.raise.Raise
 import arrow.core.raise.either
 import co.touchlab.kermit.Logger
 import com.georgeci.moneysurfer.domain.auth.AuthError
 import com.georgeci.moneysurfer.domain.auth.SessionMutator
 import com.georgeci.moneysurfer.domain.config.SyncedSettingsSession
 import com.georgeci.moneysurfer.domain.logging.redactEmail
 import com.georgeci.moneysurfer.domain.logging.redactUid
 import com.georgeci.moneysurfer.domain.model.User
 import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
 import com.georgeci.moneysurfer.domain.repositories.UserRemoteRepository
 import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
 import com.georgeci.moneysurfer.domain.repositories.WorkspaceSyncer
 import org.koin.core.annotation.Single
 
 /**
  * After Firebase Auth completes we either find an existing `users/{uid}` document and pull the
  * user's workspaces into Room, or we create the doc as a first-time entry and let the workspace
  * selector handle workspace creation.
  *
  * For an existing user we resolve `defaultWorkspaceId` with a fallback to the first item in
  * `workspaceIds` — covers historical accounts where the default was never written — and pin it
  * as `session.currentWorkspaceId`, but **only after checking the workspace actually landed in
  * Room**. Pinning an id the pull never hydrated routes the next cold start to Dashboard on top
  * of an empty database, with the selector never shown again (issue #342). When the remote lists
  * workspaces and none of them hydrated, the caller gets [Result.CloudDataUnavailable] instead.
  */
 @Single
 @Suppress("LongParameterList")
 class PostAuthBootstrapUseCase(
     private val userRemoteRepository: UserRemoteRepository,
     private val workspaceRepository: WorkspaceRepository,
     private val workspaceSyncer: WorkspaceSyncer,
     private val sessionMutator: SessionMutator,
     private val getCurrentTime: GetCurrentTimeUseCase,
     private val syncedSettingsSession: SyncedSettingsSession,
 ) {
     private val log = Logger.withTag(TAG)
 
     sealed interface Result {
         /**
          * [defaultWorkspaceId] is the workspace that was pinned — always one that exists in Room,
          * so it can be `null` even when [workspaceIds] is not (the user is a member of workspaces
          * that have not been hydrated on this device).
          */
         data class ExistingUser(
             val workspaceIds: List<WorkspaceId>,
             val defaultWorkspaceId: WorkspaceId?,
         ) : Result
 
         /**
          * The remote user document lists workspaces but the pull hydrated none of them — sync is
          * off, the network died mid-pull, or every ref is stale. Auth itself succeeded, so the
          * session stays valid; the UI tells the user their cloud data is not here rather than
          * dropping them into an empty selector as if the account were brand new.
          */
         data class CloudDataUnavailable(
             val workspaceIds: List<WorkspaceId>,
         ) : Result
 
         data object FirstTime : Result
     }
 
     suspend operator fun invoke(
         uid: String,
         email: String?,
         displayName: String?,
         isAnon: Boolean,
     ): Either<AuthError, Result> = either {
         log.i { "[start] uid=${uid.redactUid()} isAnon=$isAnon hasEmail=${email != null}" }
 
         // Before anything reads or pulls: drop the previous user's settings overlay, and queue the
         // settings written while the outbox was refusing writes. Both have to happen ahead of the
         // first pull of this session — the overlay would otherwise shadow what the pull writes, and
         // a write reconciled afterwards would race the pull it is supposed to precede.
         //
         // Best-effort, like the email backfill below. It touches two databases, and `either { }`
         // does not catch exceptions: an unwrapped throw would escape as a raw exception rather than
         // an `AuthError`, so `LoginUseCase`'s `.onLeft { abandonAuthSession() }` would never run and
         // the session would be left pinned with no workspace — the #342 dangling state. Losing the
         // reconciliation costs one sync cycle; the next session start retries it.
         Either.catch { syncedSettingsSession.onSessionStart() }
             .onLeft { log.w(it) { "[settings] session start failed uid=${uid.redactUid()} — non-fatal" } }
 
         // Backfill the email→uid mapping so future invites can resolve targetUserId for this
         // account. Best-effort — failure shouldn't block the login flow, but it does block
         // anyone from inviting this user until the next successful bootstrap retries the write.
         when {
             !email.isNullOrBlank() -> {
                 log.d { "[email-map] start uid=${uid.redactUid()} email=${email.redactEmail()}" }
                 Either.catch { userRemoteRepository.upsertEmailMapping(email = email, uid = uid) }
                     .onLeft { log.w(it) { "[email-map] upsert failed uid=${uid.redactUid()} — non-fatal" } }
                     .onRight { log.i { "[email-map] ok uid=${uid.redactUid()}" } }
             }
             isAnon -> log.d { "[email-map:skip] uid=${uid.redactUid()} — anonymous account, no email" }
             else -> log.w {
                 "[email-map:skip] uid=${uid.redactUid()} — non-anon account but email is blank; " +
                     "Firebase Auth should have provided an email claim, this user will " +
                     "NOT be invitable until the mapping is backfilled"
             }
         }
 
         log.d { "[fetch] users/${uid.redactUid()} …" }
         val existing = Either.catch { userRemoteRepository.fetch(uid) }
             .onLeft { log.e(it) { "[fetch] users/${uid.redactUid()} failed" } }
             .onRight { user ->
                 if (user != null) {
                     log.i {
                         "[fetch] users/${uid.redactUid()} found: workspaces=${user.workspaceIds.size} " +
                             "default=${user.defaultWorkspaceId} " +
                             "isAnon=${user.isAnon} hasDisplayName=${user.displayName != null}"
                     }
                 } else {
                     log.i { "[fetch] users/${uid.redactUid()} not found (first-time entry)" }
                 }
             }
             .mapLeft { it.toAuthError() }
             .bind()
 
         if (existing != null) {
             hydrateExistingUser(uid = uid, existing = existing)
         } else {
             log.i { "[create] first-time user, creating users/${uid.redactUid()} …" }
             Either
                 .catch {
                     userRemoteRepository.create(
                         uid = uid,
                         displayName = displayName,
                         email = email,
                         isAnon = isAnon,
                         createdAt = getCurrentTime().toEpochMilliseconds(),
                     )
                 }
                 .onLeft { log.e(it) { "[create] users/${uid.redactUid()} failed" } }
                 .onRight { log.i { "[create] users/${uid.redactUid()} ok" } }
                 .mapLeft { it.toAuthError() }
                 .bind()
             Result.FirstTime.also { log.i { "[done] FirstTime uid=${uid.redactUid()}" } }
         }
     }
 
     /**
      * Pulls every workspace the remote user document lists, then picks the one to pin: the
      * server's default if it hydrated, otherwise the first list entry that did.
      */
     private suspend fun Raise<AuthError>.hydrateExistingUser(uid: String, existing: User): Result {
         log.i { "[pull] existing user, syncing all workspaces" }
         // syncAll fetches users/{uid}.workspaceIds from remote and runs cursor-based pull
         // for every workspace — including ones not yet local. Fail-loud: a PERMISSION_DENIED
         // here means the whole account is unreachable (individual stale refs are tolerated
         // inside the pull); aborting keeps the sign-in screen visible rather than sending
         // users to a broken state.
         Either.catch { workspaceSyncer.syncAll() }
             .onLeft { log.e(it) { "[pull] syncAll failed" } }
             .mapLeft { it.toAuthError() }
             .bind()
 
         // Historical user docs have `defaultWorkspaceId = null` because the field was never
         // written before — without the fallback to the first entry those users re-pick a
         // workspace on every fresh device. Both candidates are then filtered through Room:
         // `defaultWorkspaceId` is not guaranteed to be a member of `workspaceIds`
         // (server-side skew), and neither is guaranteed to have been hydrated by the pull.
         val preferred = existing.defaultWorkspaceId ?: existing.workspaceIds.firstOrNull()
         val resolvedDefault = preferred.takeIf { it != null && isHydrated(it) }
             ?: existing.workspaceIds.firstOrNull { isHydrated(it) }
 
         if (resolvedDefault == null) {
             if (existing.workspaceIds.isEmpty()) {
                 log.i { "[done] ExistingUser uid=${uid.redactUid()} with no workspaces" }
                 return Result.ExistingUser(workspaceIds = emptyList(), defaultWorkspaceId = null)
             }
             log.w {
                 "[done] CloudDataUnavailable uid=${uid.redactUid()} — remote lists " +
                     "${existing.workspaceIds.size} workspace(s), none of them reached Room; " +
                     "leaving currentWorkspaceId unset"
             }
             return Result.CloudDataUnavailable(workspaceIds = existing.workspaceIds)
         }
 
         sessionMutator.setCurrentWorkspace(resolvedDefault)
         log.i {
             "[done] ExistingUser uid=${uid.redactUid()} workspaces=${existing.workspaceIds.size} " +
                 "currentWorkspaceId=${resolvedDefault.value}"
         }
         return Result.ExistingUser(
             workspaceIds = existing.workspaceIds,
             defaultWorkspaceId = resolvedDefault,
         )
     }
 
     /**
      * True when [workspaceId] has a row in the local database, i.e. the pull actually brought
      * the workspace down. A lookup failure counts as "not hydrated": pinning on a Room error
      * is the outcome this guard exists to prevent.
      */
     private suspend fun isHydrated(workspaceId: WorkspaceId): Boolean =
         Either.catch { workspaceRepository.getById(workspaceId) != null }
             .onLeft { log.w(it) { "[verify] local lookup failed wid=${workspaceId.value}" } }
             .getOrElse { false }
 
     /**
      * Domain layer can't import Firebase types, so we sniff the exception class name
      * for "PERMISSION_DENIED" / `FirebaseFirestoreException`. Far from elegant — the
      * proper fix is a `:data` boundary that throws typed domain exceptions. Until then
      * this keeps the UI from silently swallowing rules misconfiguration.
      */
     private fun Throwable.toAuthError(): AuthError {
         val signature = (this::class.simpleName.orEmpty() + "|" + (message ?: "")).uppercase()
         val type = when {
             "PERMISSION_DENIED" in signature -> AuthError.Type.PermissionDenied
             "FIRESTORE" in signature && "PERMISSION" in signature -> AuthError.Type.PermissionDenied
             else -> AuthError.Type.Unknown
         }
         return AuthError(type = type, message = message, cause = this)
     }
 
     private companion object {
         const val TAG = "PostAuthBootstrap"
     }
 }