Files
SyncFlow/app/src/test/kotlin/com/syncflow/domain/sync/SyncDecideTest.kt
T
amir 3d7a8b5f3d fix: remote deletions not mirrored when file has no state record
When a file was uploaded before state-tracking worked (getFileMetadata was
broken), its SyncFileStateEntity was never saved. On next sync the engine
saw !local + remote + known=null and downloaded it back instead of deleting
it remotely, creating an infinite re-download loop.

Fix: syncDecide() now accepts hasPriorSyncState (derived from whether the
pair has any known states at all). On initial sync (no prior state) unknown
remote files are downloaded as before. Once the pair has been synced, unknown
remote-only files are treated as mirror-eligible deletions — same as if known
state existed — so locally-deleted files propagate to the remote correctly.

Verified live: 3 remote-only orphan files deleted from Nextcloud on sync.
Bump version to 1.0.12 (code 13).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 02:18:27 +00:00

165 lines
8.2 KiB
Kotlin

package com.syncflow.domain.sync
import com.syncflow.data.db.entities.SyncFileStateEntity
import com.syncflow.domain.model.*
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Instant
class SyncDecideTest {
private val MS = 1_716_000_000_000L
private val MS2 = MS + 5_000L
private fun local(ms: Long = MS, size: Long = 100L) = LocalFileInfo("test.txt", size, ms)
private fun remote(ms: Long = MS, etag: String? = "abc", size: Long = 100L) =
RemoteFile(
path = "path/test.txt", name = "test.txt", isDirectory = false,
sizeBytes = size, modifiedAt = Instant.ofEpochMilli(ms),
etag = etag, mimeType = null,
)
private fun state(localMs: Long? = MS, remoteMs: Long? = MS, etag: String? = "abc") =
SyncFileStateEntity(
syncPairId = 1L, relativePath = "test.txt",
localModifiedAt = localMs?.let { Instant.ofEpochMilli(it) },
localSizeBytes = 100L, localHash = null,
remoteModifiedAt = remoteMs?.let { Instant.ofEpochMilli(it) },
remoteSizeBytes = 100L, remoteEtag = etag,
lastSyncedAt = Instant.now(), syncedHash = null,
)
private fun decide(
local: LocalFileInfo?, remote: RemoteFile?, known: SyncFileStateEntity? = null,
dir: SyncDirection = SyncDirection.TWO_WAY,
conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST,
delete: DeleteBehavior = DeleteBehavior.MIRROR,
hasPriorState: Boolean = known != null,
) = syncDecide(dir, conflict, delete, local, remote, known, hasPriorState)
// ── first sync (no known state) ───────────────────────────────────────────
@Test fun `first sync both exist local newer uploads`() =
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS)))
@Test fun `first sync both exist remote newer downloads`() =
assertEquals(SyncDecision.DOWNLOAD, decide(local(MS), remote(MS2)))
@Test fun `first sync local only TWO_WAY uploads`() =
assertEquals(SyncDecision.UPLOAD, decide(local(), null))
@Test fun `first sync remote only TWO_WAY downloads`() =
assertEquals(SyncDecision.DOWNLOAD, decide(null, remote()))
// ── after upload: remote metadata null in state ───────────────────────────
@Test fun `second sync after upload remote metadata null skips`() {
// State saved after upload: local mtime known, remote unknown (null).
val known = state(localMs = MS, remoteMs = null, etag = null)
// Remote listing shows a new mtime (server assigned), but we treat null as "no change".
assertEquals(SyncDecision.SKIP, decide(local(MS), remote(MS2), known))
}
@Test fun `after upload local changed again re-uploads`() {
val known = state(localMs = MS, remoteMs = null, etag = null)
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS2), known))
}
// ── after download: local mtime recorded ─────────────────────────────────
@Test fun `second sync fully recorded skips`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.SKIP, decide(local(MS), remote(MS, etag = "abc"), known))
}
@Test fun `remote changed after download downloads`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.DOWNLOAD, decide(local(MS), remote(MS2, etag = "xyz"), known))
}
@Test fun `local changed after download uploads`() {
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.UPLOAD, decide(local(MS2), remote(MS, etag = "abc"), known))
}
// ── epoch-millis precision ────────────────────────────────────────────────
@Test fun `same millisecond timestamp treated as unchanged`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.SKIP,
decide(local(ts), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `1ms difference detected as local change`() {
val ts = 1_716_393_136_789L
assertEquals(SyncDecision.UPLOAD,
decide(local(ts + 1), remote(ts, etag = "e"), state(localMs = ts, remoteMs = ts, etag = "e")))
}
@Test fun `epoch-second stored value differs from millis comparison`() {
// If we stored 1716393136 (seconds) and compare to 1716393136000 (millis) they differ →
// This was the original bug — now we store millis so they should match.
val ms = 1_716_393_136_000L // exact second boundary, no sub-second component
assertEquals(SyncDecision.SKIP,
decide(local(ms), remote(ms, etag = "e"), state(localMs = ms, remoteMs = ms, etag = "e")))
}
// ── delete behaviour ──────────────────────────────────────────────────────
@Test fun `local exists remote deleted TWO_WAY MIRROR deletes local`() =
assertEquals(SyncDecision.DELETE_LOCAL, decide(local(), null, state(), delete = DeleteBehavior.MIRROR))
@Test fun `local exists remote deleted KEEP skips`() =
assertEquals(SyncDecision.SKIP, decide(local(), null, state(), delete = DeleteBehavior.KEEP))
@Test fun `remote deleted UPLOAD_ONLY skips local deletion`() =
assertEquals(SyncDecision.SKIP,
decide(local(), null, state(), dir = SyncDirection.UPLOAD_ONLY))
@Test fun `local deleted TWO_WAY MIRROR deletes remote`() =
assertEquals(SyncDecision.DELETE_REMOTE, decide(null, remote(), state(), delete = DeleteBehavior.MIRROR))
@Test fun `local deleted TWO_WAY KEEP skips`() =
assertEquals(SyncDecision.SKIP, decide(null, remote(), state(), delete = DeleteBehavior.KEEP))
@Test fun `local deleted DOWNLOAD_ONLY skips remote deletion`() =
assertEquals(SyncDecision.SKIP,
decide(null, remote(), state(), dir = SyncDirection.DOWNLOAD_ONLY))
// ── local deleted, no state record (uploaded in broken version) ──────────
@Test fun `local deleted no known state but pair has prior history deletes remote`() =
// hasPriorState=true means the pair has been synced before; file has no state
// because it was uploaded when getFileMetadata was broken. Should still mirror deletion.
assertEquals(SyncDecision.DELETE_REMOTE,
decide(null, remote(), known = null, delete = DeleteBehavior.MIRROR, hasPriorState = true))
@Test fun `initial sync remote only no prior state downloads`() =
assertEquals(SyncDecision.DOWNLOAD,
decide(null, remote(), known = null, hasPriorState = false))
// ── first-seen SKIP saves baseline so later deletions are detected ────────
@Test fun `first sync both exist same mtime uploads local wins tie`() =
assertEquals(SyncDecision.UPLOAD, decide(local(MS), remote(MS, etag = "abc")))
@Test fun `after first-seen skip local deleted deletes remote`() {
// Simulate: first sync saw both sides identical → SKIP (state saved by engine).
// Then local file deleted → known is now present → DELETE_REMOTE.
val known = state(localMs = MS, remoteMs = MS, etag = "abc")
assertEquals(SyncDecision.DELETE_REMOTE,
decide(null, remote(MS, etag = "abc"), known, delete = DeleteBehavior.MIRROR))
}
// ── directions ────────────────────────────────────────────────────────────
@Test fun `UPLOAD_ONLY ignores remote changes`() =
assertEquals(SyncDecision.SKIP,
decide(local(MS), remote(MS2, etag = "new"), state(), dir = SyncDirection.UPLOAD_ONLY))
@Test fun `DOWNLOAD_ONLY ignores local changes`() =
assertEquals(SyncDecision.SKIP,
decide(local(MS2), remote(MS, etag = "abc"), state(), dir = SyncDirection.DOWNLOAD_ONLY))
}