Coverage Summary for Class: UserAccountDeletionRepositoryImpl (com.georgeci.moneysurfer.data.repository)

Class Method, % Branch, % Line, % Instruction, %
UserAccountDeletionRepositoryImpl 100% (7/7) 83.3% (20/24) 100% (44/44) 99.1% (629/635)
UserAccountDeletionRepositoryImpl$Companion 0% (0/2) 0% (0/2) 0% (0/2)
UserAccountDeletionRepositoryImpl$deleteEmailMapping$1
UserAccountDeletionRepositoryImpl$deleteRemoteUserData$1
UserAccountDeletionRepositoryImpl$disposeWorkspace$1
UserAccountDeletionRepositoryImpl$purgeWorkspace$1
Total 77.8% (7/9) 83.3% (20/24) 95.7% (44/46) 98.7% (629/637)


 package com.georgeci.moneysurfer.data.repository
 
 import arrow.core.Either
 import co.touchlab.kermit.Logger
 import com.georgeci.moneysurfer.data.remote.AccountDeletionRemoteSource
 import com.georgeci.moneysurfer.data.remote.RemoteAccessDeniedException
 import com.georgeci.moneysurfer.domain.auth.AccountDeletionError
 import com.georgeci.moneysurfer.domain.logging.redactUid
 import com.georgeci.moneysurfer.domain.model.WorkspaceMemberStatus
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 import com.georgeci.moneysurfer.domain.repositories.UserAccountDeletionRepository
 import com.georgeci.moneysurfer.sync.api.SyncCollection
 import org.koin.core.annotation.Single
 
 /**
  * Remote cleanup for account deletion (issue #213). Every step is idempotent — a retry after a
  * part-way failure re-runs against whatever documents are left. The caller
  * ([DeleteUserAccountUseCase][com.georgeci.moneysurfer.domain.usecase.DeleteUserAccountUseCase])
  * guarantees sync is shut down first, so nothing re-creates docs behind the purge.
  *
  * Deletion semantics mirror the hosted account-deletion page: workspaces that are effectively
  * personal (owned, no other ACTIVE member) are hard-deleted; in workspaces shared with other
  * people the user's member row flips to LEFT and the content stays for the remaining members.
  *
  * All Firestore specifics sit behind [AccountDeletionRemoteSource], so this class holds only the
  * decision logic and is unit-tested against a fake.
  */
 @Single(binds = [UserAccountDeletionRepository::class])
 class UserAccountDeletionRepositoryImpl(
     private val remote: AccountDeletionRemoteSource,
     private val clock: ClockUseCase,
 ) : UserAccountDeletionRepository {
 
     private val log = Logger.withTag(TAG)
 
     override suspend fun deleteRemoteUserData(
         uid: String,
         email: String?,
     ): Either<AccountDeletionError, Unit> = Either.catch {
         val workspaceIds = remote.fetchWorkspaceIds(uid)
         log.i { "[delete] start uid=${uid.redactUid()} workspaces=${workspaceIds.size}" }
 
         workspaceIds.forEach { workspaceId ->
             try {
                 disposeWorkspace(workspaceId, uid)
             } catch (e: RemoteAccessDeniedException) {
                 // A stale ref in users/{uid}.workspaceIds (left / evicted earlier) is denied —
                 // there is nothing of the user's left to clean up there.
                 log.w(e) { "[delete] no access to workspace=$workspaceId — skipping stale ref" }
             }
         }
 
         deleteEmailMapping(email, uid)
         // Before the user document, never after: Firestore does not cascade deletes into
         // subcollections, and once `users/{uid}` and the Auth user are gone nobody can ever
         // authenticate as this uid again to reach what is left behind.
         USER_COLLECTIONS.forEach { name -> remote.deleteUserCollection(uid, name) }
         remote.deleteUserDoc(uid)
         log.i { "[delete] remote data cleared uid=${uid.redactUid()}" }
     }.mapLeft { AccountDeletionError.RemoteDataCleanupFailed(cause = it) }
 
     private suspend fun disposeWorkspace(workspaceId: String, uid: String) {
         val ownerId = remote.fetchWorkspaceOwnerId(workspaceId) ?: return
         val members = remote.fetchMembers(workspaceId)
         val hasOtherActiveMembers = members.any {
             it.uid != uid && it.status == WorkspaceMemberStatus.ACTIVE
         }
 
         if (ownerId == uid && !hasOtherActiveMembers) {
             log.i { "[delete] purging personal workspace=$workspaceId" }
             purgeWorkspace(workspaceId)
         } else {
             log.i { "[delete] leaving shared workspace=$workspaceId" }
             leaveWorkspace(workspaceId, uid, members.firstOrNull { it.uid == uid }?.status)
         }
     }
 
     /**
      * Hard-delete of a personal workspace. Members go last among subcollections (listing the
      * others requires an ACTIVE membership row) and the root doc goes after everything — the
      * subcollection delete rules read it via `isOwner()`, so it must outlive them.
      */
     private suspend fun purgeWorkspace(workspaceId: String) {
         ENTITY_COLLECTIONS.forEach { name -> remote.deleteWorkspaceCollection(workspaceId, name) }
         remote.deleteWorkspaceCollection(workspaceId, "members")
         remote.deleteWorkspace(workspaceId)
     }
 
     /**
      * Marks the user's own member row LEFT instead of deleting it — profile snapshots stay
      * visible to remaining members, matching the retention promise on the hosted page. A row the
      * owner already flipped to REMOVED is left alone (rules deny self-updates on it), as is a
      * workspace the user has no row in.
      */
     private suspend fun leaveWorkspace(workspaceId: String, uid: String, status: WorkspaceMemberStatus?) {
         if (status != WorkspaceMemberStatus.ACTIVE) return
         remote.markMemberLeft(workspaceId, uid, clock.now().toEpochMilliseconds())
     }
 
     /** Removes the email→uid lookup row, but only when it still points at the deleted user. */
     private suspend fun deleteEmailMapping(email: String?, uid: String) {
         val key = email?.trim()?.lowercase().orEmpty()
         if (key.isEmpty()) return
         if (remote.fetchEmailMappingUid(key) == uid) {
             remote.deleteEmailMapping(key)
         }
     }
 
     private companion object {
         const val TAG = "AccountDeletion"
 
         /**
          * Subcollections of `users/{uid}`, purged before the user document itself. Named through
          * [SyncCollection] rather than spelled out: the pull reads the same path, and a collection
          * missing from this list is orphaned silently — the documents outlive the Auth user with
          * nobody able to authenticate as it again.
          */
         val USER_COLLECTIONS = listOf(SyncCollection.USER_CONFIG)
 
         /** Workspace subcollections purged before members + root doc. */
         val ENTITY_COLLECTIONS = listOf(
             "accounts",
             "categories",
             "transactions",
             "invites",
             "budgets",
             "recurringRules",
             "goalContributions",
             "goals",
         )
     }
 }