Files
SyncFlow/app/src/androidTest/kotlin/com/syncflow/NextcloudIntegrationTest.kt
T
amir 15b94a0407
Build & Release APK / build (push) Successful in 12m58s
Add real-world large-file test (multi-GB from phone via external URL, chunked)
Opt-in (-e bigFileMB=<size>): streams a multi-GB file from the device through
the app's chunked-upload path to the external nextcloud.khodak.me and verifies
the full size lands. Verified live: 1.5 GB and 5 GB both succeed end-to-end.
2026-06-05 16:14:13 +00:00

186 lines
9.2 KiB
Kotlin

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.assertArrayEquals
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.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
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 ctx = InstrumentationRegistry.getInstrumentation().targetContext
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
}
}
@Test
fun chunkedUpload_assemblesLargeFileByteExact() = runBlocking {
assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
// Tiny chunk size exercises multi-chunk assembly without needing a multi-GB file.
val account = CloudAccount(
id = 1, displayName = "IT", email = user, providerType = ProviderType.NEXTCLOUD,
credentialJson = """{"username":"$user","password":"$pass"}""", serverUrl = url, port = null,
)
val p = NextcloudProvider(account, chunkSize = 1L * 1024 * 1024) // 1 MB chunks
val dir = "SyncFlowChunk_${System.currentTimeMillis()}"
try {
p.createDirectory(dir).getOrThrow()
val payload = ByteArray(5 * 1024 * 1024 + 7).also { java.util.Random(7).nextBytes(it) } // ~5 MB -> 6 chunks
val up = p.uploadFile(ByteArrayInputStream(payload), "$dir/big.bin", payload.size.toLong())
assertTrue("chunked upload failed: ${up.exceptionOrNull()}", up.isSuccess)
assertEquals(payload.size.toLong(), p.listFiles(dir).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
val out = ByteArrayOutputStream(); p.downloadFile("$dir/big.bin", out).getOrThrow()
assertArrayEquals("chunk-assembled content must equal the original bytes", payload, out.toByteArray())
} finally {
runCatching { p.deleteFile(dir) }
}
}
/**
* Real-world large-file test: streams a multi-GB file FROM THE PHONE through the app's
* chunked-upload path to the external URL, verifies the full size landed, then cleans up.
* Opt-in (slow): pass -e bigFileMB=<size>, e.g. 1536 for 1.5 GB.
*/
@Test
fun realWorld_largeFileChunkedUpload() = runBlocking {
assumeTrue("ncUrl/ncUser/ncPass required", url != null && user != null && pass != null)
val mb = args.getString("bigFileMB")?.toIntOrNull() ?: 0
assumeTrue("pass -e bigFileMB=<size> to run the big-file test", mb > 0)
val account = CloudAccount(
id = 1, displayName = "IT", email = user, providerType = ProviderType.NEXTCLOUD,
credentialJson = """{"username":"$user","password":"$pass"}""", serverUrl = url, port = null,
)
val p = NextcloudProvider(account) // default 100 MB chunks -> chunked path for >100 MB
val dir = "SyncFlowBig_${System.currentTimeMillis()}"
val tmp = File(ctx.cacheDir, "bigfile_${System.currentTimeMillis()}.bin")
try {
val total = mb.toLong() * 1024 * 1024
FileOutputStream(tmp).use { os ->
val buf = ByteArray(8 * 1024 * 1024)
var written = 0L
while (written < total) {
val n = minOf(buf.size.toLong(), total - written).toInt()
os.write(buf, 0, n); written += n
}
}
assertEquals(total, tmp.length())
p.createDirectory(dir).getOrThrow()
val up = p.uploadFile(FileInputStream(tmp), "$dir/big.bin", tmp.length())
assertTrue("large chunked upload failed: ${up.exceptionOrNull()}", up.isSuccess)
assertEquals("full file size must land on the server", total,
p.listFiles(dir).getOrThrow().first { it.name == "big.bin" }.sizeBytes)
} finally {
tmp.delete()
runCatching { p.deleteFile(dir) }
}
}
}