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