package com.syncflow.domain.sync import com.syncflow.data.db.entities.SyncFileStateEntity import com.syncflow.domain.model.ConflictStrategy import com.syncflow.domain.model.DeleteBehavior import com.syncflow.domain.model.RemoteFile import com.syncflow.domain.model.SyncDirection import org.junit.Assert.assertEquals import org.junit.Test import java.time.Instant /** * End-to-end decision lifecycle for the backup scenario: * * "I back up my phone to the cloud, then I delete the file on the phone. * It must stay in the cloud." * * These walk the exact multi-cycle state the SyncEngine produces: * - a successful UPLOAD saves state with the local mtime known and remote metadata null * (SyncEngine.buildState(..., remoteAfterTransfer = null)), * - the next sync sees both sides present and unchanged, returns SKIP, and the SKIP branch * reconciles the record by filling in the remote metadata, * - then the local file is deleted and we assert what happens to the cloud copy. * * The decision is driven entirely by deleteBehavior, so each terminal case is asserted for * KEEP, MIRROR, and ARCHIVE. */ class UploadBackupLifecycleTest { private val T0 = 1_716_393_136_000L // exact second boundary private fun local(ms: Long = T0, size: Long = 100L) = LocalFileInfo("photo.jpg", size, ms) private fun remote(ms: Long = T0, etag: String? = "etag1", size: Long = 100L) = RemoteFile("backup/photo.jpg", "photo.jpg", false, size, Instant.ofEpochMilli(ms), etag, null) /** Mirrors SyncEngine.buildState right after a successful UPLOAD: remote metadata still null. */ private fun stateAfterUpload(ms: Long = T0) = SyncFileStateEntity( syncPairId = 1L, relativePath = "photo.jpg", localModifiedAt = Instant.ofEpochMilli(ms), localSizeBytes = 100L, localHash = null, remoteModifiedAt = null, remoteSizeBytes = 0L, remoteEtag = null, lastSyncedAt = Instant.now(), syncedHash = null, ) /** Mirrors the record after the next sync's SKIP reconciliation fills in remote metadata. */ private fun stateReconciled(ms: Long = T0, etag: String? = "etag1") = SyncFileStateEntity( syncPairId = 1L, relativePath = "photo.jpg", localModifiedAt = Instant.ofEpochMilli(ms), localSizeBytes = 100L, localHash = null, remoteModifiedAt = Instant.ofEpochMilli(ms), remoteSizeBytes = 100L, remoteEtag = etag, lastSyncedAt = Instant.now(), syncedHash = null, ) private fun decide( local: LocalFileInfo?, remote: RemoteFile?, known: SyncFileStateEntity?, delete: DeleteBehavior, ) = syncDecide( SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, delete, local, remote, known, hasPriorSyncState = known != null, ) // ── Cycle 1: first backup uploads the file ─────────────────────────────── @Test fun `cycle 1 - first backup uploads regardless of delete behavior`() { assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.KEEP)) assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.MIRROR)) assertEquals(SyncDecision.UPLOAD, decide(local(), null, null, DeleteBehavior.ARCHIVE)) } // ── Cycle 2: file present on both sides, unchanged -> SKIP (no deletion) ── @Test fun `cycle 2 - unchanged file skips right after upload (remote metadata still null)`() { assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateAfterUpload(), DeleteBehavior.KEEP)) assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateAfterUpload(), DeleteBehavior.MIRROR)) } @Test fun `cycle 2 - unchanged file skips once state is reconciled`() { assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateReconciled(), DeleteBehavior.KEEP)) assertEquals(SyncDecision.SKIP, decide(local(), remote(), stateReconciled(), DeleteBehavior.MIRROR)) } // ── Cycle 3: deleted on the phone — THE scenario ───────────────────────── @Test fun `KEEP - deleting on phone leaves the cloud copy (correct backup behavior)`() { // Reconciled steady state: assertEquals(SyncDecision.SKIP, decide(null, remote(), stateReconciled(), DeleteBehavior.KEEP)) // And even if deletion happens before the reconcile pass ran: assertEquals(SyncDecision.SKIP, decide(null, remote(), stateAfterUpload(), DeleteBehavior.KEEP)) } @Test fun `MIRROR - deleting on phone DELETES the cloud copy (wrong for a backup)`() { assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateReconciled(), DeleteBehavior.MIRROR)) assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateAfterUpload(), DeleteBehavior.MIRROR)) } @Test fun `ARCHIVE - deleting on phone moves the cloud copy to _Deleted (preserved)`() { // syncDecide returns DELETE_REMOTE; the engine's DELETE_REMOTE branch MOVEs the file to // /_Deleted/ instead of removing it when deleteBehavior == ARCHIVE. assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), stateReconciled(), DeleteBehavior.ARCHIVE)) } // ── After deletion: a brand-new remote file is NOT pulled down (upload-only) ─ @Test fun `KEEP - a new remote file never comes down to the phone (upload-only)`() { // Remote-only, no state record: in upload-only this must SKIP, not DOWNLOAD. assertEquals(SyncDecision.SKIP, decide(null, remote(), null, DeleteBehavior.KEEP)) } // ── Re-adding / changing a file after a KEEP deletion still uploads ─────── @Test fun `KEEP - modifying the file locally still uploads the change`() { val newer = local(T0 + 5_000) assertEquals(SyncDecision.UPLOAD, decide(newer, remote(), stateReconciled(), DeleteBehavior.KEEP)) } }