From 1e5ae2c65fcc3f6ebd5109b7be4108b9cd5075a8 Mon Sep 17 00:00:00 2001 From: Friday Date: Fri, 5 Jun 2026 09:54:02 +0000 Subject: [PATCH] Add on-device Nextcloud integration test (real WebDAV round-trip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instrumented test driving the real NextcloudProvider over TLS: connect, create dir, atomic upload (temp+MOVE), list+size, download+content, then the backup guarantee — Upload-only + KEEP yields SKIP and the cloud copy is verified still present; MIRROR yields DELETE_REMOTE and the real delete is confirmed. Creds passed via instrumentation args (ncUrl/ncUser/ncPass), never committed. Verified passing on a Galaxy S23 (Android 16) against live Nextcloud. --- .../com/syncflow/NextcloudIntegrationTest.kt | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 app/src/androidTest/kotlin/com/syncflow/NextcloudIntegrationTest.kt diff --git a/app/src/androidTest/kotlin/com/syncflow/NextcloudIntegrationTest.kt b/app/src/androidTest/kotlin/com/syncflow/NextcloudIntegrationTest.kt new file mode 100644 index 0000000..7fa129c --- /dev/null +++ b/app/src/androidTest/kotlin/com/syncflow/NextcloudIntegrationTest.kt @@ -0,0 +1,117 @@ +package com.syncflow + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.syncflow.data.db.entities.SyncFileStateEntity +import com.syncflow.data.providers.nextcloud.NextcloudProvider +import com.syncflow.domain.model.CloudAccount +import com.syncflow.domain.model.ConflictStrategy +import com.syncflow.domain.model.DeleteBehavior +import com.syncflow.domain.model.ProviderType +import com.syncflow.domain.model.SyncDirection +import com.syncflow.domain.sync.LocalFileInfo +import com.syncflow.domain.sync.SyncDecision +import com.syncflow.domain.sync.syncDecide +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +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 +import java.time.Instant + +/** + * Real end-to-end test against a live Nextcloud, run ON the device. Exercises the actual + * NextcloudProvider (WebDAV over real TLS, including the atomic temp+MOVE upload) and proves + * the backup guarantee: with Upload-only + KEEP, "deleted on phone" leaves the cloud copy. + * + * Credentials are passed as instrumentation args (never committed): + * adb shell am instrument -w \ + * -e ncUrl https://nextcloud.khodak.me -e ncUser syncflow-test -e ncPass \ + * com.syncflow.test/androidx.test.runner.AndroidJUnitRunner + */ +@RunWith(AndroidJUnit4::class) +class NextcloudIntegrationTest { + + private val args = InstrumentationRegistry.getArguments() + private val url = args.getString("ncUrl") + private val user = args.getString("ncUser") + private val pass = args.getString("ncPass") + + private fun provider(): NextcloudProvider { + val account = CloudAccount( + id = 1L, + displayName = "IT", + email = user, // Nextcloud dav path uses this + providerType = ProviderType.NEXTCLOUD, + credentialJson = """{"username":"$user","password":"$pass"}""", + serverUrl = url, + port = null, + ) + return NextcloudProvider(account) + } + + @Test + fun fullBackupRoundTrip() = runBlocking { + assumeTrue("ncUrl/ncUser/ncPass instrumentation args required", url != null && user != null && pass != null) + val p = provider() + val dir = "SyncFlowITest_${System.currentTimeMillis()}" + val remoteFile = "$dir/hello.txt" + val content = "SyncFlow integration test — 0 to 100 — ${System.currentTimeMillis()}".toByteArray() + + try { + // 1. Connect + assertTrue("testConnection failed", p.testConnection().isSuccess) + + // 2. Create the backup folder + assertTrue("createDirectory failed", p.createDirectory(dir).isSuccess) + + // 3. Upload (exercises atomic temp-file + MOVE) + val uploaded = p.uploadFile(ByteArrayInputStream(content), remoteFile, content.size.toLong()) + assertTrue("upload failed: ${uploaded.exceptionOrNull()}", uploaded.isSuccess) + + // 4. List — the file is on the cloud with the right size + val listed = p.listFiles(dir).getOrThrow() + val entry = listed.firstOrNull { it.name == "hello.txt" } + assertNotNull("uploaded file not found in listing", entry) + assertEquals("remote size mismatch", content.size.toLong(), entry!!.sizeBytes) + + // 5. Download — bytes round-trip intact + val out = ByteArrayOutputStream() + assertTrue("download failed", p.downloadFile(remoteFile, out).isSuccess) + assertEquals("downloaded content mismatch", String(content), out.toString("UTF-8")) + + // 6. THE backup guarantee. Phone copy deleted, state record exists, Upload-only + KEEP. + val known = SyncFileStateEntity( + syncPairId = 1L, relativePath = "hello.txt", + localModifiedAt = Instant.now(), localSizeBytes = content.size.toLong(), localHash = null, + remoteModifiedAt = entry.modifiedAt, remoteSizeBytes = entry.sizeBytes, remoteEtag = entry.etag, + lastSyncedAt = Instant.now(), syncedHash = null, + ) + val keepDecision = syncDecide( + SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, DeleteBehavior.KEEP, + local = null, remote = entry, known = known, hasPriorSyncState = true, + ) + assertEquals("KEEP must not delete the cloud copy", SyncDecision.SKIP, keepDecision) + + // ...and the engine would do nothing, so the file is verifiably STILL on the cloud: + val stillThere = p.listFiles(dir).getOrThrow().any { it.name == "hello.txt" } + assertTrue("cloud copy must survive a local delete under KEEP", stillThere) + + // 7. Contrast: MIRROR would delete it — prove the real DELETE works (also cleanup). + val mirrorDecision = syncDecide( + SyncDirection.UPLOAD_ONLY, ConflictStrategy.KEEP_NEWEST, DeleteBehavior.MIRROR, + local = null, remote = entry, known = known, hasPriorSyncState = true, + ) + assertEquals(SyncDecision.DELETE_REMOTE, mirrorDecision) + assertTrue("deleteFile failed", p.deleteFile(remoteFile).isSuccess) + val goneAfterDelete = p.listFiles(dir).getOrThrow().none { it.name == "hello.txt" } + assertTrue("file should be gone after explicit remote delete", goneAfterDelete) + } finally { + runCatching { p.deleteFile(dir) } // best-effort cleanup of the test folder + } + } +}