diff --git a/.github/workflows/android-unit-tests.yml b/.github/workflows/android-unit-tests.yml index 3a7a9ddcca..7f52af6eeb 100644 --- a/.github/workflows/android-unit-tests.yml +++ b/.github/workflows/android-unit-tests.yml @@ -26,4 +26,16 @@ jobs: run: ./gradlew assembleDebug - name: Run unit tests - run: ./gradlew testDebugUnitTest --continue + run: ./gradlew testDebugUnitTest :opencloudApp:testOriginalDebugUnitTest --continue + + - name: Generate coverage report + run: ./gradlew jacocoAggregatedCoverageVerification + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: jacoco-coverage-report + path: | + build/reports/jacoco/jacocoAggregatedReport + build/reports/jacoco/jacocoAggregatedCoverageVerification diff --git a/build.gradle b/build.gradle index 97a4c9a717..9a95bc3ce4 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,7 @@ plugins { alias libs.plugins.sonarqube alias libs.plugins.ksp apply false alias libs.plugins.detekt + id 'jacoco' } allprojects { @@ -39,6 +40,118 @@ subprojects { //apply plugin: "org.jlleitschuh.gradle.ktlint" //apply plugin: "org.sonarqube" apply plugin: "io.gitlab.arturbosch.detekt" + apply plugin: "jacoco" + + jacoco { + toolVersion = "0.8.12" + } + + tasks.withType(Test).configureEach { + jacoco { + includeNoLocationClasses = true + excludes = ["jdk.internal.*"] + } + } + + plugins.withId("com.android.application") { + android { + buildTypes { + debug { + enableUnitTestCoverage true + } + } + } + } + + plugins.withId("com.android.library") { + android { + buildTypes { + debug { + enableUnitTestCoverage true + } + } + } + } +} + +def coverageProjects = subprojects.findAll { + ["opencloudApp", "opencloudComLibrary", "opencloudData", "opencloudDomain"].contains(it.name) +} + +def coverageExclusions = [ + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + '**/*JsonAdapter.*', + '**/*_Impl*.*', + '**/*Binding.*', + '**/databinding/**', +] + +def coverageClassDirectories = files(coverageProjects.collect { project -> + [ + fileTree(dir: "${project.buildDir}/tmp/kotlin-classes/debug", excludes: coverageExclusions), + fileTree(dir: "${project.buildDir}/tmp/kotlin-classes/originalDebug", excludes: coverageExclusions), + fileTree(dir: "${project.buildDir}/intermediates/javac/debug/classes", excludes: coverageExclusions), + fileTree(dir: "${project.buildDir}/intermediates/javac/originalDebug/classes", excludes: coverageExclusions), + ] +}) + +def coverageSourceDirectories = files(coverageProjects.collect { project -> + [ + "${project.projectDir}/src/main/java", + "${project.projectDir}/src/main/kotlin", + ] +}) + +def coverageExecutionData = fileTree(rootDir) { + include "**/build/outputs/unit_test_code_coverage/**/*.exec" + include "**/build/jacoco/*.exec" +} + +tasks.register("jacocoAggregatedReport", JacocoReport) { + group = "verification" + description = "Generates an aggregated JaCoCo report for JVM unit tests." + + dependsOn( + ":opencloudApp:testOriginalDebugUnitTest", + ":opencloudComLibrary:testDebugUnitTest", + ":opencloudData:testDebugUnitTest", + ":opencloudDomain:testDebugUnitTest" + ) + + reports { + xml.required = true + html.required = true + csv.required = false + } + + sourceDirectories.from(coverageSourceDirectories) + classDirectories.from(coverageClassDirectories) + executionData.from(coverageExecutionData) +} + +tasks.register("jacocoAggregatedCoverageVerification", JacocoCoverageVerification) { + group = "verification" + description = "Verifies aggregated unit-test line coverage." + + dependsOn("jacocoAggregatedReport") + + sourceDirectories.from(coverageSourceDirectories) + classDirectories.from(coverageClassDirectories) + executionData.from(coverageExecutionData) + + violationRules { + rule { + limit { + counter = "LINE" + value = "COVEREDRATIO" + minimum = 0.20 + } + } + } } //sonarqube { diff --git a/opencloudApp/build.gradle b/opencloudApp/build.gradle index 230d3b2f76..41fa9a9cf2 100644 --- a/opencloudApp/build.gradle +++ b/opencloudApp/build.gradle @@ -68,6 +68,9 @@ dependencies { testImplementation libs.junit4 testImplementation libs.kotlinx.coroutines.test testImplementation libs.mockk + testImplementation libs.androidx.test.core + testImplementation 'org.robolectric:robolectric:4.15.1' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.2' // Instrumented tests androidTestImplementation project(":opencloudTestUtil") diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt index c5357260cd..79a0327f1e 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/TusUploadHelper.kt @@ -424,5 +424,12 @@ class TusUploadHelper( private const val MAX_RETRIES = 5 private const val BASE_RETRY_DELAY_MS = 250L private const val MAX_RETRY_DELAY_MS = 2_000L + + fun shouldAttemptTusUpload( + fileSize: Long, + tusSupport: OCCapability.TusSupport?, + tusUploadUrl: String?, + ): Boolean = + !tusUploadUrl.isNullOrBlank() || (tusSupport != null && fileSize >= DEFAULT_CHUNK_SIZE) } } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt index 42f7eadee9..08eedd26e2 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromContentUriWorker.kt @@ -307,14 +307,14 @@ class UploadFileFromContentUriWorker( ) ) val tusSupport = capabilitiesForAccount?.filesTusSupport - val supportsTus = tusSupport != null - val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank() - val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE) + val shouldTryTus = TusUploadHelper.shouldAttemptTusUpload( + fileSize = fileSize, + tusSupport = tusSupport, + tusUploadUrl = ocTransfer.tusUploadUrl, + ) - var attemptedTus = false if (shouldTryTus) { - attemptedTus = true Timber.d( "Attempting TUS upload (size=%d, threshold=%d, resume=%s)", fileSize, @@ -355,7 +355,7 @@ class UploadFileFromContentUriWorker( "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", fileSize, TusUploadHelper.DEFAULT_CHUNK_SIZE, - supportsTus + tusSupport != null ) } diff --git a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt index 91296f8427..7c32bccef1 100644 --- a/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt +++ b/opencloudApp/src/main/java/eu/opencloud/android/workers/UploadFileFromFileSystemWorker.kt @@ -259,10 +259,12 @@ class UploadFileFromFileSystemWorker( ) ) val tusSupport = capabilitiesForAccount?.filesTusSupport - val supportsTus = tusSupport != null - val hasPendingTusSession = !ocTransfer.tusUploadUrl.isNullOrBlank() - val shouldTryTus = hasPendingTusSession || (supportsTus && fileSize >= TusUploadHelper.DEFAULT_CHUNK_SIZE) + val shouldTryTus = TusUploadHelper.shouldAttemptTusUpload( + fileSize = fileSize, + tusSupport = tusSupport, + tusUploadUrl = ocTransfer.tusUploadUrl, + ) if (shouldTryTus) { Timber.d( @@ -309,7 +311,7 @@ class UploadFileFromFileSystemWorker( "Skipping TUS: file too small or unsupported (size=%d, threshold=%d, supportsTus=%s)", fileSize, TusUploadHelper.DEFAULT_CHUNK_SIZE, - supportsTus + tusSupport != null ) } diff --git a/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt new file mode 100644 index 0000000000..a9dbea8d19 --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/presentation/viewmodels/KeyAppViewModelsTest.kt @@ -0,0 +1,333 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.presentation.viewmodels + +import androidx.lifecycle.MutableLiveData +import androidx.work.WorkInfo +import eu.opencloud.android.data.providers.SharedPreferencesProvider +import eu.opencloud.android.domain.UseCaseResult +import eu.opencloud.android.domain.appregistry.usecases.GetAppRegistryWhichAllowCreationAsStreamUseCase +import eu.opencloud.android.domain.automaticuploads.model.FolderBackUpConfiguration +import eu.opencloud.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase +import eu.opencloud.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase +import eu.opencloud.android.domain.automaticuploads.usecases.SavePictureUploadsConfigurationUseCase +import eu.opencloud.android.domain.automaticuploads.usecases.SaveVideoUploadsConfigurationUseCase +import eu.opencloud.android.domain.files.model.FileListOption +import eu.opencloud.android.domain.files.usecases.CreateFolderAsyncUseCase +import eu.opencloud.android.domain.files.usecases.GetFileByIdUseCase +import eu.opencloud.android.domain.files.usecases.GetFolderContentAsStreamUseCase +import eu.opencloud.android.domain.files.usecases.SortFilesWithSyncInfoUseCase +import eu.opencloud.android.domain.spaces.usecases.GetPersonalSpaceForAccountUseCase +import eu.opencloud.android.domain.spaces.usecases.GetSpaceByIdForAccountUseCase +import eu.opencloud.android.domain.spaces.usecases.GetSpaceWithSpecialsByIdForAccountUseCase +import eu.opencloud.android.domain.spaces.usecases.GetSpacesFromEveryAccountUseCaseAsStream +import eu.opencloud.android.domain.transfers.usecases.GetAllTransfersAsStreamUseCase +import eu.opencloud.android.presentation.common.UIResult +import eu.opencloud.android.presentation.files.SortOrder +import eu.opencloud.android.presentation.files.SortOrder.Companion.PREF_FILE_LIST_SORT_ORDER +import eu.opencloud.android.presentation.files.SortType +import eu.opencloud.android.presentation.files.SortType.Companion.PREF_FILE_LIST_SORT_TYPE +import eu.opencloud.android.presentation.files.filelist.MainFileListViewModel +import eu.opencloud.android.presentation.files.operations.FileOperation +import eu.opencloud.android.presentation.files.operations.FileOperationsViewModel +import eu.opencloud.android.presentation.settings.automaticuploads.SettingsPictureUploadsViewModel +import eu.opencloud.android.presentation.settings.automaticuploads.SettingsVideoUploadsViewModel +import eu.opencloud.android.presentation.transfers.TransfersViewModel +import eu.opencloud.android.providers.ContextProvider +import eu.opencloud.android.providers.WorkManagerProvider +import eu.opencloud.android.testutil.OC_ACCOUNT_NAME +import eu.opencloud.android.testutil.OC_BACKUP +import eu.opencloud.android.testutil.OC_FOLDER +import eu.opencloud.android.testutil.OC_FOLDER_WITH_SPACE_ID +import eu.opencloud.android.testutil.OC_ROOT_FOLDER +import eu.opencloud.android.testutil.OC_SPACE_PERSONAL +import eu.opencloud.android.testutil.OC_TRANSFER +import eu.opencloud.android.testutil.livedata.getEmittedValues +import eu.opencloud.android.usecases.synchronization.SynchronizeFolderUseCase +import eu.opencloud.android.usecases.transfers.uploads.UploadFilesFromSystemUseCase +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module + +@ExperimentalCoroutinesApi +class KeyAppViewModelsTest : ViewModelTest() { + + private lateinit var contextProvider: ContextProvider + + @Before + fun setUp() { + contextProvider = mockk(relaxed = true) + every { contextProvider.isConnected() } returns true + kotlinx.coroutines.Dispatchers.setMain(testCoroutineDispatcher) + startKoin { + allowOverride(override = true) + modules( + module { + factory { contextProvider } + } + ) + } + } + + @After + override fun tearDown() { + stopKoin() + super.tearDown() + } + + @Test + fun transfers_combinesTransfersWithSpacesAndDelegatesUploads() = runTest(testCoroutineDispatcher) { + val uploadFilesFromSystemUseCase = mockk() + every { uploadFilesFromSystemUseCase(any()) } returns Unit + val getAllTransfersAsStreamUseCase = mockk() + val getSpacesFromEveryAccountUseCaseAsStream = mockk() + val transfer = OC_TRANSFER.copy(id = 7, spaceId = OC_SPACE_PERSONAL.id) + every { getAllTransfersAsStreamUseCase(Unit) } returns flowOf(listOf(transfer)) + every { getSpacesFromEveryAccountUseCaseAsStream(Unit) } returns flowOf(listOf(OC_SPACE_PERSONAL)) + val workInfosLiveData = MutableLiveData>(emptyList()) + val workManagerProvider = mockk() + every { workManagerProvider.getRunningUploadsWorkInfosLiveData() } returns workInfosLiveData + + val viewModel = TransfersViewModel( + uploadFilesFromContentUriUseCase = mockk(relaxed = true), + uploadFilesFromSystemUseCase = uploadFilesFromSystemUseCase, + cancelUploadUseCase = mockk(relaxed = true), + retryUploadFromSystemUseCase = mockk(relaxed = true), + retryUploadFromContentUriUseCase = mockk(relaxed = true), + retryFailedUploadsForAccountUseCase = mockk(relaxed = true), + clearFailedTransfersUseCase = mockk(relaxed = true), + retryFailedUploadsUseCase = mockk(relaxed = true), + clearSuccessfulTransfersUseCase = mockk(relaxed = true), + getAllTransfersAsStreamUseCase = getAllTransfersAsStreamUseCase, + cancelDownloadForFileUseCase = mockk(relaxed = true), + cancelUploadForFileUseCase = mockk(relaxed = true), + cancelUploadsRecursivelyUseCase = mockk(relaxed = true), + cancelDownloadsRecursivelyUseCase = mockk(relaxed = true), + getSpacesFromEveryAccountUseCaseAsStream = getSpacesFromEveryAccountUseCaseAsStream, + coroutinesDispatcherProvider = coroutineDispatcherProvider, + workManagerProvider = workManagerProvider, + ) + + val transferWithSpace = viewModel.transfersWithSpaceStateFlow.first { it.isNotEmpty() }.single() + assertEquals(transfer, transferWithSpace.first) + assertEquals(OC_SPACE_PERSONAL, transferWithSpace.second) + + viewModel.uploadFilesFromSystem( + accountName = OC_ACCOUNT_NAME, + listOfLocalPaths = listOf("/tmp/image.jpg"), + uploadFolderPath = "/Photos/", + spaceId = OC_SPACE_PERSONAL.id, + ) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + verify { + uploadFilesFromSystemUseCase( + UploadFilesFromSystemUseCase.Params( + accountName = OC_ACCOUNT_NAME, + listOfLocalPaths = listOf("/tmp/image.jpg"), + uploadFolderPath = "/Photos/", + spaceId = OC_SPACE_PERSONAL.id, + ) + ) + } + } + + @Test + fun fileOperations_createFolderEmitsLoadingAndSuccess() { + val createFolderAsyncUseCase = mockk() + every { createFolderAsyncUseCase(any()) } returns UseCaseResult.Success(Unit) + val viewModel = FileOperationsViewModel( + createFolderAsyncUseCase = createFolderAsyncUseCase, + copyFileUseCase = mockk(relaxed = true), + moveFileUseCase = mockk(relaxed = true), + removeFileUseCase = mockk(relaxed = true), + renameFileUseCase = mockk(relaxed = true), + synchronizeFileUseCase = mockk(relaxed = true), + synchronizeFolderUseCase = mockk(relaxed = true), + createFileWithAppProviderUseCase = mockk(relaxed = true), + setFilesAsAvailableOfflineUseCase = mockk(relaxed = true), + unsetFilesAsAvailableOfflineUseCase = mockk(relaxed = true), + manageDeepLinkUseCase = mockk(relaxed = true), + setLastUsageFileUseCase = mockk(relaxed = true), + isAnyFileAvailableLocallyAndNotAvailableOfflineUseCase = mockk(relaxed = true), + contextProvider = contextProvider, + coroutinesDispatcherProvider = coroutineDispatcherProvider, + ) + + val emittedValues = viewModel.createFolder.getEmittedValues(2) { + viewModel.performOperation(FileOperation.CreateFolder("Reports", OC_FOLDER)) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + } + + assertTrue(emittedValues[0]!!.peekContent() is UIResult.Loading) + assertTrue(emittedValues[1]!!.peekContent() is UIResult.Success) + verify { + createFolderAsyncUseCase(CreateFolderAsyncUseCase.Params("Reports", OC_FOLDER)) + } + } + + @Test + fun pictureUploads_updatesWifiOnlyAndSchedulesWorker() { + val savePictureUploadsConfigurationUseCase = mockk() + val saveParams = slot() + every { savePictureUploadsConfigurationUseCase(capture(saveParams)) } returns UseCaseResult.Success(Unit) + val getPictureUploadsConfigurationStreamUseCase = mockk() + every { getPictureUploadsConfigurationStreamUseCase(Unit) } returns MutableStateFlow( + OC_BACKUP.copy( + accountName = OC_ACCOUNT_NAME, + name = FolderBackUpConfiguration.pictureUploadsName, + wifiOnly = true, + chargingOnly = true, + ) + ) + val workManagerProvider = mockk(relaxed = true) + val getSpaceByIdForAccountUseCase = mockk() + every { getSpaceByIdForAccountUseCase(any()) } returns OC_SPACE_PERSONAL + + val viewModel = SettingsPictureUploadsViewModel( + accountProvider = mockk(relaxed = true), + savePictureUploadsConfigurationUseCase = savePictureUploadsConfigurationUseCase, + getPictureUploadsConfigurationStreamUseCase = getPictureUploadsConfigurationStreamUseCase, + resetPictureUploadsUseCase = mockk(relaxed = true), + getPersonalSpaceForAccountUseCase = mockk(relaxed = true), + getSpaceByIdForAccountUseCase = getSpaceByIdForAccountUseCase, + workManagerProvider = workManagerProvider, + coroutinesDispatcherProvider = coroutineDispatcherProvider, + contextProvider = contextProvider, + ) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + viewModel.useWifiOnly(false) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + viewModel.schedulePictureUploads() + + val savedConfiguration = saveParams.captured.pictureUploadsConfiguration + assertFalse(savedConfiguration.wifiOnly) + assertTrue(savedConfiguration.chargingOnly) + assertEquals(FolderBackUpConfiguration.pictureUploadsName, savedConfiguration.name) + verify { workManagerProvider.enqueueAutomaticUploadsWorker() } + } + + @Test + fun videoUploads_enablesSelectedAccountInPersonalSpace() { + val saveVideoUploadsConfigurationUseCase = mockk() + val saveParams = slot() + every { saveVideoUploadsConfigurationUseCase(capture(saveParams)) } returns UseCaseResult.Success(Unit) + val getVideoUploadsConfigurationStreamUseCase = mockk() + every { getVideoUploadsConfigurationStreamUseCase(Unit) } returns flowOf(null) + val getPersonalSpaceForAccountUseCase = mockk() + every { getPersonalSpaceForAccountUseCase(any()) } returns OC_SPACE_PERSONAL + + val viewModel = SettingsVideoUploadsViewModel( + accountProvider = mockk(relaxed = true), + saveVideoUploadsConfigurationUseCase = saveVideoUploadsConfigurationUseCase, + getVideoUploadsConfigurationStreamUseCase = getVideoUploadsConfigurationStreamUseCase, + resetVideoUploadsUseCase = mockk(relaxed = true), + getPersonalSpaceForAccountUseCase = getPersonalSpaceForAccountUseCase, + getSpaceByIdForAccountUseCase = mockk(relaxed = true), + workManagerProvider = mockk(relaxed = true), + coroutinesDispatcherProvider = coroutineDispatcherProvider, + contextProvider = contextProvider, + ) + + viewModel.enableVideoUploads(OC_ACCOUNT_NAME) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + val savedConfiguration = saveParams.captured.videoUploadsConfiguration + assertEquals(OC_ACCOUNT_NAME, savedConfiguration.accountName) + assertEquals(FolderBackUpConfiguration.videoUploadsName, savedConfiguration.name) + assertEquals(OC_SPACE_PERSONAL.id, savedConfiguration.spaceId) + } + + @Test + fun mainFileList_persistsLayoutSortAndNavigatesById() { + val sharedPreferencesProvider = mockk(relaxed = true) + every { sharedPreferencesProvider.getBoolean(any(), any()) } returns false + every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_TYPE, any()) } returns SortType.SORT_TYPE_BY_NAME.ordinal + every { sharedPreferencesProvider.getInt(PREF_FILE_LIST_SORT_ORDER, any()) } returns SortOrder.SORT_ORDER_ASCENDING.ordinal + val getFileByIdUseCase = mockk() + every { getFileByIdUseCase(GetFileByIdUseCase.Params(OC_FOLDER_WITH_SPACE_ID.id!!)) } returns + UseCaseResult.Success(OC_FOLDER_WITH_SPACE_ID) + val synchronizeFolderUseCase = mockk() + every { synchronizeFolderUseCase(any()) } returns UseCaseResult.Success(Unit) + val getAppRegistryWhichAllowCreationAsStreamUseCase = mockk() + every { getAppRegistryWhichAllowCreationAsStreamUseCase(any()) } returns flowOf(emptyList()) + val getFolderContentAsStreamUseCase = mockk() + every { getFolderContentAsStreamUseCase(any()) } returns flowOf(emptyList()) + val getSpaceWithSpecialsByIdForAccountUseCase = mockk() + every { getSpaceWithSpecialsByIdForAccountUseCase(any()) } returns OC_SPACE_PERSONAL + + val viewModel = MainFileListViewModel( + getFolderContentAsStreamUseCase = getFolderContentAsStreamUseCase, + getSharedByLinkForAccountAsStreamUseCase = mockk(relaxed = true), + getFilesAvailableOfflineFromAccountAsStreamUseCase = mockk(relaxed = true), + getFileByIdUseCase = getFileByIdUseCase, + getFileByRemotePathUseCase = mockk(relaxed = true), + getSpaceWithSpecialsByIdForAccountUseCase = getSpaceWithSpecialsByIdForAccountUseCase, + sortFilesWithSyncInfoUseCase = SortFilesWithSyncInfoUseCase(), + synchronizeFolderUseCase = synchronizeFolderUseCase, + getAppRegistryWhichAllowCreationAsStreamUseCase = getAppRegistryWhichAllowCreationAsStreamUseCase, + getAppRegistryForMimeTypeAsStreamUseCase = mockk(relaxed = true), + getUrlToOpenInWebUseCase = mockk(relaxed = true), + filterFileMenuOptionsUseCase = mockk(relaxed = true), + contextProvider = contextProvider, + coroutinesDispatcherProvider = coroutineDispatcherProvider, + sharedPreferencesProvider = sharedPreferencesProvider, + initialFolderToDisplay = OC_ROOT_FOLDER, + fileListOptionParam = FileListOption.ALL_FILES, + ) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + viewModel.setGridModeAsPreferred() + viewModel.updateSortTypeAndOrder(SortType.SORT_TYPE_BY_SIZE, SortOrder.SORT_ORDER_DESCENDING) + viewModel.navigateToFolderId(OC_FOLDER_WITH_SPACE_ID.id!!) + testCoroutineDispatcher.scheduler.advanceUntilIdle() + + assertEquals(OC_FOLDER_WITH_SPACE_ID, viewModel.getFile()) + verify { sharedPreferencesProvider.putBoolean("RECYCLER_VIEW_PREFERRED", true) } + verify { sharedPreferencesProvider.putInt(PREF_FILE_LIST_SORT_TYPE, SortType.SORT_TYPE_BY_SIZE.ordinal) } + verify { sharedPreferencesProvider.putInt(PREF_FILE_LIST_SORT_ORDER, SortOrder.SORT_ORDER_DESCENDING.ordinal) } + verify { + synchronizeFolderUseCase( + SynchronizeFolderUseCase.Params( + remotePath = OC_ROOT_FOLDER.remotePath, + accountName = OC_ROOT_FOLDER.owner, + spaceId = OC_ROOT_FOLDER.spaceId, + syncMode = SynchronizeFolderUseCase.SyncFolderMode.SYNC_CONTENTS, + ) + ) + } + } +} diff --git a/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt b/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt new file mode 100644 index 0000000000..4c9693c6aa --- /dev/null +++ b/opencloudApp/src/test/java/eu/opencloud/android/workers/TusUploadHelperTest.kt @@ -0,0 +1,238 @@ +/** + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package eu.opencloud.android.workers + +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import eu.opencloud.android.domain.capabilities.model.OCCapability +import eu.opencloud.android.domain.transfers.TransferRepository +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.testutil.OC_TRANSFER +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class TusUploadHelperTest { + + private lateinit var server: MockWebServer + private lateinit var transferRepository: TransferRepository + + @Before + fun setUp() { + server = MockWebServer() + server.start() + transferRepository = mockk(relaxed = true) + every { transferRepository.updateTusState(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun upload_createsSessionWithFirstChunkAndClearsTusState() { + val localFile = tempFileWithBytes(byteArrayOf(1, 2, 3, 4, 5)) + val uploadUrl = "/uploads/new-session" + server.enqueue( + MockResponse() + .setResponseCode(201) + .addHeader("Location", uploadUrl) + .addHeader("Upload-Offset", "5") + ) + server.enqueue(MockResponse().setResponseCode(404)) + val progress = mutableListOf() + + val resultEtag = TusUploadHelper(transferRepository).upload( + client = newClient(), + transfer = OC_TRANSFER.copy(tusUploadUrl = null, tusUploadChecksum = "sha256:abc"), + uploadId = UPLOAD_ID, + localPath = localFile.absolutePath, + remotePath = "/Photos/image.jpg", + fileSize = localFile.length(), + mimeType = "image/jpeg", + lastModified = "1700000000", + tusSupport = tusSupport(), + progressListener = null, + progressCallback = { offset, _ -> progress += offset }, + spaceWebDavUrl = server.url("/dav/spaces/personal").toString(), + ) + + assertNull(resultEtag) + assertEquals(listOf(5L), progress) + + val createRequest = server.takeRequest() + assertEquals("POST", createRequest.method) + assertEquals("/dav/spaces/personal/Photos", createRequest.path) + assertEquals("0", createRequest.getHeader("Upload-Offset")) + assertEquals("5", createRequest.getHeader("Upload-Length")) + assertTrue(createRequest.getHeader("Upload-Metadata")!!.contains("checksum")) + + verify { + transferRepository.updateTusState( + id = UPLOAD_ID, + tusUploadUrl = server.url(uploadUrl).toString(), + tusUploadLength = 5, + tusUploadMetadata = "filename=image.jpg;mimetype=image/jpeg;mtime=1700000000;checksum=sha256 abc", + tusUploadChecksum = "sha256:abc", + tusResumableVersion = "1.0.0", + tusUploadExpires = null, + tusUploadConcat = null, + ) + transferRepository.updateTusState( + id = UPLOAD_ID, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + } + + @Test + fun upload_resumesExistingSessionFromServerOffset() { + val localFile = tempFileWithBytes(byteArrayOf(1, 2, 3, 4, 5)) + val existingTusUrl = server.url("/uploads/existing-session").toString() + server.enqueue(MockResponse().setResponseCode(204).addHeader("Upload-Offset", "2")) + server.enqueue( + MockResponse() + .setResponseCode(204) + .addHeader("Upload-Offset", "5") + .addHeader("ETag", "\"remote-etag\"") + ) + val progress = mutableListOf() + + val resultEtag = TusUploadHelper(transferRepository).upload( + client = newClient(), + transfer = OC_TRANSFER.copy(tusUploadUrl = existingTusUrl), + uploadId = UPLOAD_ID, + localPath = localFile.absolutePath, + remotePath = "/Photos/image.jpg", + fileSize = localFile.length(), + mimeType = "image/jpeg", + lastModified = null, + tusSupport = tusSupport(maxChunkSize = 3), + progressListener = null, + progressCallback = { offset, _ -> progress += offset }, + ) + + assertEquals("remote-etag", resultEtag) + assertEquals(listOf(2L, 5L), progress) + + val headRequest = server.takeRequest() + assertEquals("HEAD", headRequest.method) + assertEquals("/uploads/existing-session", headRequest.path) + + val patchRequest = server.takeRequest() + assertEquals("PATCH", patchRequest.method) + assertEquals("/uploads/existing-session", patchRequest.path) + assertEquals("2", patchRequest.getHeader("Upload-Offset")) + + verify(exactly = 1) { + transferRepository.updateTusState( + id = UPLOAD_ID, + tusUploadUrl = null, + tusUploadLength = null, + tusUploadMetadata = null, + tusUploadChecksum = null, + tusResumableVersion = null, + tusUploadExpires = null, + tusUploadConcat = null, + ) + } + } + + @Test + fun shouldAttemptTusUpload_usesFallbackForSmallFilesWithoutPendingSession() { + val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload( + fileSize = TusUploadHelper.DEFAULT_CHUNK_SIZE - 1, + tusSupport = tusSupport(), + tusUploadUrl = null, + ) + + assertFalse(shouldAttemptTusUpload) + } + + @Test + fun shouldAttemptTusUpload_usesTusForLargeFilesWithServerSupport() { + val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload( + fileSize = TusUploadHelper.DEFAULT_CHUNK_SIZE, + tusSupport = tusSupport(), + tusUploadUrl = null, + ) + + assertTrue(shouldAttemptTusUpload) + } + + @Test + fun shouldAttemptTusUpload_resumesPendingSessionsWithoutServerSupport() { + val shouldAttemptTusUpload = TusUploadHelper.shouldAttemptTusUpload( + fileSize = 1, + tusSupport = null, + tusUploadUrl = "https://server.test/uploads/session", + ) + + assertTrue(shouldAttemptTusUpload) + } + + private fun newClient(): OpenCloudClient = + OpenCloudClient( + Uri.parse(server.url("/").toString().removeSuffix("/")), + null, + true, + null, + ApplicationProvider.getApplicationContext() + ) + + private fun tempFileWithBytes(bytes: ByteArray): File = + File.createTempFile("tus-helper", ".bin").apply { + writeBytes(bytes) + } + + private fun tusSupport(maxChunkSize: Int = 10): OCCapability.TusSupport = + OCCapability.TusSupport( + version = "1.0.0", + resumable = "1.0.0", + extension = "creation,creation-with-upload", + maxChunkSize = maxChunkSize, + httpMethodOverride = null + ) + + companion object { + private const val UPLOAD_ID = 42L + } +} diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt new file mode 100644 index 0000000000..f007bfe464 --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/oauth/OAuthRemoteOperationTest.kt @@ -0,0 +1,236 @@ +/* openCloud Android Library is available under MIT license + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package eu.opencloud.android.lib.resources.oauth + +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.http.HttpConstants.AUTHORIZATION_HEADER +import eu.opencloud.android.lib.resources.oauth.params.ClientRegistrationParams +import eu.opencloud.android.lib.resources.oauth.params.TokenRequestParams +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.net.URLDecoder + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class OAuthRemoteOperationTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun tokenRequest_withConfidentialClient_sendsAuthorizationHeaderAndParsesResponse() { + server.enqueue(MockResponse().setResponseCode(200).setBody(tokenResponseJson())) + + val result = TokenRequestRemoteOperation( + TokenRequestParams.Authorization( + tokenEndpoint = server.url("/token").toString(), + clientAuth = "Basic abc123", + grantType = "authorization_code", + scope = "openid profile", + clientId = "desktop-client", + clientSecret = "client-secret", + authorizationCode = "auth-code", + redirectUri = "opencloud://oauth", + codeVerifier = "verifier" + ) + ).execute(newClient()) + + assertTrue(result.isSuccess) + assertEquals("access-token", result.data.accessToken) + assertEquals("refresh-token", result.data.refreshToken) + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/token", request.path) + assertEquals("Basic abc123", request.getHeader(AUTHORIZATION_HEADER)) + + val form = request.formBody() + assertEquals("authorization_code", form["grant_type"]) + assertEquals("auth-code", form["code"]) + assertEquals("opencloud://oauth", form["redirect_uri"]) + assertEquals("verifier", form["code_verifier"]) + assertEquals("desktop-client", form["client_id"]) + assertEquals("client-secret", form["client_secret"]) + } + + @Test + fun tokenRequest_withPublicPkceClient_omitsAuthorizationHeader() { + server.enqueue(MockResponse().setResponseCode(200).setBody(tokenResponseJson())) + + val result = TokenRequestRemoteOperation( + TokenRequestParams.Authorization( + tokenEndpoint = server.url("/token").toString(), + clientAuth = "", + grantType = "authorization_code", + scope = "openid", + clientId = "public-client", + clientSecret = null, + authorizationCode = "auth-code", + redirectUri = "opencloud://oauth", + codeVerifier = "verifier" + ) + ).execute(newClient()) + + assertTrue(result.isSuccess) + + val request = server.takeRequest() + assertNull(request.getHeader(AUTHORIZATION_HEADER)) + assertEquals("public-client", request.formBody()["client_id"]) + } + + @Test + fun tokenRequest_invalidJson_returnsExceptionResult() { + server.enqueue(MockResponse().setResponseCode(200).setBody("{ invalid json")) + + val result = TokenRequestRemoteOperation( + TokenRequestParams.RefreshToken( + tokenEndpoint = server.url("/token").toString(), + clientAuth = "", + grantType = "refresh_token", + scope = "openid", + clientId = "public-client", + clientSecret = null, + refreshToken = "refresh-token" + ) + ).execute(newClient()) + + assertFalse(result.isSuccess) + assertTrue(result.exception != null) + } + + @Test + fun registerClient_sendsJsonRequestAndParsesCreatedResponse() { + server.enqueue(MockResponse().setResponseCode(201).setBody(clientRegistrationResponseJson())) + + val result = RegisterClientRemoteOperation( + ClientRegistrationParams( + registrationEndpoint = server.url("/register").toString(), + clientName = "OpenCloud Android", + redirectUris = listOf("opencloud://oauth"), + tokenEndpointAuthMethod = "none", + applicationType = "native" + ) + ).execute(newClient()) + + assertTrue(result.isSuccess) + assertEquals("client-id", result.data.clientId) + assertEquals("client-secret", result.data.clientSecret) + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/register", request.path) + + val body = JSONObject(request.body.readUtf8()) + assertEquals("native", body.getString("application_type")) + assertEquals("OpenCloud Android", body.getString("client_name")) + assertEquals("none", body.getString("token_endpoint_auth_method")) + assertEquals("opencloud://oauth", body.getJSONArray("redirect_uris").getString(0)) + } + + @Test + fun registerClient_unexpectedStatus_returnsUnsuccessfulResult() { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{"error":"invalid_client_metadata"}""")) + + val result = RegisterClientRemoteOperation( + ClientRegistrationParams( + registrationEndpoint = server.url("/register").toString(), + clientName = "OpenCloud Android", + redirectUris = listOf("opencloud://oauth"), + tokenEndpointAuthMethod = "none", + applicationType = "native" + ) + ).execute(newClient()) + + assertFalse(result.isSuccess) + assertEquals(400, result.httpCode) + } + + private fun newClient(): OpenCloudClient = + OpenCloudClient( + Uri.parse(server.url("/").toString().removeSuffix("/")), + null, + true, + null, + ApplicationProvider.getApplicationContext() + ) + + private fun tokenResponseJson(): String = + """ + { + "access_token": "access-token", + "expires_in": 3600, + "refresh_token": "refresh-token", + "token_type": "Bearer", + "user_id": "user", + "scope": "openid profile", + "id_token": "id-token", + "additional_parameters": { + "server": "opencloud" + } + } + """.trimIndent() + + private fun clientRegistrationResponseJson(): String = + """ + { + "client_id": "client-id", + "client_secret": "client-secret", + "client_id_issued_at": 1700000000, + "client_secret_expires_at": 0 + } + """.trimIndent() + + private fun okhttp3.mockwebserver.RecordedRequest.formBody(): Map = + body.readUtf8() + .split("&") + .filter { it.isNotBlank() } + .associate { entry -> + val parts = entry.split("=", limit = 2) + URLDecoder.decode(parts[0], "UTF-8") to URLDecoder.decode(parts.getOrElse(1) { "" }, "UTF-8") + } +} diff --git a/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/shares/ShareRemoteOperationTest.kt b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/shares/ShareRemoteOperationTest.kt new file mode 100644 index 0000000000..36ab3c98a9 --- /dev/null +++ b/opencloudComLibrary/src/test/java/eu/opencloud/android/lib/resources/shares/ShareRemoteOperationTest.kt @@ -0,0 +1,187 @@ +/* openCloud Android Library is available under MIT license + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS + * BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package eu.opencloud.android.lib.resources.shares + +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import eu.opencloud.android.lib.common.OpenCloudClient +import eu.opencloud.android.lib.common.authentication.OpenCloudCredentialsFactory +import eu.opencloud.android.lib.common.http.HttpConstants.CONTENT_TYPE_HEADER +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.net.URLDecoder + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ShareRemoteOperationTest { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun createShare_sendsOcsHeaderAndOptionalFormFields() { + server.enqueue(MockResponse().setResponseCode(200).setBody(shareResponseJson(id = "11", permissions = 19))) + + val operation = CreateRemoteShareOperation( + remoteFilePath = "/Photos/image.jpg", + shareType = ShareType.USER, + shareWith = "user@example.com", + permissions = 19 + ).apply { + name = "Vacation" + password = "secret" + expirationDateInMillis = 1_735_689_600_000 + } + + val result = operation.execute(newClient()) + + assertTrue(result.isSuccess) + assertEquals("11", result.data.shares.first().id) + assertEquals(19, result.data.shares.first().permissions) + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/ocs/v2.php/apps/files_sharing/api/v1/shares?format=json", request.path) + assertEquals("true", request.getHeader("OCS-APIREQUEST")) + assertEquals("application/x-www-form-urlencoded", request.getHeader(CONTENT_TYPE_HEADER)) + + val form = request.formBody() + assertEquals("/Photos/image.jpg", form["path"]) + assertEquals("0", form["shareType"]) + assertEquals("user@example.com", form["shareWith"]) + assertEquals("19", form["permissions"]) + assertEquals("Vacation", form["name"]) + assertEquals("secret", form["password"]) + assertEquals("2025-01-01", form["expireDate"]) + } + + @Test + fun updateShare_sendsOnlyRequestedFormFields() { + server.enqueue(MockResponse().setResponseCode(200).setBody(shareResponseJson(id = "12", permissions = 1))) + + val operation = UpdateRemoteShareOperation(remoteId = "12").apply { + name = "" + password = null + expirationDateInMillis = -1 + permissions = 1 + } + + val result = operation.execute(newClient()) + + assertTrue(result.isSuccess) + + val request = server.takeRequest() + assertEquals("PUT", request.method) + assertEquals("/ocs/v2.php/apps/files_sharing/api/v1/shares/12?format=json", request.path) + assertEquals("true", request.getHeader("OCS-APIREQUEST")) + + val form = request.formBody() + assertEquals("", form["name"]) + assertEquals("", form["expireDate"]) + assertFalse(form.containsKey("password")) + assertEquals("1", form["permissions"]) + } + + @Test + fun createShare_returnsUnsuccessfulResultForServerError() { + server.enqueue(MockResponse().setResponseCode(500).setBody("server error")) + + val result = CreateRemoteShareOperation( + remoteFilePath = "/Photos/image.jpg", + shareType = ShareType.USER, + shareWith = "user@example.com", + permissions = 1 + ).execute(newClient()) + + assertFalse(result.isSuccess) + assertEquals(500, result.httpCode) + assertNull(result.data) + } + + private fun newClient(): OpenCloudClient = + OpenCloudClient( + Uri.parse(server.url("/").toString().removeSuffix("/")), + null, + true, + null, + ApplicationProvider.getApplicationContext() + ).apply { + credentials = OpenCloudCredentialsFactory.newBearerCredentials("user", "TOKEN") + } + + private fun shareResponseJson(id: String, permissions: Int): String = + """ + { + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": null, + "itemsperpage": null, + "totalitems": null + }, + "data": { + "id": "$id", + "share_with": "user@example.com", + "path": "/Photos/image.jpg", + "item_type": "file", + "share_with_displayname": "User", + "share_type": 0, + "permissions": $permissions, + "stime": 1700000000 + } + } + } + """.trimIndent() + + private fun okhttp3.mockwebserver.RecordedRequest.formBody(): Map = + body.readUtf8() + .split("&") + .filter { it.isNotBlank() } + .associate { entry -> + val parts = entry.split("=", limit = 2) + URLDecoder.decode(parts[0], "UTF-8") to URLDecoder.decode(parts.getOrElse(1) { "" }, "UTF-8") + } +} diff --git a/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB49Test.kt b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB49Test.kt new file mode 100644 index 0000000000..ff0205a38d --- /dev/null +++ b/opencloudData/src/androidTest/java/eu/opencloud/android/data/roommigrations/MigrationToDB49Test.kt @@ -0,0 +1,85 @@ +/* + * openCloud Android client application + * + * Copyright (C) 2026 OpenCloud GmbH. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package eu.opencloud.android.data.roommigrations + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.test.filters.SmallTest +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILES_TABLE_NAME +import eu.opencloud.android.data.ProviderMeta.ProviderTableMeta.FILE_REMOTE_ETAG +import eu.opencloud.android.data.migrations.MIGRATION_48_49 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@SmallTest +class MigrationToDB49Test : MigrationTest() { + + @Test + fun migrationFrom48to49_preservesFilesAndAddsRemoteEtag() { + performMigrationTest( + previousVersion = 48, + currentVersion = 49, + insertData = { database -> insertFileToTest(database) }, + validateMigration = { database -> validateMigrationTo49(database) }, + listOfMigrations = arrayOf(MIGRATION_48_49) + ) + } + + private fun insertFileToTest(database: SupportSQLiteDatabase) { + database.execSQL( + "INSERT INTO `$FILES_TABLE_NAME`" + + "(" + + "owner, " + + "remotePath, " + + "length, " + + "modificationTimestamp, " + + "mimeType, " + + "needsToUpdateThumbnail, " + + "sharedByLink" + + ")" + + " VALUES " + + "(?, ?, ?, ?, ?, ?, ?)", + arrayOf( + "user@example.com", + "/Documents/test.txt", + 1024, + 1_700_000_000, + "text/plain", + 0, + 0 + ) + ) + } + + private fun validateMigrationTo49(database: SupportSQLiteDatabase) { + val cursor = database.query("SELECT * FROM `$FILES_TABLE_NAME`") + assertTrue(cursor.moveToFirst()) + + val remoteEtagIndex = cursor.getColumnIndex(FILE_REMOTE_ETAG) + assertTrue(remoteEtagIndex != -1) + + assertEquals("user@example.com", cursor.getString(cursor.getColumnIndex("owner"))) + assertEquals("/Documents/test.txt", cursor.getString(cursor.getColumnIndex("remotePath"))) + assertTrue(cursor.isNull(remoteEtagIndex)) + + cursor.close() + database.close() + } +}