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 \ * 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=, 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= 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) } } } }