diff --git a/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt b/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt new file mode 100644 index 0000000..31b1b8a --- /dev/null +++ b/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt @@ -0,0 +1,246 @@ +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.nextcloud.NextcloudProvider +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 = "", + excludePatterns: String = "", + skipHidden: Boolean = false, + 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 = "", excludeExtensions = excludeExtensions, + skipHiddenFiles = skipHidden, minFileSizeKb = 0, 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")) + } + + // ── 13. 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")) + } +} diff --git a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt index 9f32ba3..0a5b7c6 100644 --- a/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt +++ b/app/src/main/kotlin/com/syncflow/domain/sync/SyncEngine.kt @@ -186,6 +186,10 @@ class SyncEngine @Inject constructor( if (pair.deleteBehavior == DeleteBehavior.ARCHIVE) { val archivePath = "${pair.remotePath}/_Deleted/$rel" runCatching { + // Create the _Deleted base itself first — ensureRemoteDirs only + // makes sub-parents of rel, so for a top-level file the MOVE + // would otherwise fail with a missing-parent error. + provider.createDirectory("${pair.remotePath}/_Deleted") ensureRemoteDirs(provider, "${pair.remotePath}/_Deleted", rel) provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow() }.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }