15b94a0407
Build & Release APK / build (push) Successful in 12m58s
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.
186 lines
9.2 KiB
Kotlin
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) }
|
|
}
|
|
}
|
|
}
|