Coverage Summary for Class: GoalContributionSyncPlugin (com.georgeci.moneysurfer.data.sync.plugin)

Class Method, % Branch, % Line, % Instruction, %
GoalContributionSyncPlugin 100% (4/4) 62.5% (5/8) 93.5% (29/31) 87.4% (271/310)
GoalContributionSyncPlugin$applyDoc$1
GoalContributionSyncPlugin$push$2 100% (1/1) 50% (2/4) 100% (3/3) 100% (40/40)
GoalContributionSyncPlugin$resolveAndApply$1
Total 100% (5/5) 58.3% (7/12) 94.1% (32/34) 88.9% (311/350)


 package com.georgeci.moneysurfer.data.sync.plugin
 import com.georgeci.moneysurfer.data.db.dao.GoalContributionDao
 import com.georgeci.moneysurfer.data.db.dao.GoalDao
 import com.georgeci.moneysurfer.data.remote.GoalContributionDoc
 import com.georgeci.moneysurfer.data.sync.WorkspaceDocumentWriter
 import com.georgeci.moneysurfer.data.sync.toDoc
 import com.georgeci.moneysurfer.data.sync.toEntity
 import com.georgeci.moneysurfer.domain.AppInfo
 import com.georgeci.moneysurfer.domain.sync.SyncEntityTypes
 import com.georgeci.moneysurfer.sync.api.SyncCollection
 import com.georgeci.moneysurfer.sync.plugin.EntityApplyResult
 import com.georgeci.moneysurfer.sync.plugin.RemoteDocument
 import com.georgeci.moneysurfer.sync.plugin.SyncEntityPlugin
 import com.georgeci.moneysurfer.sync.repository.ConflictMetadata
 import com.georgeci.moneysurfer.sync.repository.ConflictResolver
 import com.georgeci.moneysurfer.sync.repository.PendingMutation
 import org.koin.core.annotation.Single
 import kotlin.time.Instant
 
 @Single(binds = [SyncEntityPlugin::class])
 class GoalContributionSyncPlugin(
     private val writer: WorkspaceDocumentWriter,
     private val appInfo: AppInfo,
     private val conflictResolver: ConflictResolver,
     private val contributionDao: GoalContributionDao,
     private val goalDao: GoalDao,
 ) : SyncEntityPlugin {
 
     override val entityType: String = SyncEntityTypes.GOAL_CONTRIBUTION
     override val firestoreCollectionName: String = SyncCollection.GOAL_CONTRIBUTIONS
     override val pullPriority: Int = SyncPullPriorities.GOAL_CONTRIBUTIONS
 
     override suspend fun push(mutation: PendingMutation) = writer.pushToCollection(
         mutation = mutation,
         collectionName = SyncCollection.GOAL_CONTRIBUTIONS,
         clientVersionCode = appInfo.versionCode,
         strategy = GoalContributionDoc.serializer(),
     ) {
         contributionDao.getById(mutation.entityId)
             ?.toDoc()
             ?.copy(clientVersionCode = appInfo.versionCode)
     }
 
     override suspend fun applyDoc(doc: RemoteDocument, scopeKey: String): EntityApplyResult {
         val dto = doc.decodeOrNull(GoalContributionDoc.serializer()) ?: return SKIPPED_APPLY_RESULT
         return when {
             dto.deletedAt != null -> {
                 contributionDao.delete(doc.id)
                 EntityApplyResult(applied = true, wasConflict = false)
             }
             // Hard Room FK on goalId. Goals pull first (priority 60 < 70), so the parent is
             // normally present — but a contribution whose goal was deleted on this device can
             // still arrive before its own tombstone does, and inserting it would raise an FK
             // violation that aborts the batch BEFORE the cursor advances, wedging every later
             // pull. Skipping advances the cursor and drops a row with no goal to belong to.
             goalDao.getById(dto.goalId) == null -> SKIPPED_APPLY_RESULT
             else -> resolveAndApply(doc, dto, scopeKey)
         }
     }
 
     private suspend fun resolveAndApply(
         doc: RemoteDocument,
         dto: GoalContributionDoc,
         scopeKey: String,
     ): EntityApplyResult {
         val local = contributionDao.getById(doc.id)
         val resolution = conflictResolver.resolve(
             local = local,
             remote = dto.toEntity(id = doc.id, workspaceId = scopeKey),
             metadata = ConflictMetadata(
                 entityType = SyncEntityTypes.GOAL_CONTRIBUTION,
                 entityId = doc.id,
                 localUpdatedAt = local?.updatedAt?.let(Instant::fromEpochMilliseconds),
                 remoteUpdatedAt = Instant.fromEpochMilliseconds(dto.updatedAt),
             ),
         )
         return applyResolution(resolution) { contributionDao.upsertAll(listOf(it)) }
     }
 }