Coverage Summary for Class: FirebaseAccountDeletionRemoteSource (com.georgeci.moneysurfer.data.remote)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| FirebaseAccountDeletionRemoteSource |
0%
(0/16)
|
0%
(0/16)
|
0%
(0/46)
|
0%
(0/720)
|
| FirebaseAccountDeletionRemoteSource$Companion |
|
| FirebaseAccountDeletionRemoteSource$deleteAllDocs$1 |
|
| FirebaseAccountDeletionRemoteSource$deleteEmailMapping$1 |
|
| FirebaseAccountDeletionRemoteSource$deleteUserCollection$1 |
|
| FirebaseAccountDeletionRemoteSource$deleteUserDoc$1 |
|
| FirebaseAccountDeletionRemoteSource$deleteWorkspace$1 |
|
| FirebaseAccountDeletionRemoteSource$deleteWorkspaceCollection$1 |
|
| FirebaseAccountDeletionRemoteSource$fetchEmailMappingUid$1 |
|
| FirebaseAccountDeletionRemoteSource$fetchMembers$1 |
|
| FirebaseAccountDeletionRemoteSource$fetchWorkspaceIds$1 |
|
| FirebaseAccountDeletionRemoteSource$fetchWorkspaceOwnerId$1 |
|
| FirebaseAccountDeletionRemoteSource$markMemberLeft$1 |
|
| Total |
0%
(0/16)
|
0%
(0/16)
|
0%
(0/46)
|
0%
(0/720)
|
package com.georgeci.moneysurfer.data.remote
import com.georgeci.moneysurfer.domain.model.WorkspaceMemberStatus
import com.georgeci.moneysurfer.sync.api.SyncCollection
import dev.gitlive.firebase.firestore.CollectionReference
import dev.gitlive.firebase.firestore.DocumentReference
import dev.gitlive.firebase.firestore.FirebaseFirestore
import dev.gitlive.firebase.firestore.FirebaseFirestoreException
import org.koin.core.annotation.Single
/**
* The only Firebase-aware part of the account-deletion purge: maps [AccountDeletionRemoteSource]
* primitives onto Firestore document paths, DTOs and write batches. Deliberately free of
* decision logic — what gets purged versus left is decided in
* [UserAccountDeletionRepositoryImpl][com.georgeci.moneysurfer.data.repository.UserAccountDeletionRepositoryImpl].
*/
@Single(binds = [AccountDeletionRemoteSource::class])
// Mirrors the interface one-to-one, so it inherits the same over-ceiling justification.
@Suppress("TooManyFunctions")
class FirebaseAccountDeletionRemoteSource(
private val firestore: FirebaseFirestore,
) : AccountDeletionRemoteSource {
override suspend fun fetchWorkspaceIds(uid: String): List<String> = denyingAware {
val snap = userRef(uid).get()
if (!snap.exists) emptyList() else snap.data(UserDoc.serializer()).workspaceIds.distinct()
}
override suspend fun fetchWorkspaceOwnerId(workspaceId: String): String? = denyingAware {
val snap = workspaceRef(workspaceId).get()
if (!snap.exists) null else snap.data(WorkspaceDoc.serializer()).ownerId
}
override suspend fun fetchMembers(workspaceId: String): List<RemoteMemberRef> = denyingAware {
workspaceRef(workspaceId).collection(MEMBERS).get().documents.map { snap ->
RemoteMemberRef(
uid = snap.id,
status = snap.data(WorkspaceMemberDoc.serializer()).status.toMemberStatus(),
)
}
}
override suspend fun deleteWorkspaceCollection(workspaceId: String, collectionName: String) =
denyingAware { deleteAllDocs(workspaceRef(workspaceId).collection(collectionName)) }
override suspend fun deleteWorkspace(workspaceId: String) = denyingAware {
workspaceRef(workspaceId).delete()
}
override suspend fun markMemberLeft(workspaceId: String, uid: String, nowMillis: Long) = denyingAware {
workspaceRef(workspaceId).collection(MEMBERS).document(uid).update(
"status" to WorkspaceMemberStatus.LEFT.name,
"leftAt" to nowMillis,
"updatedAt" to nowMillis,
"clientVersionCode" to CLIENT_VERSION_CODE,
)
}
override suspend fun fetchEmailMappingUid(emailKey: String): String? = denyingAware {
val snap = firestore.collection(USER_EMAILS).document(emailKey).get()
if (!snap.exists) null else snap.data(UserEmailDoc.serializer()).uid
}
override suspend fun deleteEmailMapping(emailKey: String) = denyingAware {
firestore.collection(USER_EMAILS).document(emailKey).delete()
}
override suspend fun deleteUserCollection(uid: String, collectionName: String) =
denyingAware { deleteAllDocs(userRef(uid).collection(collectionName)) }
override suspend fun deleteUserDoc(uid: String) = denyingAware { userRef(uid).delete() }
private fun userRef(uid: String): DocumentReference =
firestore.collection(USERS).document(uid)
private fun workspaceRef(workspaceId: String): DocumentReference =
firestore.collection(WORKSPACES).document(workspaceId)
/** Firestore caps a write batch at 500 operations; chunk well below that. */
private suspend fun deleteAllDocs(collection: CollectionReference) {
collection.get().documents.chunked(MAX_BATCH_SIZE).forEach { chunk ->
val batch = firestore.batch()
chunk.forEach { snap -> batch.delete(snap.reference) }
batch.commit()
}
}
/** Unknown / absent statuses on legacy docs are treated as ACTIVE, matching firestore.rules. */
private fun String.toMemberStatus(): WorkspaceMemberStatus =
WorkspaceMemberStatus.entries.firstOrNull { it.name == this } ?: WorkspaceMemberStatus.ACTIVE
/**
* Translates Firestore's PERMISSION_DENIED into the SDK-free [RemoteAccessDeniedException] so
* callers never import a Firebase type. Mirrors SyncErrorClassifier: gitlive exposes no
* cross-platform code enum, so the message is what we can inspect.
*/
private inline fun <T> denyingAware(block: () -> T): T =
try {
block()
} catch (e: FirebaseFirestoreException) {
if (e.message?.lowercase()?.contains("permission") == true) {
throw RemoteAccessDeniedException(e.message, e)
}
throw e
}
private companion object {
const val USERS = SyncCollection.USERS
const val USER_EMAILS = "userEmails"
const val WORKSPACES = SyncCollection.WORKSPACES
const val MEMBERS = SyncCollection.WORKSPACE_MEMBERS
const val CLIENT_VERSION_CODE = 1
const val MAX_BATCH_SIZE = 400
}
}