From 10007eb4fb748efc1d615dc7460444b3836527d0 Mon Sep 17 00:00:00 2001 From: Friday Date: Fri, 5 Jun 2026 15:16:10 +0000 Subject: [PATCH] Add interruption/atomicity, SFTP, and scheduling tests - 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. --- .../kotlin/com/syncflow/FullSyncEngineTest.kt | 42 +++++++++++ .../kotlin/com/syncflow/SchedulingTest.kt | 47 +++++++++++++ .../kotlin/com/syncflow/SftpProviderTest.kt | 69 +++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/syncflow/SchedulingTest.kt create mode 100644 app/src/androidTest/kotlin/com/syncflow/SftpProviderTest.kt diff --git a/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt b/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt index e9c9761..96fb1e8 100644 --- a/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt +++ b/app/src/androidTest/kotlin/com/syncflow/FullSyncEngineTest.kt @@ -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 = + 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")) + } } diff --git a/app/src/androidTest/kotlin/com/syncflow/SchedulingTest.kt b/app/src/androidTest/kotlin/com/syncflow/SchedulingTest.kt new file mode 100644 index 0000000..b363f89 --- /dev/null +++ b/app/src/androidTest/kotlin/com/syncflow/SchedulingTest.kt @@ -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) + } +} diff --git a/app/src/androidTest/kotlin/com/syncflow/SftpProviderTest.kt b/app/src/androidTest/kotlin/com/syncflow/SftpProviderTest.kt new file mode 100644 index 0000000..59ede04 --- /dev/null +++ b/app/src/androidTest/kotlin/com/syncflow/SftpProviderTest.kt @@ -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 }) + } +}