10007eb4fb
Build & Release APK / build (push) Successful in 12m50s
- Interruption: failed mid-write leaves original intact (no truncation, no temp leftover); a sync that drops after N files resumes cleanly on the next sync with all content byte-intact (real network-drop simulation). - SFTP: live round-trip test against an SFTP server (connect/upload-atomic/ list/download/overwrite/special-name/delete); skips if endpoint unreachable. - Scheduling: WorkManager request builders map Wi-Fi-only -> UNMETERED, charging-only -> requiresCharging, interval, input data, and tags correctly.
466 lines
25 KiB
Kotlin
466 lines
25 KiB
Kotlin
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<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 = "",
|
|
includeExtensions: String = "",
|
|
excludePatterns: String = "",
|
|
skipHidden: Boolean = false,
|
|
minKb: Long = 0L,
|
|
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 = 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<RemoteFile> =
|
|
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"))
|
|
}
|
|
}
|