package com.syncflow import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.syncflow.data.db.SyncDatabase import com.syncflow.data.db.entities.CloudAccountEntity import com.syncflow.data.db.entities.SyncPairEntity import com.syncflow.data.db.entities.toDomain import com.syncflow.data.providers.CloudProvider import com.syncflow.data.providers.nextcloud.NextcloudProvider import com.syncflow.domain.sync.LocalAccessor import com.syncflow.domain.model.* import com.syncflow.domain.sync.SyncEngine import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.* import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.time.Instant /** * Full-matrix end-to-end test of the REAL SyncEngine against a live Nextcloud, on the device: * in-memory Room DB, a real local folder per test, and the real NextcloudProvider over TLS. * Covers every direction, every delete behavior, updates, nested/recursive, filters, and conflicts. * * Creds via instrumentation args: -e ncUrl ... -e ncUser ... -e ncPass ... */ @RunWith(AndroidJUnit4::class) class FullSyncEngineTest { private val ctx = InstrumentationRegistry.getInstrumentation().targetContext private val args = InstrumentationRegistry.getArguments() private val url get() = args.getString("ncUrl") private val user get() = args.getString("ncUser") private val pass get() = args.getString("ncPass") private lateinit var db: SyncDatabase private lateinit var engine: SyncEngine private lateinit var provider: NextcloudProvider private var accountId = 0L private val remoteRoot = "SyncFlowFull_${System.currentTimeMillis()}" private val localDirs = mutableListOf() @Before fun setUp() = runBlocking { assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null) db = Room.inMemoryDatabaseBuilder(ctx, SyncDatabase::class.java).build() val account = CloudAccount(0, "IT", user, ProviderType.NEXTCLOUD, """{"username":"$user","password":"$pass"}""", url, null) accountId = db.cloudAccountDao().insert( CloudAccountEntity(0, account.displayName, account.email, account.providerType, account.credentialJson, account.serverUrl, account.port)) provider = NextcloudProvider(account) engine = SyncEngine(db.syncPairDao(), db.syncFileStateDao(), db.syncConflictDao(), db.syncEventDao(), ctx) provider.createDirectory(remoteRoot).getOrThrow() } @After fun tearDown() = runBlocking { runCatching { provider.deleteFile(remoteRoot) } localDirs.forEach { it.deleteRecursively() } if (::db.isInitialized) db.close() } // ── helpers ────────────────────────────────────────────────────────────── private suspend fun newPair( name: String, dir: SyncDirection = SyncDirection.TWO_WAY, delete: DeleteBehavior = DeleteBehavior.MIRROR, conflict: ConflictStrategy = ConflictStrategy.KEEP_NEWEST, recursive: Boolean = true, excludeExtensions: String = "", includeExtensions: String = "", excludePatterns: String = "", skipHidden: Boolean = false, minKb: Long = 0L, maxKb: Long = 0L, ): Triple { val local = File(ctx.cacheDir, "synctest_${name}_${System.currentTimeMillis()}").apply { mkdirs() } localDirs += local val remote = "$remoteRoot/$name" provider.createDirectory(remote).getOrThrow() val id = db.syncPairDao().insert(SyncPairEntity( id = 0, name = name, localPath = local.absolutePath, remotePath = remote, accountId = accountId, syncDirection = dir, conflictStrategy = conflict, deleteBehavior = delete, recursive = recursive, scheduleType = ScheduleType.MANUAL, scheduleIntervalMinutes = 30, scheduleDailyTime = null, scheduleWeekdays = 0, wifiOnly = false, wifiSsid = "", chargingOnly = false, minBatteryPct = 0, excludePatterns = excludePatterns, includeExtensions = includeExtensions, excludeExtensions = excludeExtensions, skipHiddenFiles = skipHidden, minFileSizeKb = minKb, maxFileSizeKb = maxKb, notifyOnComplete = false, notifyOnError = false, isEnabled = true, lastSyncAt = null, lastSyncResult = SyncStatus.IDLE, pendingConflicts = 0, )) return Triple(db.syncPairDao().getById(id)!!.toDomain(), local, remote) } private suspend fun sync(pair: SyncPair) = engine.sync(pair, provider) private fun write(dir: File, rel: String, content: String) = File(dir, rel).apply { parentFile?.mkdirs() }.writeText(content) private suspend fun remoteNames(remote: String) = provider.listFiles(remote).getOrThrow().map { it.name } private suspend fun remoteText(path: String): String { val out = ByteArrayOutputStream(); provider.downloadFile(path, out).getOrThrow(); return out.toString("UTF-8") } private suspend fun putRemote(remote: String, name: String, content: String) { val b = content.toByteArray() provider.uploadFile(ByteArrayInputStream(b), "$remote/$name", b.size.toLong()).getOrThrow() } // ── 1. Upload-only backup: delete on phone keeps the cloud copy (KEEP) ──── @Test fun uploadOnly_keep_localDeleteKeepsCloud() = runBlocking { val (pair, local, remote) = newPair("ul_keep", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) write(local, "a.txt", "hello") assertEquals(1, sync(pair).uploaded) assertTrue("a.txt" in remoteNames(remote)) File(local, "a.txt").delete() val r = sync(pair) assertEquals("KEEP must not delete remotely", 0, r.deleted) assertTrue("cloud copy must survive", "a.txt" in remoteNames(remote)) } // ── 2. Upload-only + MIRROR: local delete removes the cloud copy ────────── @Test fun uploadOnly_mirror_localDeleteRemovesCloud() = runBlocking { val (pair, local, remote) = newPair("ul_mirror", SyncDirection.UPLOAD_ONLY, DeleteBehavior.MIRROR) write(local, "a.txt", "hello"); assertEquals(1, sync(pair).uploaded) File(local, "a.txt").delete() assertEquals(1, sync(pair).deleted) assertFalse("a.txt" in remoteNames(remote)) } // ── 3. Upload-only + ARCHIVE: deleted file moved to _Deleted/ ───────────── @Test fun uploadOnly_archive_movesToDeleted() = runBlocking { val (pair, local, remote) = newPair("ul_archive", SyncDirection.UPLOAD_ONLY, DeleteBehavior.ARCHIVE) write(local, "a.txt", "keepme"); sync(pair) File(local, "a.txt").delete(); sync(pair) assertFalse("a.txt" in remoteNames(remote)) assertTrue("archived copy expected", "a.txt" in remoteNames("$remote/_Deleted")) } // ── 4. Two-way initial sync: each side gets the other's files ───────────── @Test fun twoWay_initial_mergesBothSides() = runBlocking { val (pair, local, remote) = newPair("tw_init", SyncDirection.TWO_WAY) write(local, "local.txt", "L") putRemote(remote, "remote.txt", "R") val r = sync(pair) assertEquals(1, r.uploaded); assertEquals(1, r.downloaded) assertTrue(File(local, "remote.txt").exists()) assertTrue("local.txt" in remoteNames(remote) && "remote.txt" in remoteNames(remote)) } // ── 5. Two-way: a local edit propagates to the remote ───────────────────── @Test fun twoWay_localEdit_updatesRemote() = runBlocking { val (pair, local, remote) = newPair("tw_edit", SyncDirection.TWO_WAY) write(local, "f.txt", "v1"); sync(pair) Thread.sleep(1100) // cross the 1s mtime resolution write(local, "f.txt", "v2-updated"); val r = sync(pair) assertEquals(1, r.uploaded) assertEquals("v2-updated", remoteText("$remote/f.txt")) } // ── 6. Two-way + MIRROR: deleting locally removes it remotely ───────────── @Test fun twoWay_mirror_localDeletePropagates() = runBlocking { val (pair, local, remote) = newPair("tw_mirror", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR) write(local, "f.txt", "x"); sync(pair) File(local, "f.txt").delete() assertEquals(1, sync(pair).deleted) assertFalse("f.txt" in remoteNames(remote)) } // ── 7. Download-only: pulls remote, never uploads local-only files ──────── @Test fun downloadOnly_pullsRemoteIgnoresLocal() = runBlocking { val (pair, local, remote) = newPair("dl_only", SyncDirection.DOWNLOAD_ONLY) putRemote(remote, "cloud.txt", "from-cloud") write(local, "phoneonly.txt", "P") val r = sync(pair) assertEquals(1, r.downloaded); assertEquals(0, r.uploaded) assertEquals("from-cloud", File(local, "cloud.txt").readText()) assertFalse("local-only file must NOT upload", "phoneonly.txt" in remoteNames(remote)) } // ── 8. Recursive: nested directory structure is preserved ───────────────── @Test fun recursive_uploadsNestedTree() = runBlocking { val (pair, local, remote) = newPair("rec_on", SyncDirection.UPLOAD_ONLY, recursive = true) write(local, "sub/deep/n.txt", "nested") assertEquals(1, sync(pair).uploaded) assertTrue("n.txt" in remoteNames("$remote/sub/deep")) } // ── 9. recursive=false: subfolders are skipped ──────────────────────────── @Test fun nonRecursive_skipsSubfolders() = runBlocking { val (pair, local, remote) = newPair("rec_off", SyncDirection.UPLOAD_ONLY, recursive = false) write(local, "top.txt", "T") write(local, "sub/deep.txt", "D") assertEquals(1, sync(pair).uploaded) assertTrue("top.txt" in remoteNames(remote)) assertTrue("subfolder must be skipped", remoteNames(remote).none { it == "sub" }) } // ── 10. Filters: excluded extension + hidden file are not uploaded ───────── @Test fun filters_excludeExtensionAndHidden() = runBlocking { val (pair, local, remote) = newPair("filters", SyncDirection.UPLOAD_ONLY, excludeExtensions = "tmp", skipHidden = true) write(local, "keep.txt", "k") write(local, "skip.tmp", "s") write(local, ".hidden", "h") sync(pair) val names = remoteNames(remote) assertTrue("keep.txt" in names) assertFalse("skip.tmp" in names) assertFalse(".hidden" in names) } // ── 11. Conflict: both sides changed (ASK) → conflict recorded, no clobber ─ @Test fun twoWay_bothChanged_recordsConflict() = runBlocking { val (pair, local, remote) = newPair("conflict", SyncDirection.TWO_WAY, conflict = ConflictStrategy.ASK) write(local, "c.txt", "base"); sync(pair) // upload sync(pair) // reconcile: record remote baseline (etag/mtime) Thread.sleep(1100) write(local, "c.txt", "LOCAL-change") // change local putRemote(remote, "c.txt", "REMOTE-change") // change remote out-of-band val r = sync(pair) assertEquals("a conflict must be detected", 1, r.conflicts) // ASK must not silently overwrite either side assertEquals("LOCAL-change", File(local, "c.txt").readText()) assertEquals("REMOTE-change", remoteText("$remote/c.txt")) } // ── 12. Conflict KEEP_NEWEST: newer local wins and uploads ──────────────── @Test fun twoWay_keepNewest_newerLocalWins() = runBlocking { val (pair, local, remote) = newPair("newest", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_NEWEST) write(local, "n.txt", "base"); sync(pair) putRemote(remote, "n.txt", "remote-older") Thread.sleep(1100) write(local, "n.txt", "local-newer") // local is newer than remote sync(pair) assertEquals("newer local must win", "local-newer", remoteText("$remote/n.txt")) } // ── 13b. Special & non-ASCII filenames upload (WebDAV URL/header encoding) ─ @Test fun specialAndNonAsciiNames_upload() = runBlocking { val (pair, local, remote) = newPair("special", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) write(local, "naïve café.txt", "accents") // non-ASCII (broke MOVE Destination header) write(local, "a&b (1).txt", "ampersand") // & ( ) space write(local, "日本語.txt", "cjk") // multibyte unicode write(local, "my photo.txt", "space") val r = sync(pair) assertEquals("all special-name files must upload", 4, r.uploaded) assertEquals(0, r.failedFiles) val names = remoteNames(remote) assertTrue("naïve café.txt" in names) assertTrue("a&b (1).txt" in names) assertTrue("日本語.txt" in names) assertTrue("my photo.txt" in names) } // ── 13c. Volume: 100+ files (incl. subfolders & non-ASCII) upload, 0 fails ─ @Test fun volume_hundredFiles_allUploadNoFailures() = runBlocking { val (pair, local, remote) = newPair("vol100", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) repeat(100) { i -> write(local, "f_%03d.txt".format(i), "payload $i ".repeat(30)) } write(local, "sub/nested_a.txt", "n1") write(local, "sub/deep/nested_b.txt", "n2") write(local, "naïve café.txt", "accented") val r = sync(pair) assertEquals("no file may fail under volume", 0, r.failedFiles) assertEquals("all 103 files upload", 103, r.uploaded) assertEquals("100 flat files present on cloud", 100, remoteNames(remote).count { it.startsWith("f_") }) assertTrue("non-ASCII name present too", "naïve café.txt" in remoteNames(remote)) // re-sync is a clean no-op (no phantom re-uploads / loops at volume) val r2 = sync(pair) assertEquals(0, r2.uploaded); assertEquals(0, r2.deleted); assertEquals(0, r2.failedFiles) } // ── 14. Content integrity: binary-ish bytes round-trip exactly ──────────── @Test fun contentIntegrity_roundTrip() = runBlocking { val (pair, local, remote) = newPair("integrity", SyncDirection.TWO_WAY) val payload = (0..5000).joinToString("") { "Ω$it·" } write(local, "big.txt", payload); sync(pair) assertEquals(payload, remoteText("$remote/big.txt")) } // ══ EDGE CASES & STRESS ═══════════════════════════════════════════════════ private fun writeBytes(dir: File, rel: String, bytes: ByteArray) = File(dir, rel).apply { parentFile?.mkdirs() }.writeBytes(bytes) // 15. Empty (0-byte) file uploads correctly @Test fun emptyFile_uploads() = runBlocking { val (pair, local, remote) = newPair("empty", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) write(local, "zero.txt", "") val r = sync(pair) assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded) assertEquals(0L, provider.listFiles(remote).getOrThrow().first { it.name == "zero.txt" }.sizeBytes) } // 16. Large file (20 MB) uploads + downloads byte-intact (OOM / streaming guard) @Test fun largeFile_intactRoundTrip() = runBlocking { val (pair, local, remote) = newPair("large", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) val size = 20 * 1024 * 1024 val bytes = ByteArray(size).also { java.util.Random(42).nextBytes(it) } writeBytes(local, "big.bin", bytes) val r = sync(pair) assertEquals(0, r.failedFiles); assertEquals(1, r.uploaded) assertEquals(size.toLong(), provider.listFiles(remote).getOrThrow().first { it.name == "big.bin" }.sizeBytes) val out = ByteArrayOutputStream(size); provider.downloadFile("$remote/big.bin", out).getOrThrow() val dl = out.toByteArray() assertEquals(size, dl.size) assertArrayEquals(bytes.copyOfRange(0, 4096), dl.copyOfRange(0, 4096)) assertArrayEquals(bytes.copyOfRange(size - 4096, size), dl.copyOfRange(size - 4096, size)) } // 17. Deeply nested path (8 levels) is created + uploaded @Test fun deepNesting_uploads() = runBlocking { val (pair, local, remote) = newPair("deep", SyncDirection.UPLOAD_ONLY, recursive = true) write(local, "a/b/c/d/e/f/g/deep.txt", "deep") assertEquals(0, sync(pair).failedFiles) assertTrue("deep.txt" in remoteNames("$remote/a/b/c/d/e/f/g")) } // 18. Unicode FOLDER names (not just files) are created + encoded @Test fun unicodeFolderNames_upload() = runBlocking { val (pair, local, remote) = newPair("ufolder", SyncDirection.UPLOAD_ONLY, recursive = true) write(local, "Фото/café/x.txt", "u") assertEquals(0, sync(pair).failedFiles) assertTrue("x.txt" in remoteNames("$remote/Фото/café")) } // 19. Very long filename (200 chars) @Test fun veryLongFilename_uploads() = runBlocking { val (pair, local, remote) = newPair("longname", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) val name = "L".repeat(200) + ".txt" write(local, name, "x") assertEquals(0, sync(pair).failedFiles) assertTrue(name in remoteNames(remote)) } // 20. File with no extension @Test fun noExtensionFile_uploads() = runBlocking { val (pair, local, remote) = newPair("noext", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) write(local, "README", "x") assertEquals(1, sync(pair).uploaded) assertTrue("README" in remoteNames(remote)) } // 21. Idempotency / loop guard — repeated syncs do NOT re-upload anything @Test fun idempotent_repeatedSyncsNoPhantomUploads() = runBlocking { val (pair, local, remote) = newPair("idem", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) repeat(10) { i -> write(local, "x_$i.txt", "v$i") } assertEquals(10, sync(pair).uploaded) repeat(4) { val r = sync(pair) assertEquals("sync must be idempotent (no re-upload loop)", 0, r.uploaded) assertEquals(0, r.deleted); assertEquals(0, r.failedFiles) } } // 22. Bulk update — modifying many files re-uploads exactly those @Test fun bulkUpdate_reuploadsChanged() = runBlocking { val (pair, local, remote) = newPair("bulkupd", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) repeat(10) { i -> write(local, "u_$i.txt", "v1") }; sync(pair) Thread.sleep(1100) repeat(10) { i -> write(local, "u_$i.txt", "v2-updated-content") } assertEquals(10, sync(pair).uploaded) assertEquals("v2-updated-content", remoteText("$remote/u_0.txt")) } // 23. Bulk delete (MIRROR two-way) propagates all deletions @Test fun mirror_bulkDeletePropagates() = runBlocking { val (pair, local, remote) = newPair("bulkdel", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR) repeat(10) { i -> write(local, "d_$i.txt", "x") }; sync(pair) repeat(10) { i -> File(local, "d_$i.txt").delete() } assertEquals(10, sync(pair).deleted) assertEquals(0, remoteNames(remote).count { it.startsWith("d_") }) } // 24. Bulk download (download-only) pulls all remote files @Test fun downloadOnly_bulkPull() = runBlocking { val (pair, local, remote) = newPair("bulkdl", SyncDirection.DOWNLOAD_ONLY) repeat(10) { i -> putRemote(remote, "r_$i.txt", "cloud$i") } assertEquals(10, sync(pair).downloaded) assertEquals(10, local.listFiles()!!.count { it.name.startsWith("r_") }) } // 25. KEEP_BOTH conflict strategy records a conflict (no silent clobber) @Test fun twoWay_keepBoth_recordsConflict() = runBlocking { val (pair, local, remote) = newPair("keepboth", SyncDirection.TWO_WAY, conflict = ConflictStrategy.KEEP_BOTH) write(local, "c.txt", "base"); sync(pair); sync(pair) // baseline + reconcile Thread.sleep(1100) write(local, "c.txt", "LOCAL"); putRemote(remote, "c.txt", "REMOTE") assertEquals(1, sync(pair).conflicts) assertEquals("LOCAL", File(local, "c.txt").readText()) assertEquals("REMOTE", remoteText("$remote/c.txt")) } // 26. Min-size filter skips tiny files @Test fun filters_minSizeSkipsTiny() = runBlocking { val (pair, local, remote) = newPair("minsize", SyncDirection.UPLOAD_ONLY, minKb = 1) write(local, "tiny.txt", "x") // < 1 KB write(local, "big.txt", "A".repeat(2048)) // ~2 KB sync(pair) val n = remoteNames(remote) assertFalse("tiny.txt" in n); assertTrue("big.txt" in n) } // 27. Include-extension filter uploads only matching files @Test fun filters_includeExtensionOnly() = runBlocking { val (pair, local, remote) = newPair("incl", SyncDirection.UPLOAD_ONLY, includeExtensions = "jpg") write(local, "keep.jpg", "x"); write(local, "skip.txt", "y") sync(pair) val n = remoteNames(remote) assertTrue("keep.jpg" in n); assertFalse("skip.txt" in n) } // 28. Whole-folder wipe locally (MIRROR) removes all remote copies @Test fun mirror_emptyLocalWipesRemote() = runBlocking { val (pair, local, remote) = newPair("wipe", SyncDirection.TWO_WAY, DeleteBehavior.MIRROR) repeat(5) { i -> write(local, "w_$i.txt", "x") }; sync(pair) local.listFiles()!!.forEach { it.delete() } assertEquals(5, sync(pair).deleted) assertEquals(0, remoteNames(remote).count { it.startsWith("w_") }) } // ══ INTERRUPTION / ATOMICITY ══════════════════════════════════════════════ // 29. A write that fails mid-stream must leave the existing file intact (no truncation) @Test fun atomicWrite_failedWriteLeavesOriginalIntact() = runBlocking { val dir = File(ctx.cacheDir, "atomic_${System.currentTimeMillis()}").apply { mkdirs() } localDirs += dir File(dir, "f.txt").writeText("ORIGINAL-GOOD-CONTENT") val accessor = LocalAccessor.JavaFile(dir) val outcome = runCatching { accessor.writeAtomically("f.txt") { os -> os.write("PARTIAL-GARBAGE".toByteArray()); os.flush() throw java.io.IOException("simulated network drop mid-download") } } assertTrue("the failed write must propagate", outcome.isFailure) assertEquals("original must be untouched after a failed write", "ORIGINAL-GOOD-CONTENT", File(dir, "f.txt").readText()) assertTrue("no leftover .sfpart temp", dir.listFiles()!!.none { it.name.endsWith(".sfpart") }) } // 30. A sync interrupted partway (provider fails after N files) loses nothing and the // next sync completes the rest with all content intact. @Test fun interruptedSync_resumesCleanlyNoCorruption() = runBlocking { val (pair, local, remote) = newPair("interrupt", SyncDirection.UPLOAD_ONLY, DeleteBehavior.KEEP) repeat(10) { i -> write(local, "i_$i.txt", "content-$i-".repeat(50)) } // Provider that simulates a connection drop after 4 successful uploads. val flaky = object : CloudProvider by provider { private val n = java.util.concurrent.atomic.AtomicInteger(0) override suspend fun uploadFile(localStream: java.io.InputStream, remotePath: String, sizeBytes: Long, onProgress: (Long) -> Unit): Result = if (n.incrementAndGet() > 4) Result.failure(java.io.IOException("connection dropped")) else provider.uploadFile(localStream, remotePath, sizeBytes, onProgress) } val r1 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), flaky) assertTrue("some files should fail on the dropped sync", r1.failedFiles > 0) // Re-sync with the healthy provider completes the rest. val r2 = engine.sync(db.syncPairDao().getById(pair.id)!!.toDomain(), provider) assertEquals("re-sync must complete with no failures", 0, r2.failedFiles) assertEquals("all 10 files end up on the cloud", 10, remoteNames(remote).count { it.startsWith("i_") }) assertEquals("content intact (no truncation)", "content-0-".repeat(50), remoteText("$remote/i_0.txt")) } }