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

Class Method, % Branch, % Line, % Instruction, %
TransactionSyncPlugin 100% (3/3) 83.3% (5/6) 100% (28/28) 99.3% (275/277)
TransactionSyncPlugin$applyDoc$1
TransactionSyncPlugin$push$2 100% (1/1) 75% (3/4) 100% (3/3) 100% (49/49)
Total 100% (4/4) 80% (8/10) 100% (31/31) 99.4% (324/326)


 package com.georgeci.moneysurfer.data.sync.plugin
 import com.georgeci.moneysurfer.data.db.dao.TransactionDao
 import com.georgeci.moneysurfer.data.remote.TransactionDoc
 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 TransactionSyncPlugin(
     private val writer: WorkspaceDocumentWriter,
     private val appInfo: AppInfo,
     private val conflictResolver: ConflictResolver,
     private val transactionDao: TransactionDao,
 ) : SyncEntityPlugin {
 
     override val entityType: String = SyncEntityTypes.TRANSACTION
     override val firestoreCollectionName: String = SyncCollection.TRANSACTIONS
     override val pullPriority: Int = SyncPullPriorities.TRANSACTIONS
 
     override suspend fun push(mutation: PendingMutation) = writer.pushToCollection(
         mutation = mutation,
         collectionName = SyncCollection.TRANSACTIONS,
         clientVersionCode = appInfo.versionCode,
         strategy = TransactionDoc.serializer(),
     ) {
         transactionDao.getById(mutation.entityId)
             ?.toDoc()
             ?.copy(clientVersionCode = appInfo.versionCode)
     }
 
     /**
      * A pulled tombstone marks the local row deleted instead of dropping it (issue #346), so a
      * remote delete and a local one land in exactly the same state — which is what keeps an edit
      * that was already in flight here from colliding with the surviving primary key.
      *
      * It does not follow that a peer's delete stays recoverable for the full retention window: the
      * `deletedAt` written here is the deleting device's, so a delete that was made long before it
      * reached us is purged sooner. `PurgeDeletedTransactionsUseCase` documents why that is fine.
      *
      * It is a targeted UPDATE rather than an upsert of the decoded doc: a tombstone patch carries
      * only `deletedAt`, `updatedAt` and `clientVersionCode`, so upserting what it decodes to would
      * write a row of defaults over real data — and would conjure one out of nothing, failing the
      * account foreign key, on a device that never held the row. A device that never had it has
      * nothing to forget, and `softDelete` no-ops there.
      *
      * The tombstone wins unconditionally, without consulting the resolver — as the hard delete it
      * replaces did. The other direction still goes through last-writer-wins: a remote doc with no
      * `deletedAt` and a newer `updatedAt` than the local delete clears the tombstone through the
      * upsert below, which is a peer's edit legitimately outranking this device's delete.
      *
      * `softDelete` also copies the remote `deletedAt` onto `updatedAt` — the same value the
      * tombstone patch wrote remotely — so re-pulling the doc is a tie, resolves to `TakeLocal` and
      * changes nothing.
      */
     override suspend fun applyDoc(doc: RemoteDocument, scopeKey: String): EntityApplyResult {
         val dto = doc.decodeOrNull(TransactionDoc.serializer()) ?: return SKIPPED_APPLY_RESULT
         val remoteDeletedAt = dto.deletedAt
         if (remoteDeletedAt != null) {
             transactionDao.softDelete(doc.id, remoteDeletedAt)
             return EntityApplyResult(applied = true, wasConflict = false)
         }
         // Includes tombstoned rows on purpose: a locally-deleted row that is invisible here reads
         // as "no local copy", and the resolver would take an older remote doc as a fresh insert —
         // resurrecting exactly what the user just deleted.
         val local = transactionDao.getByIdIncludingDeleted(doc.id)
         val resolution = conflictResolver.resolve(
             local = local,
             remote = dto.toEntity(id = doc.id, workspaceId = scopeKey),
             metadata = ConflictMetadata(
                 entityType = SyncEntityTypes.TRANSACTION,
                 entityId = doc.id,
                 localUpdatedAt = local?.updatedAt?.let(Instant::fromEpochMilliseconds),
                 remoteUpdatedAt = Instant.fromEpochMilliseconds(dto.updatedAt),
             ),
         )
         return applyResolution(resolution) { transactionDao.upsertAll(listOf(it)) }
     }
 }