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

Class Method, % Branch, % Line, % Instruction, %
SavingsGoalSyncPlugin 100% (4/4) 70% (7/10) 100% (30/30) 97.3% (326/335)
SavingsGoalSyncPlugin$applyDoc$1
SavingsGoalSyncPlugin$push$2 100% (1/1) 50% (2/4) 100% (3/3) 100% (44/44)
SavingsGoalSyncPlugin$withResolvableAccount$1
Total 100% (5/5) 64.3% (9/14) 100% (33/33) 97.6% (370/379)


 package com.georgeci.moneysurfer.data.sync.plugin
 import com.georgeci.moneysurfer.data.db.dao.AccountDao
 import com.georgeci.moneysurfer.data.db.dao.GoalDao
 import com.georgeci.moneysurfer.data.db.entity.GoalEntity
 import com.georgeci.moneysurfer.data.remote.GoalDoc
 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 SavingsGoalSyncPlugin(
     private val writer: WorkspaceDocumentWriter,
     private val appInfo: AppInfo,
     private val conflictResolver: ConflictResolver,
     private val goalDao: GoalDao,
     private val accountDao: AccountDao,
 ) : SyncEntityPlugin {
 
     override val entityType: String = SyncEntityTypes.GOAL
     override val firestoreCollectionName: String = SyncCollection.GOALS
     override val pullPriority: Int = SyncPullPriorities.GOALS
 
     override suspend fun push(mutation: PendingMutation) = writer.pushToCollection(
         mutation = mutation,
         collectionName = SyncCollection.GOALS,
         clientVersionCode = appInfo.versionCode,
         strategy = GoalDoc.serializer(),
     ) {
         goalDao.getById(mutation.entityId)
             ?.toDoc()
             ?.copy(clientVersionCode = appInfo.versionCode)
     }
 
     override suspend fun applyDoc(doc: RemoteDocument, scopeKey: String): EntityApplyResult {
         val dto = doc.decodeOrNull(GoalDoc.serializer()) ?: return SKIPPED_APPLY_RESULT
         if (dto.deletedAt != null) {
             // Room cascades the contribution rows away with the goal. The peer that
             // deleted the goal enqueued their tombstones too, so nothing is lost here.
             goalDao.delete(doc.id)
             return EntityApplyResult(applied = true, wasConflict = false)
         }
         val local = goalDao.getById(doc.id)
         val resolution = conflictResolver.resolve(
             local = local,
             remote = dto.toEntity(id = doc.id, workspaceId = scopeKey).withResolvableAccount(),
             metadata = ConflictMetadata(
                 entityType = SyncEntityTypes.GOAL,
                 entityId = doc.id,
                 localUpdatedAt = local?.updatedAt?.let(Instant::fromEpochMilliseconds),
                 remoteUpdatedAt = Instant.fromEpochMilliseconds(dto.updatedAt),
             ),
         )
         return applyResolution(resolution) { goalDao.upsertAll(listOf(it)) }
     }
 
     /**
      * Drops an `accountId` that points at an account this device does not have.
      *
      * The FK is soft in spirit — the pointer is decorative — but hard in SQLite, so
      * a goal referencing an account that was never pulled here (or was deleted
      * before this doc arrived) would fail the insert and abort the batch BEFORE the
      * cursor advanced, wedging every later pull. Accounts pull first (priority 20 <
      * 60), so this only fires for a genuinely absent account. The goal keeps every
      * field that carries meaning; only the decoration is lost, and a later edit that
      * re-points it syncs the pointer back.
      */
     private suspend fun GoalEntity.withResolvableAccount(): GoalEntity {
         val id = accountId ?: return this
         return if (accountDao.getById(id) == null) copy(accountId = null) else this
     }
 }