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 \ * 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 } } }