Add on-device Nextcloud integration test (real WebDAV round-trip)
Build & Release APK / build (push) Successful in 12m47s
Build & Release APK / build (push) Successful in 12m47s
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.
This commit is contained in:
@@ -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 <pw> \
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user