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)) }