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) {
|
if (pair.deleteBehavior == DeleteBehavior.ARCHIVE) {
|
||||||
val archivePath = "${pair.remotePath}/_Deleted/$rel"
|
val archivePath = "${pair.remotePath}/_Deleted/$rel"
|
||||||
runCatching {
|
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)
|
ensureRemoteDirs(provider, "${pair.remotePath}/_Deleted", rel)
|
||||||
provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow()
|
provider.moveFile("${pair.remotePath}/$rel", archivePath).getOrThrow()
|
||||||
}.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }
|
}.onFailure { e -> Timber.e(e, "SyncEngine: ARCHIVE failed for $rel") }
|
||||||
|
|||||||
Reference in New Issue
Block a user