diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index 144875798b..356266070c 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -210,13 +210,6 @@ class CellsModule { fun provideGetNodeVersionsUseCase(cellsScope: CellsScope): GetNodeVersionsUseCase = cellsScope.getNodeVersions - @ViewModelScoped - @Provides - fun provideRefreshHelper(cellsScope: CellsScope, kaliumConfigs: KaliumConfigs): CellAssetRefreshHelper = CellAssetRefreshHelper( - refreshAsset = cellsScope.refreshAsset, - featureFlags = kaliumConfigs - ) - @ViewModelScoped @Provides fun provideRestoreNodeVersionUseCase(cellsScope: CellsScope): RestoreNodeVersionUseCase = @@ -226,4 +219,11 @@ class CellsModule { @Provides fun provideDownloadCellVersionUseCase(cellsScope: CellsScope): DownloadCellVersionUseCase = cellsScope.downloadCellVersion + + @ViewModelScoped + @Provides + fun provideRefreshHelper(cellsScope: CellsScope, kaliumConfigs: KaliumConfigs): CellAssetRefreshHelper = CellAssetRefreshHelper( + refreshAsset = cellsScope.refreshAsset, + featureFlags = kaliumConfigs + ) } diff --git a/app/src/test/kotlin/com/wire/android/util/StringUtilTest.kt b/app/src/test/kotlin/com/wire/android/util/StringUtilTest.kt index de98921f67..adff1b2c83 100644 --- a/app/src/test/kotlin/com/wire/android/util/StringUtilTest.kt +++ b/app/src/test/kotlin/com/wire/android/util/StringUtilTest.kt @@ -46,6 +46,36 @@ class StringUtilTest { assert(expected == actual) } + @Test + fun givenFilenameWithExtension_whenCalled_thenShouldInsertStringBeforeExtension() { + val originalFilename = "document.pdf" + val textToInsert = "new" + + val result = originalFilename.addBeforeExtension(textToInsert) + + assertEquals("document_new.pdf", result) + } + + @Test + fun givenFilenameWithoutExtension_whenCalled_thenShouldAppendString() { + val originalFilename = "myfile" + val textToInsert = "_version2" + + val result = originalFilename.addBeforeExtension(textToInsert) + + assertEquals("myfile_version2", result) + } + + @Test + fun givenFilenameWithMultipleDots_whenCalled_thenShouldInsertBeforeLastDot() { + val originalFilename = "archive.2024.tar.gz" + val textToInsert = "backup" + + val result = originalFilename.addBeforeExtension(textToInsert) + + assertEquals("archive.2024_backup.tar.gz", result) + } + @Suppress("LongMethod") @Test fun givenDifferentMarkdownsWithOnlyWhitespaces_whenCheckingIfNotBlank_thenReturnProperValues() { diff --git a/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt b/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt index 6b789982c5..67c95f8305 100644 --- a/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt +++ b/core/ui-common/src/main/kotlin/com/wire/android/util/StringUtils.kt @@ -49,12 +49,17 @@ fun String.capitalizeFirstLetter(): String = lowercase().replaceFirstChar(Char:: fun String.normalizeFileName(): String = this.replace("/", "") fun String.addBeforeExtension(insert: String): String { - val dotIndex = this.lastIndexOf('.') - return if (dotIndex != -1) { - val name = this.take(dotIndex) - val ext = this.substring(dotIndex) - "${name}_$insert$ext" - } else { - this + insert + val lastDotIndex = this.lastIndexOf('.') + if (lastDotIndex <= 0) { + return this + insert } + + val extensionBlockIndex = this.lastIndexOf('.', lastDotIndex - 1).let { + if (it == -1) lastDotIndex else it + } + + val name = this.take(extensionBlockIndex) + val ext = this.substring(extensionBlockIndex) + + return "${name}_$insert$ext" } diff --git a/features/cells/build.gradle.kts b/features/cells/build.gradle.kts index 3fccbffb0c..7a556c69aa 100644 --- a/features/cells/build.gradle.kts +++ b/features/cells/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { testRuntimeOnly(libs.junit5.engine) androidTestImplementation(libs.androidx.test.extJunit) androidTestImplementation(libs.androidx.espresso.core) + testImplementation(testFixtures(project(":core:ui-common"))) } android { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt index aea842070c..925d302b79 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryScreen.kt @@ -145,9 +145,9 @@ fun VersionHistoryScreen( private fun VersionHistoryScreenContent( versionsGroupedByTime: List, versionHistoryState: State, - onRefresh: () -> Unit, optionsBottomSheetState: WireModalSheetState>, restoreDialogState: RestoreDialogState, + onRefresh: () -> Unit, modifier: Modifier = Modifier, fileName: String? = null, restoreVersion: () -> Unit = {}, diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt index e958024f33..54608dac72 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModel.kt @@ -156,7 +156,6 @@ class VersionHistoryViewModel @Inject constructor( } } - // TODO: Unit test coming in another PR fun showRestoreConfirmationDialog(versionId: String) { restoreDialogState.value = restoreDialogState.value.copy( visible = true, @@ -166,7 +165,6 @@ class VersionHistoryViewModel @Inject constructor( ) } - // TODO: Unit test coming in another PR fun hideRestoreConfirmationDialog() { restoreDialogState.value = restoreDialogState.value.copy( restoreVersionState = RestoreVersionState.Idle, @@ -175,7 +173,6 @@ class VersionHistoryViewModel @Inject constructor( ) } - // TODO: Unit test coming in another PR fun restoreVersion() { with(restoreDialogState) { restoreDialogState.value = value.copy( @@ -205,7 +202,6 @@ class VersionHistoryViewModel @Inject constructor( } } - // TODO: Unit test coming in another PR fun downloadVersion(versionId: String, versionDate: String) { viewModelScope.launch { downloadState.value = DownloadState.Downloading(0, 0) diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt index 466961796e..b576a3836d 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileHelper.kt @@ -22,9 +22,9 @@ import android.content.ContentValues import android.content.Context import android.content.Intent import android.net.Uri -import android.os.Build import android.os.Environment import android.provider.MediaStore +import android.os.Build import androidx.core.content.FileProvider import dagger.hilt.android.qualifiers.ApplicationContext import okio.Path diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt index 327878a19c..06a1df84bd 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/versioning/VersionHistoryViewModelTest.kt @@ -18,20 +18,27 @@ package com.wire.android.feature.cells.ui.versioning import androidx.lifecycle.SavedStateHandle +import com.wire.android.config.TestDispatcherProvider import com.wire.android.feature.cells.R import com.wire.android.feature.cells.ui.edit.OnlineEditor +import com.wire.android.feature.cells.ui.versioning.download.DownloadState +import com.wire.android.feature.cells.ui.versioning.restore.RestoreDialogState +import com.wire.android.feature.cells.ui.versioning.restore.RestoreVersionState import com.wire.android.feature.cells.util.FileHelper import com.wire.android.util.FileSizeFormatter -import com.wire.android.util.dispatchers.DispatcherProvider +import com.wire.android.util.ui.UIText import com.wire.android.util.ui.resolveForTest import com.wire.android.util.ui.toUIText import com.wire.kalium.cells.domain.model.NodeVersion +import com.wire.kalium.cells.domain.model.PreSignedUrl import com.wire.kalium.cells.domain.usecase.DownloadCellVersionUseCase import com.wire.kalium.cells.domain.usecase.versioning.GetNodeVersionsUseCase import com.wire.kalium.cells.domain.usecase.versioning.RestoreNodeVersionUseCase import com.wire.kalium.common.error.CoreFailure import com.wire.kalium.common.functional.Either +import com.wire.kalium.common.functional.right import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.Dispatchers @@ -39,13 +46,16 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.io.OutputStream import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -53,27 +63,14 @@ import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle +private val dispatcher = StandardTestDispatcher() + @ExperimentalCoroutinesApi class VersionHistoryViewModelTest { - private val testDispatcher = StandardTestDispatcher() - - private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) - private val getNodeVersionsUseCase: GetNodeVersionsUseCase = mockk() - private val restoreNodeVersionUseCase: RestoreNodeVersionUseCase = mockk() - private val downloadCellVersionUseCase: DownloadCellVersionUseCase = mockk() - private val fileSizeFormatter: FileSizeFormatter = mockk() - val fileHelper: FileHelper = mockk() - val onlineEditor: OnlineEditor = mockk() - private val testDispatcherProvider: DispatcherProvider = mockk() - private val testNodeUuid = "test-node-uuid" - @BeforeEach fun setUp() { - Dispatchers.setMain(testDispatcher) - - every { savedStateHandle.get("uuid") } returns testNodeUuid - every { savedStateHandle.get("fileName") } returns testNodeUuid + Dispatchers.setMain(dispatcher) } @AfterEach @@ -83,18 +80,10 @@ class VersionHistoryViewModelTest { @Test fun givenViewModel_whenItInits_thenIsFetchingStateIsManagedCorrectly() = runTest { - coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Right(emptyList()) - - val viewModel = VersionHistoryViewModel( - savedStateHandle = savedStateHandle, - getNodeVersionsUseCase = getNodeVersionsUseCase, - fileSizeFormatter = fileSizeFormatter, - restoreNodeVersionUseCase = restoreNodeVersionUseCase, - downloadCellVersionUseCase = downloadCellVersionUseCase, - fileHelper = fileHelper, - onlineEditor = onlineEditor, - dispatchers = testDispatcherProvider, - ) + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .arrange() assertEquals(VersionHistoryState.Idle, viewModel.versionHistoryState.value) advanceUntilIdle() @@ -107,65 +96,20 @@ class VersionHistoryViewModelTest { val today = LocalDate.now() val yesterday = today.minusDays(1) val twoDaysAgo = today.minusDays(2) - val versionNode = NodeVersion( - id = "v1", - hash = null, - description = null, - isDraft = true, - etag = "etag", - editorUrls = null, - filePreviews = null, - isHead = false, - modifiedTime = today.atTime(10, 30).toEpochSecond(ZoneOffset.UTC).toString(), - ownerName = "User A", - ownerUuid = "uuid", - getUrl = null, - size = "1500" - ) - val versionsFromApi = listOf( - versionNode, - versionNode.copy( - id = "v2", - ownerName = "User B", - modifiedTime = yesterday.atTime(14, 0).toEpochSecond(ZoneOffset.UTC).toString(), - size = "2048" - ), - versionNode.copy( - id = "v3", - ownerName = "User A", - modifiedTime = yesterday.atTime(9, 15).toEpochSecond(ZoneOffset.UTC).toString(), - size = "5000000" - ), - versionNode.copy( - id = "v4", - ownerName = "User C", - modifiedTime = twoDaysAgo.atStartOfDay().toEpochSecond(ZoneOffset.UTC).toString(), - size = "123" - ), - ) - coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Right(versionsFromApi) - every { fileSizeFormatter.formatSize(any()) } returns "30 MB" - - val viewModel = VersionHistoryViewModel( - savedStateHandle = savedStateHandle, - getNodeVersionsUseCase = getNodeVersionsUseCase, - fileSizeFormatter = fileSizeFormatter, - restoreNodeVersionUseCase = restoreNodeVersionUseCase, - downloadCellVersionUseCase = downloadCellVersionUseCase, - fileHelper = fileHelper, - onlineEditor = onlineEditor, - dispatchers = testDispatcherProvider, - ) + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(versionsFromApi)) + .withFileSizeFormatter() + .arrange() - testDispatcher.scheduler.advanceUntilIdle() + advanceUntilIdle() // Versions should be grouped into three sections (Today, Yesterday, and an older date) val groupedVersions = viewModel.versionsGroupedByTime.value assertEquals(3, groupedVersions.size) // Verify "Today" group is correct - every { fileSizeFormatter.formatSize(any()) } returns groupedVersions[0].versions[0].fileSize val todayFormattedDate = today.format(DateTimeFormatter.ofPattern("d MMM yyyy")) val todayFakeString = mapOf(R.string.date_label_today to "Today, %1\$s") @@ -182,7 +126,6 @@ class VersionHistoryViewModelTest { assertEquals(expectedTime, groupedVersions[0].versions[0].modifiedAt) // Verify "Yesterday" group is correct - every { fileSizeFormatter.formatSize(any()) } returns groupedVersions[1].versions[0].fileSize val yesterdayFormattedDate = yesterday.format(DateTimeFormatter.ofPattern("d MMM yyyy")) val yesterdayFakeString = mapOf(R.string.date_label_yesterday to "Yesterday, %1\$s") val actualYesterdayText = groupedVersions[1].dateLabel.resolveForTest(yesterdayFakeString) @@ -200,21 +143,345 @@ class VersionHistoryViewModelTest { @Test fun givenApiFailure_whenViewModelInits_thenVersionListIsEmpty() = runTest { - coEvery { getNodeVersionsUseCase(testNodeUuid) } returns Either.Left(CoreFailure.MissingClientRegistration) - - val viewModel = VersionHistoryViewModel( - savedStateHandle = savedStateHandle, - getNodeVersionsUseCase = getNodeVersionsUseCase, - fileSizeFormatter = fileSizeFormatter, - restoreNodeVersionUseCase = restoreNodeVersionUseCase, - downloadCellVersionUseCase = downloadCellVersionUseCase, - fileHelper = fileHelper, - onlineEditor = onlineEditor, - dispatchers = testDispatcherProvider, - ) + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Left(CoreFailure.MissingClientRegistration)) + .arrange() + advanceUntilIdle() assertTrue(viewModel.versionsGroupedByTime.value.isEmpty()) assertEquals(VersionHistoryState.Failed, viewModel.versionHistoryState.value) } + + @Test + fun givenDialogIsHidden_whenShowRestoreConfirmationDialogIsCalled_thenStateIsVisibleWithCorrectVersionId() = runTest { + // GIVEN an initial state where the dialog is not visible + val testVersionId = "version-id-12345" + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .arrange() + + viewModel.restoreDialogState.value = RestoreDialogState(visible = false) + assertFalse(viewModel.restoreDialogState.value.visible) + + // WHEN the `showRestoreConfirmationDialog` function is called + viewModel.showRestoreConfirmationDialog(testVersionId) + + // THEN the dialog state should be updated to be visible with the correct data + val newState = viewModel.restoreDialogState.value + assertTrue(newState.visible) + assertEquals(testVersionId, newState.versionId) + assertEquals(RestoreVersionState.Idle, newState.restoreVersionState) + assertEquals(0f, newState.restoreProgress) + } + + @Test + fun givenDialogIsVisible_whenHideRestoreConfirmationDialogIsCalled_thenStateIsHiddenAndReset() = runTest { + // GIVEN an initial state where the dialog is visible and has data + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .arrange() + + viewModel.restoreDialogState.value = RestoreDialogState( + visible = true, + versionId = "version-id-12345", + restoreVersionState = RestoreVersionState.Completed, + restoreProgress = 1f + ) + + assertTrue(viewModel.restoreDialogState.value.visible) + + // WHEN the `hideRestoreConfirmationDialog` function is called + viewModel.hideRestoreConfirmationDialog() + + // THEN the dialog state should be updated to be hidden and reset + val newState = viewModel.restoreDialogState.value + assertFalse(newState.visible) + assertEquals("", newState.versionId) + assertEquals(RestoreVersionState.Idle, newState.restoreVersionState) + } + + @Test + fun givenUseCaseSucceeds_whenRestoreVersionIsCalled_thenStateBecomesCompleted() = runTest { + // GIVEN the restore use case will succeed + val testVersionId = "version-to-restore" + val (arrangement, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .withRestoreNodeVersionReturning(Unit.right()) + .arrange() + viewModel.restoreDialogState.value = RestoreDialogState(versionId = testVersionId) + + // WHEN the `restoreVersion` function is called + viewModel.restoreVersion() + + // THEN the state should immediately be updated to Restoring + assertEquals(RestoreVersionState.Restoring, viewModel.restoreDialogState.value.restoreVersionState) + + // AND after coroutines complete, the final state should be Completed + advanceUntilIdle() + + val finalState = viewModel.restoreDialogState.value + assertEquals(RestoreVersionState.Completed, finalState.restoreVersionState) + assertEquals(1f, finalState.restoreProgress) + + // list of versions should be re-fetched, making it the SECOND call overall after the init block + coVerify(exactly = 2) { arrangement.getNodeVersionsUseCase(any()) } + } + + @Test + fun givenUseCaseFails_whenRestoreVersionIsCalled_thenStateBecomesFailed() = runTest { + // GIVEN + val testVersionId = "version-to-restore" + val (arrangement, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .withRestoreNodeVersionReturning(Either.Left(CoreFailure.MissingClientRegistration)) + .arrange() + viewModel.restoreDialogState.value = RestoreDialogState(versionId = testVersionId) + + // WHEN the `restoreVersion` function is called + viewModel.restoreVersion() + + // THEN the state should immediately be updated to Restoring + assertEquals(RestoreVersionState.Restoring, viewModel.restoreDialogState.value.restoreVersionState) + + advanceUntilIdle() // Execute all pending coroutines + + val finalState = viewModel.restoreDialogState.value + assertEquals(RestoreVersionState.Failed, finalState.restoreVersionState) + + // list should NOT be re-fetched, the call count remains 1 (from the init block) + coVerify(exactly = 1) { arrangement.getNodeVersionsUseCase(any()) } + } + + @Test + fun givenVersionExistsAndUseCaseSucceeds_whenDownloadVersionIsCalled_thenStateBecomesDownloaded() = runTest { + // GIVEN a version exists and all dependencies will succeed + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withDownloadVersionReturning(shouldSucceed = true, true) + .withGetNodeVersionReturning(Either.Right(versionsFromApi)) + .withFileSizeFormatter() + .withSuccessfulFileCreation() + .arrange() + + // WHEN downloadVersion is called + viewModel.downloadVersion(testVersion.versionId, "2025-01-01") + + runCurrent() + // THEN the state should immediately become Downloading + assertTrue(viewModel.downloadState.value is DownloadState.Downloading) + + // AND after the coroutine finishes, the state becomes Downloaded + advanceUntilIdle() + + assertTrue(viewModel.downloadState.value is DownloadState.Downloaded) + } + + @Test + fun givenDownloadUseCaseFails_whenDownloadVersionIsCalled_thenStateBecomesFailed() = runTest { + // GIVEN a version exists but the download use case will fail + val (_, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .withDownloadVersionReturning(shouldSucceed = false) + .withFileSizeFormatter() + .withSuccessfulFileCreation() + .arrange() + + val versionGroup = VersionGroup( + dateLabel = UIText.DynamicString("Today"), + versions = listOf(testVersion) + ) + viewModel.versionsGroupedByTime.value = listOf(versionGroup) + + // WHEN downloadVersion is called + viewModel.downloadVersion(testVersion.versionId, "2025-01-01") + advanceUntilIdle() + + // THEN the final state should be Failed + assertEquals(DownloadState.Failed, viewModel.downloadState.value) + } + + @Test + fun givenFileCreationFails_whenDownloadVersionIsCalled_thenStateBecomesFailed() = runTest { + // GIVEN a version exists but the file helper returns null (cannot create file) + val (arrangement, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .withFileSizeFormatter() + .withFileCreationFailure() + .arrange() + + val versionGroup = VersionGroup( + dateLabel = UIText.DynamicString("Today"), + versions = listOf(testVersion) + ) + viewModel.versionsGroupedByTime.value = listOf(versionGroup) + + // WHEN downloadVersion is called + viewModel.downloadVersion(testVersion.versionId, "2025-01-01") + advanceUntilIdle() + + // THEN the state remains Idle and the use case is never called + assertEquals(DownloadState.Failed, viewModel.downloadState.value) + coVerify(exactly = 0) { arrangement.downloadCellVersionUseCase(any(), any(), any()) } + } + + @Test + fun givenVersionDoesNotExist_whenDownloadVersionIsCalled_thenStateBecomesFailed() = runTest { + val (arrangement, viewModel) = Arrangement() + .withSavedStateHandleReturning() + .withGetNodeVersionReturning(Either.Right(emptyList())) + .withFileSizeFormatter() + .withFileCreationFailure() + .arrange() + + // WHEN downloadVersion is called with a non-existent ID + viewModel.downloadVersion("non-existent-id", "2025-01-01") + advanceUntilIdle() + + // THEN the state remains Idle + assertEquals(DownloadState.Failed, viewModel.downloadState.value) + coVerify(exactly = 0) { arrangement.fileHelper.createDownloadFileStream(any()) } + coVerify(exactly = 0) { arrangement.downloadCellVersionUseCase.invoke(any(), any(), any()) } + } + + private class Arrangement { + + val savedStateHandle: SavedStateHandle = mockk(relaxed = true) + val getNodeVersionsUseCase: GetNodeVersionsUseCase = mockk() + val fileSizeFormatter: FileSizeFormatter = mockk() + val restoreNodeVersionUseCase: RestoreNodeVersionUseCase = mockk() + val downloadCellVersionUseCase: DownloadCellVersionUseCase = mockk() + val fileHelper: FileHelper = mockk() + val onlineEditor: OnlineEditor = mockk() + private val testDispatcherProvider = TestDispatcherProvider(dispatcher) + + private val testNodeUuid = "test-node-uuid" + + init { + every { savedStateHandle.get("uuid") } returns "test-node-uuid" + every { savedStateHandle.get("fileName") } returns "file-name" + } + + fun withSavedStateHandleReturning() = apply { + every { savedStateHandle.get("uuid") } returns testNodeUuid + every { savedStateHandle.get("fileName") } returns "file-name" + } + + fun withGetNodeVersionReturning(returnValue: Either>) = apply { + coEvery { getNodeVersionsUseCase(testNodeUuid) } returns returnValue + } + + fun withRestoreNodeVersionReturning(returnValue: Either) = apply { + coEvery { restoreNodeVersionUseCase(any(), any()) } returns returnValue + } + + fun withDownloadVersionReturning( + shouldSucceed: Boolean, + simulateProgress: Boolean = false + ) = apply { + coEvery { downloadCellVersionUseCase.invoke(any(), any(), any()) } coAnswers { + val onProgressUpdate = it.invocation.args[2] as (Long, Long) -> Unit + + if (simulateProgress) { + // Simulate async work before the first progress update + kotlinx.coroutines.delay(1) + onProgressUpdate(50L, 100L) // Simulate 50% progress + // Simulate more work before finishing + kotlinx.coroutines.delay(1) + } + + if (shouldSucceed) { + Unit.right() + } else { + Either.Left(CoreFailure.MissingClientRegistration) + } + } + } + + fun withSuccessfulFileCreation() = apply { + val mockOutputStream: OutputStream = mockk(relaxed = true) + coEvery { fileHelper.createDownloadFileStream(any()) } returns mockOutputStream + } + + fun withFileCreationFailure() = apply { + every { fileHelper.createDownloadFileStream(any()) } returns null + } + + fun withFileSizeFormatter() = apply { + coEvery { fileSizeFormatter.formatSize(any()) } returns "30 MB" + } + + fun arrange(): Pair { + val viewModel = VersionHistoryViewModel( + savedStateHandle = savedStateHandle, + getNodeVersionsUseCase = getNodeVersionsUseCase, + fileSizeFormatter = fileSizeFormatter, + restoreNodeVersionUseCase = restoreNodeVersionUseCase, + downloadCellVersionUseCase = downloadCellVersionUseCase, + fileHelper = fileHelper, + onlineEditor = onlineEditor, + dispatchers = testDispatcherProvider, + ) + return this to viewModel + } + } + + companion object { + val today: LocalDate = LocalDate.now() + val yesterday: LocalDate = today.minusDays(1) + val twoDaysAgo: LocalDate = today.minusDays(2) + + val versionNode = NodeVersion( + id = "v1", + hash = null, + description = null, + isDraft = true, + etag = "etag", + editorUrls = null, + filePreviews = null, + isHead = false, + modifiedTime = today.atTime(10, 30).toEpochSecond(ZoneOffset.UTC).toString(), + ownerName = "User A", + ownerUuid = "uuid", + getUrl = PreSignedUrl("expiration", "url"), + size = "1500" + ) + + val versionsFromApi = listOf( + versionNode, + versionNode.copy( + id = "v2", + ownerName = "User B", + modifiedTime = yesterday.atTime(14, 0).toEpochSecond(ZoneOffset.UTC).toString(), + size = "2048" + ), + versionNode.copy( + id = "v3", + ownerName = "User A", + modifiedTime = yesterday.atTime(9, 15).toEpochSecond(ZoneOffset.UTC).toString(), + size = "5000000" + ), + versionNode.copy( + id = "v4", + ownerName = "User C", + modifiedTime = twoDaysAgo.atStartOfDay().toEpochSecond(ZoneOffset.UTC).toString(), + size = "123" + ), + ) + private val testVersion = CellVersion( + versionId = "v1", + modifiedBy = "user", + fileSize = "1MB", + modifiedAt = "10:30 AM", + isCurrentVersion = false, + presignedUrl = "https://wire.com/" + ) + } }