diff --git a/app/src/test/kotlin/com/syncflow/domain/sync/UploadBackupLifecycleTest.kt b/app/src/test/kotlin/com/syncflow/domain/sync/UploadBackupLifecycleTest.kt new file mode 100644 index 0000000..8fba158 --- /dev/null +++ b/app/src/test/kotlin/com/syncflow/domain/sync/UploadBackupLifecycleTest.kt @@ -0,0 +1,117 @@ +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)) + } +}