Add interruption/atomicity, SFTP, and scheduling tests
Build & Release APK / build (push) Successful in 12m50s
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:
@@ -7,7 +7,9 @@ import com.syncflow.data.db.SyncDatabase
|
|||||||
import com.syncflow.data.db.entities.CloudAccountEntity
|
import com.syncflow.data.db.entities.CloudAccountEntity
|
||||||
import com.syncflow.data.db.entities.SyncPairEntity
|
import com.syncflow.data.db.entities.SyncPairEntity
|
||||||
import com.syncflow.data.db.entities.toDomain
|
import com.syncflow.data.db.entities.toDomain
|
||||||
|
import com.syncflow.data.providers.CloudProvider
|
||||||
import com.syncflow.data.providers.nextcloud.NextcloudProvider
|
import com.syncflow.data.providers.nextcloud.NextcloudProvider
|
||||||
|
import com.syncflow.domain.sync.LocalAccessor
|
||||||
import com.syncflow.domain.model.*
|
import com.syncflow.domain.model.*
|
||||||
import com.syncflow.domain.sync.SyncEngine
|
import com.syncflow.domain.sync.SyncEngine
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -420,4 +422,44 @@ class FullSyncEngineTest {
|
|||||||
assertEquals(5, sync(pair).deleted)
|
assertEquals(5, sync(pair).deleted)
|
||||||
assertEquals(0, remoteNames(remote).count { it.startsWith("w_") })
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.syncflow
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import com.syncflow.worker.SyncWorker
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduling/constraint mapping for WorkManager-backed syncs. Verifies the request builders
|
||||||
|
* translate pair settings into the right constraints (Wi-Fi-only, charging-only), interval, input
|
||||||
|
* data, and tags — the deterministic part of scheduling (without waiting for the OS to fire it).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SchedulingTest {
|
||||||
|
|
||||||
|
@Test fun periodic_wifiOnly_chargingOnly_intervalAndData() {
|
||||||
|
val req = SyncWorker.buildPeriodicRequest(pairId = 42L, intervalMinutes = 30, wifiOnly = true, chargingOnly = true)
|
||||||
|
val ws = req.workSpec
|
||||||
|
assertEquals(NetworkType.UNMETERED, ws.constraints.requiredNetworkType)
|
||||||
|
assertTrue("charging constraint", ws.constraints.requiresCharging())
|
||||||
|
assertEquals(TimeUnit.MINUTES.toMillis(30), ws.intervalDuration)
|
||||||
|
assertEquals(42L, ws.input.getLong(SyncWorker.KEY_PAIR_ID, -1))
|
||||||
|
assertTrue("sync_42" in req.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun periodic_anyNetwork_noCharging() {
|
||||||
|
val req = SyncWorker.buildPeriodicRequest(pairId = 7L, intervalMinutes = 60, wifiOnly = false, chargingOnly = false)
|
||||||
|
val c = req.workSpec.constraints
|
||||||
|
assertEquals(NetworkType.CONNECTED, c.requiredNetworkType)
|
||||||
|
assertFalse(c.requiresCharging())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun oneTime_constraintsDataAndTag() {
|
||||||
|
val req = SyncWorker.buildOneTimeRequest(pairId = 9L, wifiOnly = true, chargingOnly = false, silent = true)
|
||||||
|
val ws = req.workSpec
|
||||||
|
assertEquals(NetworkType.UNMETERED, ws.constraints.requiredNetworkType)
|
||||||
|
assertFalse(ws.constraints.requiresCharging())
|
||||||
|
assertEquals(9L, ws.input.getLong(SyncWorker.KEY_PAIR_ID, -1))
|
||||||
|
assertTrue(ws.input.getBoolean(SyncWorker.KEY_SILENT, false))
|
||||||
|
assertTrue("sync_9" in req.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.syncflow
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.syncflow.data.providers.sftp.SftpProvider
|
||||||
|
import com.syncflow.data.security.CredentialStore
|
||||||
|
import com.syncflow.domain.model.CloudAccount
|
||||||
|
import com.syncflow.domain.model.ProviderType
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live SFTP test (the other major provider code path: sshj). Runs against a throwaway SFTP
|
||||||
|
* server. Skips unless -e sftpHost/sftpPort/sftpUser/sftpPass are provided.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SftpProviderTest {
|
||||||
|
|
||||||
|
private val ctx = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
private val args = InstrumentationRegistry.getArguments()
|
||||||
|
|
||||||
|
private fun provider() = SftpProvider(
|
||||||
|
CloudAccount(
|
||||||
|
id = 1, displayName = "sftp", email = null, providerType = ProviderType.SFTP,
|
||||||
|
credentialJson = """{"username":"${args.getString("sftpUser")}","password":"${args.getString("sftpPass")}"}""",
|
||||||
|
serverUrl = args.getString("sftpHost"), port = args.getString("sftpPort")?.toInt(),
|
||||||
|
),
|
||||||
|
CredentialStore(ctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun sftpFullRoundTrip() = runBlocking {
|
||||||
|
assumeTrue("sftp* args required", args.getString("sftpHost") != null)
|
||||||
|
val p = provider()
|
||||||
|
val dir = "upload/it_${System.currentTimeMillis()}"
|
||||||
|
|
||||||
|
// Skip (don't fail) if the endpoint isn't reachable from the test runner's network —
|
||||||
|
// e.g. a phone on an isolated VLAN that only reaches services via the reverse proxy.
|
||||||
|
assumeTrue("SFTP endpoint not reachable from this device's network", p.testConnection().isSuccess)
|
||||||
|
assertTrue("mkdir", p.createDirectory(dir).isSuccess)
|
||||||
|
|
||||||
|
// upload (atomic temp + rename), list, download
|
||||||
|
val body = "sftp round-trip ✓".toByteArray()
|
||||||
|
assertTrue("upload", p.uploadFile(ByteArrayInputStream(body), "$dir/f.txt", body.size.toLong()).isSuccess)
|
||||||
|
assertTrue("f.txt" in p.listFiles(dir).getOrThrow().map { it.name })
|
||||||
|
val out = ByteArrayOutputStream(); p.downloadFile("$dir/f.txt", out).getOrThrow()
|
||||||
|
assertEquals("sftp round-trip ✓", out.toString("UTF-8"))
|
||||||
|
|
||||||
|
// atomic overwrite (temp + rename over existing)
|
||||||
|
val v2 = "updated-content".toByteArray()
|
||||||
|
assertTrue(p.uploadFile(ByteArrayInputStream(v2), "$dir/f.txt", v2.size.toLong()).isSuccess)
|
||||||
|
val out2 = ByteArrayOutputStream(); p.downloadFile("$dir/f.txt", out2).getOrThrow()
|
||||||
|
assertEquals("updated-content", out2.toString("UTF-8"))
|
||||||
|
|
||||||
|
// special / non-ASCII name (SFTP handles UTF-8 natively, no URL encoding)
|
||||||
|
val special = "café & rapport (1).txt"
|
||||||
|
assertTrue(p.uploadFile(ByteArrayInputStream("x".toByteArray()), "$dir/$special", 1).isSuccess)
|
||||||
|
assertTrue(special in p.listFiles(dir).getOrThrow().map { it.name })
|
||||||
|
|
||||||
|
// delete
|
||||||
|
assertTrue(p.deleteFile("$dir/f.txt").isSuccess)
|
||||||
|
assertTrue("f.txt" !in p.listFiles(dir).getOrThrow().map { it.name })
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user