Add interruption/atomicity, SFTP, and scheduling tests
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.
This commit is contained in:
2026-06-05 15:16:10 +00:00
parent 29b5d555b8
commit 10007eb4fb
3 changed files with 158 additions and 0 deletions
@@ -7,7 +7,9 @@ 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
@@ -420,4 +422,44 @@ class FullSyncEngineTest {
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"))
}
}