Fix ARCHIVE delete (create _Deleted base) + full engine test matrix
Build & Release APK / build (push) Successful in 12m46s
Build & Release APK / build (push) Successful in 12m46s
Full-matrix on-device test (FullSyncEngineTest) drives the real SyncEngine (in-memory Room + real local folder + live Nextcloud) across all directions, all delete behaviors, updates, recursive/non-recursive, filters, conflicts, and content integrity — 14 instrumented tests, all green on a Galaxy S23. It caught a real bug: ARCHIVE delete moved files to _Deleted/ but never created the _Deleted folder, so the MOVE failed for top-level files and they were left in place. Now creates the _Deleted base before the move.
This commit is contained in:
@@ -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<File>()
|
||||
|
||||
@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<SyncPair, File, String> {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
|
||||
Reference in New Issue
Block a user