Characterizes the 'back up phone -> delete locally -> must stay in cloud' scenario across the real multi-cycle engine state (upload saves null remote metadata; next sync reconciles), asserting per delete behavior: - KEEP -> SKIP (cloud copy retained) — correct backup behavior - ARCHIVE -> DELETE_REMOTE decision (engine moves to _Deleted/, preserved) - MIRROR -> DELETE_REMOTE (cloud copy wiped) — footgun, and the current default Also: upload-only never pulls a new remote file down; local edits still upload.
This commit is contained in:
@@ -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
|
||||
// <remote>/_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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user