From eecaf44b724926a3d6e3b94be1eee34bc08952da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Tue, 19 May 2026 00:48:07 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B2=D1=8B=D0=B5=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 23 +++ .../wallenc/app/ExampleInstrumentedTest.kt | 24 --- .../yandex/ExportYandexTestCredentialsTest.kt | 53 ++++++ .../yandex/YandexDiskLiveIntegrationTest.kt | 66 +++++++ .../yandex/YandexTestCredentials.kt | 19 ++ .../app/di/modules/data/PersistenceModule.kt | 12 +- .../wallenc/app/di/modules/data/RoomModule.kt | 12 +- app/src/main/res/values-ru/plurals.xml | 15 ++ .../nullptroma/wallenc/app/ExampleUnitTest.kt | 17 -- domain-vault/build.gradle.kts | 2 + .../infrastructure/ExampleInstrumentedTest.kt | 24 --- .../yandexdisk/YandexDiskApiFactory.kt | 26 ++- .../vault/errors/VaultThrowableMappingTest.kt | 47 +++++ .../YandexDiskRepositoryTestFactory.kt | 48 ++++++ .../repository/YandexDiskRepositoryTest.kt | 66 +++++++ .../wallenc/infrastructure/ExampleUnitTest.kt | 17 -- domain/build.gradle.kts | 1 + gradle/libs.versions.toml | 16 +- infrastructure-android/build.gradle.kts | 6 +- infrastructure-android/consumer-rules.pro | 1 + .../repository/YandexAccountRepositoryTest.kt | 80 +++++++++ .../{ => android}/db/RoomFactory.kt | 4 +- .../infrastructure/android/db/app/AppDb.kt | 31 ++++ .../db/app/dao/StorageKeyMapDao.kt | 4 +- .../db/app/dao/StorageMetaInfoDao.kt | 4 +- .../db/app/dao/StorageSyncGroupDao.kt | 4 +- .../db/app/dao/YandexAccountDao.kt | 4 +- .../db/app/model/DbStorageKeyMap.kt | 5 +- .../db/app/model/DbStorageMetaInfo.kt | 2 +- .../db/app/model/DbStorageSyncGroup.kt | 2 +- .../db/app/model/DbYandexAccount.kt | 2 +- .../app/repository/StorageKeyMapRepository.kt | 6 +- .../repository/StorageMetaInfoRepository.kt | 6 +- .../repository/StorageSyncGroupRepository.kt | 6 +- .../app/repository/YandexAccountRepository.kt | 6 +- .../wallenc/infrastructure/db/app/AppDb.kt | 31 ---- task-runtime/build.gradle.kts | 1 + .../task/runtime/TaskOrchestratorTest.kt | 99 +++++++++++ ui/build.gradle.kts | 10 ++ .../wallenc/ui/ExampleInstrumentedTest.kt | 24 --- .../secrets/TextSecretsScreenContentTest.kt | 66 +++++++ .../twofa/TwoFaTokensScreenContentTest.kt | 70 ++++++++ .../storage/secrets/TextSecretsScreen.kt | 113 ++---------- .../secrets/TextSecretsScreenContent.kt | 133 ++++++++++++++ .../storage/twofa/TwoFaTokensScreen.kt | 54 +----- .../storage/twofa/TwoFaTokensScreenContent.kt | 86 +++++++++ .../nullptroma/wallenc/ui/ExampleUnitTest.kt | 17 -- .../ui/navigation/WallencDeepLinksTest.kt | 39 +++++ .../storage/twofa/OtpAuthUriParserTest.kt | 37 ++++ .../tasks/TaskPipelineViewModelTest.kt | 42 +++++ usecases/build.gradle.kts | 1 + .../usecases/StorageDomainUseCasesTest.kt | 163 +----------------- .../wallenc/usecases/StorageSyncEngineTest.kt | 152 ++++++++++++++++ .../wallenc/usecases/TwoFaTotpTest.kt | 88 ++++++++++ .../wallenc/usecases/fakes/FakeStorage.kt | 60 +++++++ .../usecases/fakes/FakeStorageAccessor.kt | 140 +++++++++++++++ .../fakes/FakeStorageSyncGroupStore.kt | 18 ++ .../usecases/fakes/FakeVaultsManager.kt | 30 ++++ 58 files changed, 1634 insertions(+), 501 deletions(-) delete mode 100644 app/src/androidTest/java/com/github/nullptroma/wallenc/app/ExampleInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/ExportYandexTestCredentialsTest.kt create mode 100644 app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexDiskLiveIntegrationTest.kt create mode 100644 app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexTestCredentials.kt create mode 100644 app/src/main/res/values-ru/plurals.xml delete mode 100644 app/src/test/java/com/github/nullptroma/wallenc/app/ExampleUnitTest.kt delete mode 100644 domain-vault/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/ExampleInstrumentedTest.kt create mode 100644 domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt create mode 100644 domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskRepositoryTestFactory.kt create mode 100644 domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/repository/YandexDiskRepositoryTest.kt delete mode 100644 domain-vault/src/test/java/com/github/nullptroma/wallenc/infrastructure/ExampleUnitTest.kt create mode 100644 infrastructure-android/consumer-rules.pro create mode 100644 infrastructure-android/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepositoryTest.kt rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/RoomFactory.kt (70%) create mode 100644 infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/AppDb.kt rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/dao/StorageKeyMapDao.kt (73%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/dao/StorageMetaInfoDao.kt (85%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/dao/StorageSyncGroupDao.kt (74%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/dao/YandexAccountDao.kt (85%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/model/DbStorageKeyMap.kt (91%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/model/DbStorageMetaInfo.kt (80%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/model/DbStorageSyncGroup.kt (85%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/model/DbYandexAccount.kt (82%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/repository/StorageKeyMapRepository.kt (80%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/repository/StorageMetaInfoRepository.kt (88%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/repository/StorageSyncGroupRepository.kt (92%) rename infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/{ => android}/db/app/repository/YandexAccountRepository.kt (87%) delete mode 100644 infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt create mode 100644 task-runtime/src/test/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestratorTest.kt delete mode 100644 ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/ExampleInstrumentedTest.kt create mode 100644 ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContentTest.kt create mode 100644 ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContentTest.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContent.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContent.kt delete mode 100644 ui/src/test/java/com/github/nullptroma/wallenc/ui/ExampleUnitTest.kt create mode 100644 ui/src/test/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinksTest.kt create mode 100644 ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParserTest.kt create mode 100644 ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModelTest.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorage.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageSyncGroupStore.kt create mode 100644 usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeVaultsManager.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5d10d49..921153c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) @@ -5,6 +7,13 @@ plugins { alias(libs.plugins.ksp) } +val localProps = Properties().apply { + val file = rootProject.file("local.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } +} + android { namespace = "com.github.nullptroma.wallenc.app" compileSdk = 37 @@ -17,6 +26,12 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments["yandex.oauth.token"] = + localProps.getProperty("yandex.test.oauth.token").orEmpty() + testInstrumentationRunnerArguments["yandex.user.id"] = + localProps.getProperty("yandex.test.user.id").orEmpty() + testInstrumentationRunnerArguments["yandex.vault.uuid"] = + localProps.getProperty("yandex.test.vault.uuid").orEmpty() vectorDrawables { useSupportLibrary = true } @@ -78,6 +93,14 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.room.testing) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.okhttp3) + androidTestImplementation(libs.retrofit) + androidTestImplementation(libs.retrofit.converter.jackson) + androidTestImplementation(libs.jackson.module.kotlin) + androidTestImplementation(libs.jackson.datatype.jsr310) implementation(project(":domain")) implementation(project(":usecases")) diff --git a/app/src/androidTest/java/com/github/nullptroma/wallenc/app/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/ExampleInstrumentedTest.kt deleted file mode 100644 index 7747c1d..0000000 --- a/app/src/androidTest/java/com/github/nullptroma/wallenc/app/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.nullptroma.wallenc.app - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.github.nullptroma.wallenc.app", appContext.packageName) - } -} \ No newline at end of file diff --git a/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/ExportYandexTestCredentialsTest.kt b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/ExportYandexTestCredentialsTest.kt new file mode 100644 index 0000000..c1a134d --- /dev/null +++ b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/ExportYandexTestCredentialsTest.kt @@ -0,0 +1,53 @@ +package com.github.nullptroma.wallenc.app.integration.yandex + +import android.util.Log +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.nullptroma.wallenc.infrastructure.android.db.app.AppDb +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Ручной helper: после входа в Yandex через приложение печатает в Logcat маскированный + * токен и подсказку для local.properties. Запускать вручную из Android Studio. + */ +@RunWith(AndroidJUnit4::class) +@Ignore("Manual: export Yandex credentials to local.properties") +class ExportYandexTestCredentialsTest { + + @Test + fun printFirstAccountCredentialsToLogcat() { + val context = ApplicationProvider.getApplicationContext() + val db = Room.databaseBuilder(context, AppDb::class.java, "wallenc.db") + .build() + try { + val row = runBlocking { + db.yandexAccountDao.observeAll().first().firstOrNull() + } + if (row == null) { + Log.i(TAG, "No Yandex accounts in DB. Link a vault in the app first.") + return + } + Log.i(TAG, "Add to local.properties:") + Log.i(TAG, "yandex.test.oauth.token=") + Log.i(TAG, "yandex.test.oauth.token.prefix=${maskToken(row.oauthToken)}") + Log.i(TAG, "yandex.test.user.id=${row.yandexUserId}") + Log.i(TAG, "yandex.test.vault.uuid=${row.vaultUuid}") + } finally { + db.close() + } + } + + private fun maskToken(token: String): String { + if (token.length <= 8) return "***" + return token.take(4) + "…" + token.takeLast(4) + } + + companion object { + private const val TAG = "WallencYandexExport" + } +} diff --git a/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexDiskLiveIntegrationTest.kt b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexDiskLiveIntegrationTest.kt new file mode 100644 index 0000000..db5f7e1 --- /dev/null +++ b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexDiskLiveIntegrationTest.kt @@ -0,0 +1,66 @@ +package com.github.nullptroma.wallenc.app.integration.yandex + +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskApiFactory +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class YandexDiskLiveIntegrationTest { + + private lateinit var repository: YandexDiskRepository + private val testFolder = "disk:/wallenc-integration-test" + private val probeFileName = "wallenc-probe.txt" + private val probePath = "$testFolder/$probeFileName" + private val probePayload = "wallenc-integration-probe".encodeToByteArray() + + @Before + fun setUp() { + YandexTestCredentials.assumePresent() + val token = YandexTestCredentials.oauthToken()!! + repository = YandexDiskApiFactory.createRepositoryWithToken(token, Dispatchers.IO) + runBlocking { + runCatching { repository.createFolder(testFolder) } + runCatching { + repository.uploadBytes(probePath, probePayload, overwrite = true) + } + } + } + + @After + fun tearDown() = runBlocking { + runCatching { repository.delete(probePath, permanently = true) } + } + + @Test + fun diskInfoReturnsQuota() = runBlocking { + val info = repository.diskInfo() + assertNotNull(info.totalSpace) + assertTrue(info.totalSpace!! > 0) + } + + @Test + fun listTestFolderDoesNotThrow() = runBlocking { + val result = repository.list(testFolder, limit = 10, offset = 0) + assertNotNull(result) + } + + @Test + fun uploadAndDownloadRoundTrip() = runBlocking { + val path = "$testFolder/roundtrip-${System.currentTimeMillis()}.bin" + try { + repository.uploadBytes(path, probePayload, overwrite = true) + val downloaded = repository.openDownloadStream(path).use { it.readBytes() } + assertTrue(downloaded.contentEquals(probePayload)) + } finally { + runCatching { repository.delete(path, permanently = true) } + } + } +} diff --git a/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexTestCredentials.kt b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexTestCredentials.kt new file mode 100644 index 0000000..243386e --- /dev/null +++ b/app/src/androidTest/java/com/github/nullptroma/wallenc/app/integration/yandex/YandexTestCredentials.kt @@ -0,0 +1,19 @@ +package com.github.nullptroma.wallenc.app.integration.yandex + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assume.assumeFalse + +object YandexTestCredentials { + fun oauthToken(): String? = + InstrumentationRegistry.getArguments().getString("yandex.oauth.token")?.takeIf { it.isNotBlank() } + + fun userId(): String? = + InstrumentationRegistry.getArguments().getString("yandex.user.id")?.takeIf { it.isNotBlank() } + + fun vaultUuid(): String? = + InstrumentationRegistry.getArguments().getString("yandex.vault.uuid")?.takeIf { it.isNotBlank() } + + fun assumePresent(message: String = "Добавьте yandex.test.oauth.token в local.properties") { + assumeFalse(message, oauthToken().isNullOrBlank()) + } +} diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/PersistenceModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/PersistenceModule.kt index 60cd145..2c9f6f0 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/PersistenceModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/PersistenceModule.kt @@ -2,12 +2,12 @@ package com.github.nullptroma.wallenc.app.di.modules.data import com.github.nullptroma.wallenc.app.di.modules.app.IoDispatcher import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao -import com.github.nullptroma.wallenc.domain.vault.db.app.repository.StorageKeyMapRepository -import com.github.nullptroma.wallenc.domain.vault.db.app.repository.StorageSyncGroupRepository -import com.github.nullptroma.wallenc.domain.vault.db.app.repository.YandexAccountRepository +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.StorageKeyMapRepository +import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.StorageSyncGroupRepository +import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.YandexAccountRepository import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore import dagger.Module diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt index 17e6ec7..01703d3 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/di/modules/data/RoomModule.kt @@ -1,12 +1,12 @@ package com.github.nullptroma.wallenc.app.di.modules.data import android.content.Context -import com.github.nullptroma.wallenc.domain.vault.db.RoomFactory -import com.github.nullptroma.wallenc.domain.vault.db.app.IAppDb -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao +import com.github.nullptroma.wallenc.infrastructure.android.db.RoomFactory +import com.github.nullptroma.wallenc.infrastructure.android.db.app.IAppDb +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/res/values-ru/plurals.xml b/app/src/main/res/values-ru/plurals.xml new file mode 100644 index 0000000..3fc3ceb --- /dev/null +++ b/app/src/main/res/values-ru/plurals.xml @@ -0,0 +1,15 @@ + + + + Выполняется %d задача + Выполняется %d задачи + Выполняется %d задач + Выполняется %d задач + + + ещё %d + ещё %d + ещё %d + ещё %d + + diff --git a/app/src/test/java/com/github/nullptroma/wallenc/app/ExampleUnitTest.kt b/app/src/test/java/com/github/nullptroma/wallenc/app/ExampleUnitTest.kt deleted file mode 100644 index 5aba99d..0000000 --- a/app/src/test/java/com/github/nullptroma/wallenc/app/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.nullptroma.wallenc.app - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/domain-vault/build.gradle.kts b/domain-vault/build.gradle.kts index d948e3e..a1f91fa 100644 --- a/domain-vault/build.gradle.kts +++ b/domain-vault/build.gradle.kts @@ -21,6 +21,8 @@ dependencies { implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockwebserver) implementation(project(":domain")) implementation(project(":vault-contracts")) diff --git a/domain-vault/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/ExampleInstrumentedTest.kt b/domain-vault/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/ExampleInstrumentedTest.kt deleted file mode 100644 index 22a9d9f..0000000 --- a/domain-vault/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.nullptroma.wallenc.domain.vault - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.github.nullptroma.wallenc.domain.vault.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt index e9f18d6..45c6fa9 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskApiFactory.kt @@ -1,6 +1,7 @@ package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.runBlocking @@ -72,7 +73,30 @@ class YandexDiskApiFactory( } companion object { - private const val BASE_URL = "https://cloud-api.yandex.net/" + const val BASE_URL = "https://cloud-api.yandex.net/" private const val OAUTH_TOKEN_CACHE_TTL_MS = 120_000L + + fun createRepositoryWithToken( + oauthToken: String, + ioDispatcher: CoroutineDispatcher, + ): YandexDiskRepository { + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + chain.proceed( + chain.request().newBuilder() + .header("Authorization", "OAuth $oauthToken") + .header("Accept", "application/json") + .build(), + ) + } + .build() + val api = Retrofit.Builder() + .baseUrl(BASE_URL) + .client(client) + .addConverterFactory(JacksonConverterFactory.create(jacksonObjectMapper().findAndRegisterModules())) + .build() + .create(YandexDiskApi::class.java) + return YandexDiskRepository(api, OkHttpClient(), ioDispatcher) + } } } diff --git a/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt new file mode 100644 index 0000000..5b8fbfd --- /dev/null +++ b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/errors/VaultThrowableMappingTest.kt @@ -0,0 +1,47 @@ +package com.github.nullptroma.wallenc.domain.vault.errors + +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import okhttp3.ResponseBody.Companion.toResponseBody +import retrofit2.HttpException +import retrofit2.Response +import java.io.FileNotFoundException +import java.io.IOException + +class VaultThrowableMappingTest { + + @Test + fun mapsYandexDiskAuthToAuthFailed() { + val mapped = YandexDiskAuthException("unauthorized").toVaultWallencException() + assertEquals(WallencException.Auth.Failed, mapped) + } + + @Test + fun mapsHttpExceptionToNetworkHttpFailed() { + val response = Response.error(401, "".toResponseBody(null)) + val mapped = HttpException(response).toVaultWallencException() + assertTrue(mapped is WallencException.Network.HttpFailed) + assertEquals(401, (mapped as WallencException.Network.HttpFailed).statusCode) + } + + @Test + fun mapsMissingOAuthTokenIoToTokenMissing() { + val mapped = IOException("Yandex OAuth token is missing").toVaultWallencException() + assertEquals(WallencException.Auth.TokenMissing, mapped) + } + + @Test + fun mapsFileNotFoundToStorageFileNotFound() { + val mapped = FileNotFoundException("x").toVaultWallencException() + assertEquals(WallencException.Storage.FileNotFound, mapped) + } + + @Test + fun mapsIllegalStateNotAFile() { + val mapped = IllegalStateException("Not a file").toVaultWallencException() + assertEquals(WallencException.Storage.NotAFile, mapped) + } +} diff --git a/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskRepositoryTestFactory.kt b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskRepositoryTestFactory.kt new file mode 100644 index 0000000..8c6ccdf --- /dev/null +++ b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/YandexDiskRepositoryTestFactory.kt @@ -0,0 +1,48 @@ +package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.jackson.JacksonConverterFactory +import java.io.IOException + +object YandexDiskRepositoryTestFactory { + + private val jackson = jacksonObjectMapper().findAndRegisterModules() + + fun create( + baseUrl: String, + oauthToken: String, + ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + rawHttp: OkHttpClient = OkHttpClient(), + ): YandexDiskRepository { + val api = createApi(baseUrl) { oauthToken } + return YandexDiskRepository(api, rawHttp, ioDispatcher) + } + + fun createApi( + baseUrl: String, + tokenProvider: () -> String?, + ): YandexDiskApi { + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + val token = tokenProvider() + ?: throw IOException("Yandex OAuth token is missing") + chain.proceed( + chain.request().newBuilder() + .header("Authorization", "OAuth $token") + .build(), + ) + } + .build() + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(JacksonConverterFactory.create(jackson)) + .build() + .create(YandexDiskApi::class.java) + } +} diff --git a/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/repository/YandexDiskRepositoryTest.kt b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/repository/YandexDiskRepositoryTest.kt new file mode 100644 index 0000000..d86cce9 --- /dev/null +++ b/domain-vault/src/test/java/com/github/nullptroma/wallenc/domain/vault/network/yandexdisk/repository/YandexDiskRepositoryTest.kt @@ -0,0 +1,66 @@ +package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository + +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskAuthException +import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskRepositoryTestFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class YandexDiskRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var repository: YandexDiskRepository + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val api = YandexDiskRepositoryTestFactory.createApi(server.url("/").toString()) { "test-token" } + repository = YandexDiskRepository(api, OkHttpClient(), Dispatchers.IO) + } + + @After + fun tearDown() { + server.shutdown() + } + + @Test + fun diskInfoParsesResponse() = runBlocking { + server.enqueue( + MockResponse() + .setResponseCode(200) + .setBody("""{"total_space":1000,"used_space":200,"trash_size":0}""") + .addHeader("Content-Type", "application/json"), + ) + val info = repository.diskInfo() + assertEquals(1000L, info.totalSpace) + assertEquals(200L, info.usedSpace) + } + + @Test + fun listReturnsEmptyEmbeddedOn404() = runBlocking { + repeat(2) { + server.enqueue(MockResponse().setResponseCode(404)) + } + val result = repository.list("disk:/missing", limit = 10, offset = 0) + assertTrue(result.embedded?.items?.isEmpty() == true) + } + + @Test + fun diskInfoThrowsAuthExceptionOn401() = runBlocking { + server.enqueue(MockResponse().setResponseCode(401)) + try { + repository.diskInfo() + error("Expected YandexDiskAuthException") + } catch (_: YandexDiskAuthException) { + // expected + } + } +} diff --git a/domain-vault/src/test/java/com/github/nullptroma/wallenc/infrastructure/ExampleUnitTest.kt b/domain-vault/src/test/java/com/github/nullptroma/wallenc/infrastructure/ExampleUnitTest.kt deleted file mode 100644 index 408fe7b..0000000 --- a/domain-vault/src/test/java/com/github/nullptroma/wallenc/infrastructure/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.nullptroma.wallenc.domain.vault - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 7cb1c80..3ab50cd 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -16,4 +16,5 @@ kotlin { dependencies { implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 595a83c..247ebfe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,6 +5,7 @@ kotlin = "2.3.21" coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" +androidxTestCore = "1.7.0" espressoCore = "3.7.0" kotlinReflect = "2.3.21" kotlinxCoroutinesCore = "1.11.0" @@ -27,13 +28,24 @@ cameraX = "1.6.1" mlkitBarcode = "17.3.0" javaOtp = "0.4.0" appcompat = "1.7.1" -datastore = "1.2.0" +datastore = "1.2.1" +mockk = "1.14.9" +robolectric = "4.16.1" +androidxArchCore = "2.2.0" +turbine = "1.2.1" [libraries] jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jacksonModuleKotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlinReflect" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesCore" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } +mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } +androidx-arch-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "androidxArchCore" } +turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } @@ -66,6 +78,8 @@ androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestCore" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } diff --git a/infrastructure-android/build.gradle.kts b/infrastructure-android/build.gradle.kts index a215eb9..7f9e62b 100644 --- a/infrastructure-android/build.gradle.kts +++ b/infrastructure-android/build.gradle.kts @@ -9,6 +9,7 @@ android { defaultConfig { minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") } @@ -26,7 +27,6 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - } dependencies { @@ -44,4 +44,8 @@ dependencies { implementation(libs.androidx.core.ktx) testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.room.testing) } diff --git a/infrastructure-android/consumer-rules.pro b/infrastructure-android/consumer-rules.pro new file mode 100644 index 0000000..2099e4f --- /dev/null +++ b/infrastructure-android/consumer-rules.pro @@ -0,0 +1 @@ +# Consumer ProGuard rules for infrastructure-android (empty). diff --git a/infrastructure-android/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepositoryTest.kt b/infrastructure-android/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepositoryTest.kt new file mode 100644 index 0000000..ac62d03 --- /dev/null +++ b/infrastructure-android/src/androidTest/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepositoryTest.kt @@ -0,0 +1,80 @@ +package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.nullptroma.wallenc.domain.vault.model.YandexAccount +import com.github.nullptroma.wallenc.infrastructure.android.db.app.AppDb +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class YandexAccountRepositoryTest { + + private lateinit var db: AppDb + private lateinit var repository: YandexAccountRepository + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, AppDb::class.java).build() + repository = YandexAccountRepository( + dao = db.yandexAccountDao, + ioDispatcher = Dispatchers.IO, + ) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun insertAndLoadByVaultUuid() = runBlocking { + val account = YandexAccount( + vaultUuid = "vault-1", + yandexUserId = "user-1", + email = "test@yandex.ru", + oauthToken = "token-abc", + ) + repository.insert(account) + val loaded = repository.getByVaultUuid("vault-1") + assertEquals(account, loaded) + } + + @Test + fun updateCredentialsChangesToken() = runBlocking { + repository.insert( + YandexAccount( + vaultUuid = "vault-2", + yandexUserId = "user-2", + email = "old@yandex.ru", + oauthToken = "old-token", + ), + ) + repository.updateCredentials("vault-2", "new@yandex.ru", "new-token") + val loaded = repository.getByVaultUuid("vault-2") + assertEquals("new@yandex.ru", loaded?.email) + assertEquals("new-token", loaded?.oauthToken) + } + + @Test + fun deleteByVaultUuidRemovesRow() = runBlocking { + repository.insert( + YandexAccount( + vaultUuid = "vault-3", + yandexUserId = "user-3", + email = "x@yandex.ru", + oauthToken = "t", + ), + ) + repository.deleteByVaultUuid("vault-3") + assertNull(repository.getByVaultUuid("vault-3")) + } +} diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/RoomFactory.kt similarity index 70% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/RoomFactory.kt index 2274bdd..63b3eee 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/RoomFactory.kt @@ -1,8 +1,8 @@ -package com.github.nullptroma.wallenc.domain.vault.db +package com.github.nullptroma.wallenc.infrastructure.android.db import android.content.Context import androidx.room.Room -import com.github.nullptroma.wallenc.domain.vault.db.app.AppDb +import com.github.nullptroma.wallenc.infrastructure.android.db.app.AppDb class RoomFactory(private val context: Context) { fun buildAppDb(): AppDb { diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/AppDb.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/AppDb.kt new file mode 100644 index 0000000..fd9c22c --- /dev/null +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/AppDb.kt @@ -0,0 +1,31 @@ +package com.github.nullptroma.wallenc.infrastructure.android.db.app + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageKeyMap +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbYandexAccount + +interface IAppDb { + val storageKeyMapDao: StorageKeyMapDao + val storageMetaInfoDao: StorageMetaInfoDao + val storageSyncGroupDao: StorageSyncGroupDao + val yandexAccountDao: YandexAccountDao +} + +@Database( + entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class, DbStorageSyncGroup::class], + version = 5, + exportSchema = false +) +abstract class AppDb : IAppDb, RoomDatabase() { + abstract override val storageKeyMapDao: StorageKeyMapDao + abstract override val storageMetaInfoDao: StorageMetaInfoDao + abstract override val storageSyncGroupDao: StorageSyncGroupDao + abstract override val yandexAccountDao: YandexAccountDao +} diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageKeyMapDao.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageKeyMapDao.kt similarity index 73% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageKeyMapDao.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageKeyMapDao.kt index fc523cc..40cc57c 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageKeyMapDao.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageKeyMapDao.kt @@ -1,11 +1,11 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.dao +package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageKeyMap @Dao interface StorageKeyMapDao { diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageMetaInfoDao.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageMetaInfoDao.kt similarity index 85% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageMetaInfoDao.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageMetaInfoDao.kt index 7332d45..05e9e44 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageMetaInfoDao.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageMetaInfoDao.kt @@ -1,11 +1,11 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.dao +package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo import kotlinx.coroutines.flow.Flow import java.util.UUID diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageSyncGroupDao.kt similarity index 74% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageSyncGroupDao.kt index 1f22e3f..e86d02d 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/StorageSyncGroupDao.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/StorageSyncGroupDao.kt @@ -1,10 +1,10 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.dao +package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup @Dao interface StorageSyncGroupDao { diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/YandexAccountDao.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/YandexAccountDao.kt similarity index 85% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/YandexAccountDao.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/YandexAccountDao.kt index 2ddc62f..7a6cd2c 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/dao/YandexAccountDao.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/dao/YandexAccountDao.kt @@ -1,9 +1,9 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.dao +package com.github.nullptroma.wallenc.infrastructure.android.db.app.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.Query -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbYandexAccount import kotlinx.coroutines.flow.Flow @Dao diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageKeyMap.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageKeyMap.kt similarity index 91% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageKeyMap.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageKeyMap.kt index e7dfb6f..e0c06d0 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageKeyMap.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageKeyMap.kt @@ -1,14 +1,15 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.model +package com.github.nullptroma.wallenc.infrastructure.android.db.app.model import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.PrimaryKey import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import java.util.UUID @Entity(tableName = "storage_key_maps") data class DbStorageKeyMap( - @androidx.room.PrimaryKey + @PrimaryKey @ColumnInfo(name = "source_uuid") val sourceUuid: UUID, @ColumnInfo(name = "key") val key: ByteArray ) { diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageMetaInfo.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageMetaInfo.kt similarity index 80% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageMetaInfo.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageMetaInfo.kt index 7674394..dcbcd85 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageMetaInfo.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageMetaInfo.kt @@ -1,4 +1,4 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.model +package com.github.nullptroma.wallenc.infrastructure.android.db.app.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageSyncGroup.kt similarity index 85% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageSyncGroup.kt index 1c6ad93..1f29d82 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbStorageSyncGroup.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbStorageSyncGroup.kt @@ -1,4 +1,4 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.model +package com.github.nullptroma.wallenc.infrastructure.android.db.app.model import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbYandexAccount.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbYandexAccount.kt similarity index 82% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbYandexAccount.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbYandexAccount.kt index ec5cc25..12ddbac 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/model/DbYandexAccount.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/model/DbYandexAccount.kt @@ -1,4 +1,4 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.model +package com.github.nullptroma.wallenc.infrastructure.android.db.app.model import androidx.room.Entity import androidx.room.Index diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageKeyMapRepository.kt similarity index 80% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageKeyMapRepository.kt index d647315..1b9e3a1 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageKeyMapRepository.kt @@ -1,7 +1,7 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.repository +package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageKeyMap import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore import kotlinx.coroutines.CoroutineDispatcher diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageMetaInfoRepository.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageMetaInfoRepository.kt similarity index 88% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageMetaInfoRepository.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageMetaInfoRepository.kt index 11a0093..87af41e 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageMetaInfoRepository.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageMetaInfoRepository.kt @@ -1,8 +1,8 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.repository +package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageMetaInfo import com.github.nullptroma.wallenc.domain.vault.utils.IProvider import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo import kotlinx.coroutines.CoroutineDispatcher diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageSyncGroupRepository.kt similarity index 92% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageSyncGroupRepository.kt index 22490c4..322aafc 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageSyncGroupRepository.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/StorageSyncGroupRepository.kt @@ -1,10 +1,10 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.repository +package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup import com.github.nullptroma.wallenc.domain.vault.utils.IProvider import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/YandexAccountRepository.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepository.kt similarity index 87% rename from infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/YandexAccountRepository.kt rename to infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepository.kt index 4f969da..8aa7075 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/YandexAccountRepository.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/android/db/app/repository/YandexAccountRepository.kt @@ -1,7 +1,7 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app.repository +package com.github.nullptroma.wallenc.infrastructure.android.db.app.repository -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount +import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao +import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbYandexAccount import com.github.nullptroma.wallenc.domain.vault.model.YandexAccount import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore import kotlinx.coroutines.CoroutineDispatcher diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt deleted file mode 100644 index 5d3b6d3..0000000 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/AppDb.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.github.nullptroma.wallenc.domain.vault.db.app - -import androidx.room.Database -import androidx.room.RoomDatabase -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao -import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup -import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount - -interface IAppDb { - val storageKeyMapDao: StorageKeyMapDao - val storageMetaInfoDao: StorageMetaInfoDao - val storageSyncGroupDao: StorageSyncGroupDao - val yandexAccountDao: YandexAccountDao -} - -@Database( - entities = [DbStorageKeyMap::class, DbStorageMetaInfo::class, DbYandexAccount::class, DbStorageSyncGroup::class], - version = 5, - exportSchema = false -) -abstract class AppDb : IAppDb, RoomDatabase() { - abstract override val storageKeyMapDao: StorageKeyMapDao - abstract override val storageMetaInfoDao: StorageMetaInfoDao - abstract override val storageSyncGroupDao: StorageSyncGroupDao - abstract override val yandexAccountDao: YandexAccountDao -} diff --git a/task-runtime/build.gradle.kts b/task-runtime/build.gradle.kts index 07c4e1f..7fb46d6 100644 --- a/task-runtime/build.gradle.kts +++ b/task-runtime/build.gradle.kts @@ -17,4 +17,5 @@ dependencies { implementation(project(":domain")) implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/task-runtime/src/test/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestratorTest.kt b/task-runtime/src/test/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestratorTest.kt new file mode 100644 index 0000000..f41f87c --- /dev/null +++ b/task-runtime/src/test/java/com/github/nullptroma/wallenc/task/runtime/TaskOrchestratorTest.kt @@ -0,0 +1,99 @@ +package com.github.nullptroma.wallenc.task.runtime + +import com.github.nullptroma.wallenc.domain.errors.WallencException +import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel +import com.github.nullptroma.wallenc.domain.tasks.TaskProgress +import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel +import com.github.nullptroma.wallenc.domain.tasks.TaskRunState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TaskOrchestratorTest { + + private val dispatcher = StandardTestDispatcher() + + @Test + fun enqueueCompletesTask() = runTest(dispatcher) { + val orchestrator = TaskOrchestrator(dispatcher) + val id = orchestrator.enqueue( + title = "Test", + dispatcher = dispatcher, + work = { ctx -> + ctx.reportProgress(0.5f, TaskProgressLabel.SyncPreparing(1)) + }, + ) + advanceUntilIdle() + val task = orchestrator.pipelineState.value.tasks.first { it.id == id } + assertTrue(task.state is TaskRunState.Completed) + } + + @Test + fun cancelMarksTaskCancelled() = runTest(dispatcher) { + val orchestrator = TaskOrchestrator(dispatcher) + val id = orchestrator.enqueue( + title = "Long", + dispatcher = dispatcher, + work = { ctx -> + ctx.reportProgress(null, null) + kotlinx.coroutines.delay(60_000) + }, + ) + advanceTimeBy(1) + orchestrator.cancel(id) + advanceUntilIdle() + val task = orchestrator.pipelineState.value.tasks.first { it.id == id } + assertTrue(task.state is TaskRunState.Cancelled) + } + + @Test + fun failRecordsFailedState() = runTest(dispatcher) { + val orchestrator = TaskOrchestrator(dispatcher) + val id = orchestrator.enqueue( + title = "Fail", + dispatcher = dispatcher, + work = { ctx -> + ctx.fail(WallencException.Storage.FileNotFound) + }, + ) + advanceUntilIdle() + val task = orchestrator.pipelineState.value.tasks.first { it.id == id } + val failed = task.state as TaskRunState.Failed + assertEquals(WallencException.Storage.FileNotFound, failed.error) + } + + @Test + fun progressUpdatesRunningState() = runTest(dispatcher) { + val orchestrator = TaskOrchestrator(dispatcher) + val id = orchestrator.enqueue( + title = "Progress", + dispatcher = dispatcher, + work = { ctx -> + ctx.reportProgress(0.25f, TaskProgressLabel.SyncCompleted) + }, + ) + advanceUntilIdle() + val task = orchestrator.pipelineState.value.tasks.first { it.id == id } + assertTrue(task.state is TaskRunState.Completed) + } + + @Test + fun logAppendsLine() = runTest(dispatcher) { + val orchestrator = TaskOrchestrator(dispatcher) + orchestrator.enqueue( + title = "Log", + dispatcher = dispatcher, + work = { ctx -> + ctx.log(TaskLogLevel.Info, "hello") + }, + ) + advanceUntilIdle() + assertTrue(orchestrator.logLines.value.any { it.message == "hello" }) + } +} diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index dfd229b..ed978bf 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -36,6 +36,10 @@ android { compose = true } + testOptions { + unitTests.isIncludeAndroidResources = true + } + packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -79,6 +83,10 @@ dependencies { implementation(libs.mlkit.barcode.scanning) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.arch.core.testing) + testImplementation(libs.robolectric) + testImplementation(libs.mockk) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) @@ -87,4 +95,6 @@ dependencies { implementation(project(":domain")) implementation(project(":usecases")) implementation(project(":vault-contracts")) + + testImplementation(project(":task-runtime")) } \ No newline at end of file diff --git a/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/ExampleInstrumentedTest.kt b/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/ExampleInstrumentedTest.kt deleted file mode 100644 index 5f1872e..0000000 --- a/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.nullptroma.wallenc.ui - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.github.nullptroma.wallenc.ui.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContentTest.kt b/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContentTest.kt new file mode 100644 index 0000000..c44f380 --- /dev/null +++ b/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContentTest.kt @@ -0,0 +1,66 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord +import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord +import com.github.nullptroma.wallenc.ui.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TextSecretsScreenContentTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun showsEmptyStateWhenNoSecrets() { + composeRule.setContent { + MaterialTheme { + TextSecretsScreenContent( + uiState = TextSecretsScreenState( + isLoading = false, + isAvailable = true, + items = emptyList(), + ), + ) + } + } + composeRule.onNodeWithText(context.getString(R.string.text_secret_empty_state)).assertIsDisplayed() + } + + @Test + fun showsSecretTitle() { + val secret = TextSecretRecord( + id = "s1", + title = "Production DB", + items = listOf(TextSecretEntryRecord(label = "user", value = "admin")), + ) + composeRule.setContent { + MaterialTheme { + TextSecretsScreenContent( + uiState = TextSecretsScreenState( + isLoading = false, + isAvailable = true, + items = listOf(secret), + ), + ) { + TextSecretListCard( + secret = secret, + onClick = {}, + enabled = true, + ) + } + } + } + composeRule.onNodeWithText("Production DB").assertIsDisplayed() + } +} diff --git a/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContentTest.kt b/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContentTest.kt new file mode 100644 index 0000000..ac2837e --- /dev/null +++ b/ui/src/androidTest/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContentTest.kt @@ -0,0 +1,70 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord +import com.github.nullptroma.wallenc.ui.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class TwoFaTokensScreenContentTest { + + @get:Rule + val composeRule = createComposeRule() + + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Test + fun showsEmptyStateWhenNoTokens() { + composeRule.setContent { + MaterialTheme { + TwoFaTokensScreenContent( + uiState = TwoFaTokensScreenState( + isLoading = false, + isAvailable = true, + items = emptyList(), + ), + ) + } + } + composeRule.onNodeWithText(context.getString(R.string.two_fa_empty_state)).assertIsDisplayed() + } + + @Test + fun showsIssuerAndAccountForToken() { + val token = TwoFaTokenRecord( + id = "1", + issuer = "GitHub", + account = "user@example.com", + secret = "SECRET", + ) + composeRule.setContent { + MaterialTheme { + TwoFaTokensScreenContent( + uiState = TwoFaTokensScreenState( + isLoading = false, + isAvailable = true, + items = listOf(token), + ), + ) { + TwoFaTokenListHeader( + issuer = token.issuer, + account = token.account, + modifier = Modifier.padding(8.dp), + ) + } + } + } + composeRule.onNodeWithText("GitHub").assertIsDisplayed() + composeRule.onNodeWithText("user@example.com").assertIsDisplayed() + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt index 4ba02af..1953575 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreen.kt @@ -1,28 +1,16 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.outlined.Notes import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.outlined.Notes -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -33,7 +21,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.ui.R -import com.github.nullptroma.wallenc.ui.resources.resolveText @Composable fun TextSecretsScreen( @@ -60,99 +47,19 @@ fun TextSecretsScreen( } }, ) { innerPadding -> - Column( + TextSecretsScreenContent( + uiState = uiState, modifier = Modifier .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(innerPadding), ) { - uiState.errorNotification?.let { notification -> - Text( - text = notification.resolveText(), - color = MaterialTheme.colorScheme.error, - ) - } - - if (uiState.items.isEmpty()) { - Text(stringResource(R.string.text_secret_empty_state)) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { - items(uiState.items) { secret -> - Card( - modifier = Modifier.fillMaxWidth(), - onClick = { onOpenSecret(secret) }, - enabled = uiState.isAvailable, - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.Notes, - contentDescription = null, - modifier = Modifier - .size(22.dp) - .padding(top = 2.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Column( - modifier = Modifier.padding(end = 12.dp), - ) { - Text( - text = secret.title, - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = stringResource(R.string.text_secret_items_count, secret.items.size), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - val fieldNames = secret.items - .map { item -> - item.label - ?.trim() - .takeUnless { it.isNullOrBlank() } - ?: stringResource(R.string.text_secret_item_without_label) - } - if (fieldNames.isNotEmpty()) { - fieldNames.take(3).forEach { fieldName -> - Text( - text = "\u2022 $fieldName", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - val hiddenCount = fieldNames.size - 3 - if (hiddenCount > 0) { - Text( - text = stringResource( - R.string.text_secret_more_fields, - hiddenCount, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - ) - } - } - } + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(uiState.items) { secret -> + TextSecretListCard( + secret = secret, + onClick = { onOpenSecret(secret) }, + enabled = uiState.isAvailable, + ) } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContent.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContent.kt new file mode 100644 index 0000000..cb84dd1 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/secrets/TextSecretsScreenContent.kt @@ -0,0 +1,133 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.outlined.Notes +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord +import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.resolveText + +@Composable +fun TextSecretsScreenContent( + uiState: TextSecretsScreenState, + modifier: Modifier = Modifier, + secretList: @Composable () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + uiState.errorNotification?.let { notification -> + Text( + text = notification.resolveText(), + color = MaterialTheme.colorScheme.error, + ) + } + + if (uiState.items.isEmpty()) { + Text(stringResource(R.string.text_secret_empty_state)) + } else { + secretList() + } + } +} + +@Composable +fun TextSecretListCard( + secret: TextSecretRecord, + onClick: () -> Unit, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Notes, + contentDescription = null, + modifier = Modifier + .size(22.dp) + .padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column(modifier = Modifier.padding(end = 12.dp)) { + Text( + text = secret.title, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = stringResource(R.string.text_secret_items_count, secret.items.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val fieldNames = secret.items + .map { item -> + item.label + ?.trim() + .takeUnless { it.isNullOrBlank() } + ?: stringResource(R.string.text_secret_item_without_label) + } + if (fieldNames.isNotEmpty()) { + fieldNames.take(3).forEach { fieldName -> + Text( + text = "\u2022 $fieldName", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val hiddenCount = fieldNames.size - 3 + if (hiddenCount > 0) { + Text( + text = stringResource( + R.string.text_secret_more_fields, + hiddenCount, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + ) + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 6964eda..4e8ad65 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -122,30 +122,14 @@ fun TwoFaTokensScreen( } }, ) { innerPadding -> - Column( + TwoFaTokensScreenContent( + uiState = uiState, modifier = Modifier .fillMaxSize() - .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + .padding(innerPadding), ) { - if (uiState.isLoading) { - CircularProgressIndicator() - return@Column - } - - uiState.errorNotification?.let { notification -> - Text( - text = notification.resolveText(), - color = MaterialTheme.colorScheme.error, - ) - } - - if (uiState.items.isEmpty()) { - Text(stringResource(R.string.two_fa_empty_state)) - } else { - LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { - items(uiState.items) { item -> + LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { + items(uiState.items) { item -> Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.elevatedCardColors( @@ -159,30 +143,11 @@ fun TwoFaTokensScreen( .padding(14.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { - Row( + TwoFaTokenListHeader( + issuer = item.issuer, + account = item.account, modifier = Modifier.weight(1f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Outlined.Lock, - contentDescription = null, - modifier = Modifier - .size(22.dp) - .padding(top = 2.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Column { - Text( - text = item.issuer, - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = item.account, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + ) Row { IconButton( onClick = { editingToken = item }, @@ -282,7 +247,6 @@ fun TwoFaTokensScreen( } } } - } } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContent.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContent.kt new file mode 100644 index 0000000..0028768 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreenContent.kt @@ -0,0 +1,86 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.resources.resolveText + +@Composable +fun TwoFaTokensScreenContent( + uiState: TwoFaTokensScreenState, + modifier: Modifier = Modifier, + tokenList: @Composable () -> Unit = {}, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (uiState.isLoading) { + CircularProgressIndicator() + return@Column + } + + uiState.errorNotification?.let { notification -> + Text( + text = notification.resolveText(), + color = MaterialTheme.colorScheme.error, + ) + } + + if (uiState.items.isEmpty()) { + Text(stringResource(R.string.two_fa_empty_state)) + } else { + tokenList() + } + } +} + +@Composable +fun TwoFaTokenListHeader( + issuer: String, + account: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = Icons.Outlined.Lock, + contentDescription = null, + modifier = Modifier + .size(22.dp) + .padding(top = 2.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Column { + Text( + text = issuer, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = account, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/ui/src/test/java/com/github/nullptroma/wallenc/ui/ExampleUnitTest.kt b/ui/src/test/java/com/github/nullptroma/wallenc/ui/ExampleUnitTest.kt deleted file mode 100644 index 86f7552..0000000 --- a/ui/src/test/java/com/github/nullptroma/wallenc/ui/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.nullptroma.wallenc.ui - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/ui/src/test/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinksTest.kt b/ui/src/test/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinksTest.kt new file mode 100644 index 0000000..4dc905a --- /dev/null +++ b/ui/src/test/java/com/github/nullptroma/wallenc/ui/navigation/WallencDeepLinksTest.kt @@ -0,0 +1,39 @@ +package com.github.nullptroma.wallenc.ui.navigation + +import android.content.Intent +import android.net.Uri +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class WallencDeepLinksTest { + + @Test + fun matchesWallencViewIntent() { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.MAIN_URI_PATTERN)) + assertTrue(intent.matchesWallencDeepLink()) + } + + @Test + fun rejectsUnrelatedIntent() { + val intent = Intent(Intent.ACTION_MAIN) + assertFalse(intent.matchesWallencDeepLink()) + } + + @Test + fun matchesTasksAndSettingsHosts() { + assertTrue( + Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.TASKS_URI_PATTERN)) + .matchesWallencDeepLink(), + ) + assertTrue( + Intent(Intent.ACTION_VIEW, Uri.parse(WallencDeepLinks.SETTINGS_URI_PATTERN)) + .matchesWallencDeepLink(), + ) + } +} diff --git a/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParserTest.kt b/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParserTest.kt new file mode 100644 index 0000000..d44fc47 --- /dev/null +++ b/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParserTest.kt @@ -0,0 +1,37 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class OtpAuthUriParserTest { + + @Test + fun parsesStandardTotpUri() { + val uri = "otpauth://totp/GitHub:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&digits=6&period=30" + val parsed = parseOtpAuthTotpUri(uri) + assertNotNull(parsed) + assertEquals("GitHub", parsed!!.issuer) + assertEquals("user@example.com", parsed.account) + assertEquals("JBSWY3DPEHPK3PXP", parsed.secret) + assertEquals(6, parsed.digits) + assertEquals(30, parsed.periodSeconds) + assertEquals("SHA1", parsed.algorithm) + } + + @Test + fun rejectsNonOtpauthScheme() { + assertNull(parseOtpAuthTotpUri("https://example.com")) + } + + @Test + fun rejectsMissingSecret() { + assertNull(parseOtpAuthTotpUri("otpauth://totp/Test:account")) + } +} diff --git a/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModelTest.kt b/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModelTest.kt new file mode 100644 index 0000000..a7a01d3 --- /dev/null +++ b/ui/src/test/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineViewModelTest.kt @@ -0,0 +1,42 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.tasks + +import com.github.nullptroma.wallenc.domain.tasks.TaskRunState +import com.github.nullptroma.wallenc.task.runtime.TaskOrchestrator +import com.github.nullptroma.wallenc.ui.resources.UiStringResolver +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class TaskPipelineViewModelTest { + + @Test + fun startTestTaskEnqueuesWork() = runBlocking { + val orchestrator = TaskOrchestrator(Dispatchers.Default) + val uiStrings = mockk() + every { uiStrings.invoke(any(), any()) } returns "Test task" + every { uiStrings.invoke(any()) } returns "Test" + val viewModel = TaskPipelineViewModel(orchestrator, uiStrings) + + viewModel.startTestTask(durationSec = 0, infinityIndeterminateProgress = false) + + withTimeout(5_000) { + while (true) { + val task = orchestrator.pipelineState.value.tasks.singleOrNull() + if (task?.state is TaskRunState.Completed) break + delay(25) + } + } + val task = orchestrator.pipelineState.value.tasks.single() + assertTrue(task.state is TaskRunState.Completed) + } +} diff --git a/usecases/build.gradle.kts b/usecases/build.gradle.kts index c30d388..777db40 100644 --- a/usecases/build.gradle.kts +++ b/usecases/build.gradle.kts @@ -20,4 +20,5 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(libs.java.otp) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) } diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt index ef8578d..c4a098a 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt @@ -1,42 +1,19 @@ package com.github.nullptroma.wallenc.usecases -import com.github.nullptroma.wallenc.domain.datatypes.DataPage -import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord -import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry -import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock -import com.github.nullptroma.wallenc.domain.interfaces.IDirectory -import com.github.nullptroma.wallenc.domain.interfaces.IFile -import com.github.nullptroma.wallenc.domain.interfaces.IStorage -import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor -import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo -import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo -import com.github.nullptroma.wallenc.domain.tasks.TaskProgress -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.emptyFlow +import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.io.InputStream -import java.io.OutputStream -import java.time.Instant -import java.util.UUID class StorageDomainUseCasesTest { @Test - fun twoFaCrudWorksAndPersists() = runBlocking { + fun twoFaCrudWorksAndPersists() = runTest { val storage = FakeStorage() val useCase = ManageTwoFaTokensUseCase() @@ -63,7 +40,7 @@ class StorageDomainUseCasesTest { } @Test - fun twoFaInvalidJsonFallsBackToEmptyList() = runBlocking { + fun twoFaInvalidJsonFallsBackToEmptyList() = runTest { val storage = FakeStorage().apply { setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json") } @@ -72,7 +49,7 @@ class StorageDomainUseCasesTest { } @Test - fun textSecretsCrudWorksWithOptionalLabels() = runBlocking { + fun textSecretsCrudWorksWithOptionalLabels() = runTest { val storage = FakeStorage() val useCase = ManageTextSecretsUseCase() @@ -104,7 +81,7 @@ class StorageDomainUseCasesTest { } @Test - fun textSecretsInvalidJsonFallsBackToEmptyList() = runBlocking { + fun textSecretsInvalidJsonFallsBackToEmptyList() = runTest { val storage = FakeStorage().apply { setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken") } @@ -112,131 +89,3 @@ class StorageDomainUseCasesTest { assertTrue(useCase.observe(storage).first().isEmpty()) } } - -private class FakeStorage : IStorage { - private val accessorImpl = FakeStorageAccessor() - - override val uuid: UUID = UUID.randomUUID() - override val isAvailable: StateFlow = MutableStateFlow(true) - override val size: StateFlow = MutableStateFlow(0L) - override val numberOfFiles: StateFlow = MutableStateFlow(0) - override val isEmpty: Flow = flowOf(true) - override val metaInfo: StateFlow = MutableStateFlow(FakeMetaInfo()) - override val isVirtualStorage: Boolean = false - override val accessor: IStorageAccessor = accessorImpl - - override suspend fun rename(newName: String) = Unit - - override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = Unit - - override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = Unit - - fun setDomainFile(path: String, value: String) { - accessorImpl.dataFiles[path] = value.encodeToByteArray() - } -} - -private class FakeMetaInfo : IStorageMetaInfo { - override val encInfo: StorageEncryptionInfo? = null - override val name: String = "Fake" - override val lastModified: Instant = Instant.now() -} - -private class FakeStorageAccessor : IStorageAccessor { - val dataFiles: MutableMap = mutableMapOf() - private val systemFiles: MutableMap = mutableMapOf() - private val _filesUpdates = MutableSharedFlow>(extraBufferCapacity = 16) - - override val size: StateFlow = MutableStateFlow(0L) - override val numberOfFiles: StateFlow = MutableStateFlow(0) - override val isAvailable: StateFlow = MutableStateFlow(true) - override val filesUpdates: SharedFlow> = _filesUpdates - override val dirsUpdates: SharedFlow> = MutableSharedFlow() - - override suspend fun getAllFiles(): List = emptyList() - - override suspend fun getFiles(path: String): List = emptyList() - - override fun getFilesFlow(path: String): Flow> = emptyFlow() - - override suspend fun getAllDirs(): List = emptyList() - - override suspend fun getDirs(path: String): List = emptyList() - - override fun getDirsFlow(path: String): Flow> = emptyFlow() - - override suspend fun getFileInfo(path: String): IFile { - error("Not implemented in tests") - } - - override suspend fun getDirInfo(path: String): IDirectory { - error("Not implemented in tests") - } - - override suspend fun setHidden(path: String, hidden: Boolean) = Unit - - override suspend fun touchFile(path: String) = Unit - - override suspend fun touchDir(path: String) = Unit - - override suspend fun delete(path: String) = Unit - - override suspend fun openWrite(path: String): OutputStream { - return object : ByteArrayOutputStream() { - override fun close() { - dataFiles[path] = toByteArray() - _filesUpdates.tryEmit( - DataPage( - listOf(FakeFile(path)), - pageLength = 1, - pageIndex = 0, - ), - ) - } - } - } - - override suspend fun openRead(path: String): InputStream { - val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path") - return ByteArrayInputStream(bytes) - } - - override suspend fun moveToTrash(path: String) = Unit - - override suspend fun openReadSystemFile(name: String): InputStream { - val bytes = systemFiles[name] ?: ByteArray(0) - return ByteArrayInputStream(bytes) - } - - override suspend fun openWriteSystemFile(name: String): OutputStream { - return object : ByteArrayOutputStream() { - override fun close() { - systemFiles[name] = toByteArray() - } - } - } - - override suspend fun readSyncJournal(): List = emptyList() - - override suspend fun appendSyncJournal(entries: List) = Unit - - override suspend fun rewriteSyncJournal(entries: List) = Unit - - override suspend fun readSyncLock(): StorageSyncLock? = null - - override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = true - - override suspend fun releaseSyncLock(holderId: String) = Unit - - override suspend fun forceClearSyncLock() = Unit -} - -private class FakeFile(path: String) : IFile { - override val metaInfo: IMetaInfo = object : IMetaInfo { - override val size: Long = 0L - override val isDeleted: Boolean = false - override val isHidden: Boolean = false - override val lastModified: Instant = Instant.now() - override val path: String = path - } -} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt new file mode 100644 index 0000000..046d285 --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageSyncEngineTest.kt @@ -0,0 +1,152 @@ +package com.github.nullptroma.wallenc.usecases + +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncOperation +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncRevision +import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup +import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind +import com.github.nullptroma.wallenc.domain.tasks.TaskProgressLabel +import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage +import com.github.nullptroma.wallenc.usecases.fakes.FakeStorageSyncGroupStore +import com.github.nullptroma.wallenc.usecases.fakes.FakeVaultsManager +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +class StorageSyncEngineTest { + + @Test + fun syncAllGroupsReportsNoGroupsWhenEmpty() = runBlocking { + val labels = mutableListOf() + val engine = createEngine(storages = emptyList(), groups = emptyList()) + engine.syncAllGroups { _, label -> labels.add(label) } + assertTrue(labels.any { it is TaskProgressLabel.SyncNoGroups }) + } + + @Test + fun syncGroupCopiesFileFromSourceToTarget() = runBlocking { + val source = FakeStorage() + val target = FakeStorage() + val path = "shared/doc.txt" + val payload = "sync-payload".encodeToByteArray() + source.putFile(path, payload) + + val entry = StorageSyncJournalEntry( + path = path, + operation = StorageSyncOperation.UPSERT, + revision = StorageSyncRevision( + sequence = 1L, + actorId = "actor-a", + createdAt = Instant.parse("2024-01-01T00:00:00Z"), + ), + size = payload.size.toLong(), + originStorageUuid = source.uuid, + ) + source.addSyncJournalEntry(entry) + + val groupId = "group-1" + val group = StorageSyncGroup( + id = groupId, + storageUuids = setOf(source.uuid, target.uuid), + encryptionKind = StorageSyncGroupEncryptionKind.NONE, + ) + val labels = mutableListOf() + val engine = createEngine( + storages = listOf(source, target), + groups = listOf(group), + ) + engine.syncGroup(groupId) { _, label -> labels.add(label) } + + assertArrayEquals(payload, target.fileBytes(path)) + assertTrue(labels.any { it is TaskProgressLabel.SyncGroupCompleted }) + assertTrue(target.syncJournalEntries().any { it.path == path }) + } + + @Test + fun syncGroupSkippedWhenFewerThanTwoStorages() = runBlocking { + val only = FakeStorage() + val groupId = "solo" + val group = StorageSyncGroup( + id = groupId, + storageUuids = setOf(only.uuid), + encryptionKind = StorageSyncGroupEncryptionKind.NONE, + ) + val labels = mutableListOf() + val engine = createEngine(storages = listOf(only), groups = listOf(group)) + engine.syncGroup(groupId) { _, label -> labels.add(label) } + assertTrue(labels.any { it is TaskProgressLabel.SyncGroupSkippedTooFewStorages }) + } + + @Test + fun syncGroupDeleteRemovesFileOnTarget() = runBlocking { + val source = FakeStorage() + val target = FakeStorage() + val path = "obsolete.bin" + target.putFile(path, "old".encodeToByteArray()) + + val entry = StorageSyncJournalEntry( + path = path, + operation = StorageSyncOperation.DELETE, + revision = StorageSyncRevision( + sequence = 2L, + actorId = "actor-b", + createdAt = Instant.parse("2024-06-01T00:00:00Z"), + ), + ) + source.addSyncJournalEntry(entry) + + val group = StorageSyncGroup( + id = "delete-group", + storageUuids = setOf(source.uuid, target.uuid), + encryptionKind = StorageSyncGroupEncryptionKind.NONE, + ) + val engine = createEngine( + storages = listOf(source, target), + groups = listOf(group), + ) + engine.syncGroup(group.id) { _, _ -> } + + assertNull(target.fileBytes(path)) + } + + @Test + fun syncGroupStopsWhenLockCannotBeAcquired() = runBlocking { + val first = FakeStorage() + val second = FakeStorage().apply { setAcquireLockResult(false) } + val group = StorageSyncGroup( + id = "lock-fail", + storageUuids = setOf(first.uuid, second.uuid), + encryptionKind = StorageSyncGroupEncryptionKind.NONE, + ) + first.addSyncJournalEntry( + StorageSyncJournalEntry( + path = "a.txt", + operation = StorageSyncOperation.UPSERT, + revision = StorageSyncRevision(1L, "x", Instant.EPOCH), + ), + ) + val labels = mutableListOf() + val engine = createEngine( + storages = listOf(first, second), + groups = listOf(group), + ) + engine.syncGroup(group.id) { _, label -> labels.add(label) } + assertTrue(labels.any { it is TaskProgressLabel.SyncGroupLockFailed }) + } + + private fun createEngine( + storages: List, + groups: List, + ): StorageSyncEngine { + val vaultsManager = FakeVaultsManager(storages) + val findStorage = FindStorageUseCase(vaultsManager) + return StorageSyncEngine( + vaultsManager = vaultsManager, + groupStore = FakeStorageSyncGroupStore(groups), + findStorageUseCase = findStorage, + ) + } +} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt new file mode 100644 index 0000000..c027ea9 --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/TwoFaTotpTest.kt @@ -0,0 +1,88 @@ +package com.github.nullptroma.wallenc.usecases + +import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord +import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Duration +import java.time.Instant +import javax.crypto.spec.SecretKeySpec + +class TwoFaTotpTest { + + @Test + fun buildTwoFaCodeStateMatchesJavaOtpForKnownSecret() { + val secretBase32 = "JBSWY3DPEHPK3PXP" + val token = TwoFaTokenRecord( + id = "1", + issuer = "Test", + account = "user", + secret = secretBase32, + digits = 6, + periodSeconds = 30, + algorithm = "SHA1", + notes = null, + ) + val nowMillis = 1_700_000_000_000L + val state = buildTwoFaCodeState(token, nowMillis) + assertNotNull(state) + val expected = generateReferenceTotp(secretBase32, nowMillis, 6, 30, "HmacSHA1") + assertEquals(expected, state!!.code) + assertTrue(state.secondsUntilRefresh in 1..30) + } + + @Test + fun buildTwoFaCodeStateReturnsNullForInvalidSecret() { + val token = TwoFaTokenRecord( + id = "1", + issuer = "Test", + account = "user", + secret = "!!!not-base32!!!", + digits = 6, + periodSeconds = 30, + algorithm = "SHA1", + notes = null, + ) + assertEquals(null, buildTwoFaCodeState(token, System.currentTimeMillis())) + } + + private fun generateReferenceTotp( + secretBase32: String, + nowMillis: Long, + digits: Int, + periodSeconds: Int, + macAlgorithm: String, + ): String { + val key = decodeBase32(secretBase32) + val generator = TimeBasedOneTimePasswordGenerator( + Duration.ofSeconds(periodSeconds.toLong()), + digits, + macAlgorithm, + ) + val otp = generator.generateOneTimePassword( + SecretKeySpec(key, "RAW"), + Instant.ofEpochMilli(nowMillis), + ) + return otp.toString().padStart(digits, '0') + } + + private fun decodeBase32(input: String): ByteArray { + val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + val clean = input.uppercase().replace(" ", "").replace("=", "") + var buffer = 0 + var bitsLeft = 0 + val out = ArrayList() + for (ch in clean) { + val value = alphabet.indexOf(ch) + buffer = (buffer shl 5) or value + bitsLeft += 5 + if (bitsLeft >= 8) { + out.add(((buffer shr (bitsLeft - 8)) and 0xFF).toByte()) + bitsLeft -= 8 + } + } + return out.toByteArray() + } +} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorage.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorage.kt new file mode 100644 index 0000000..2b5c3a0 --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorage.kt @@ -0,0 +1,60 @@ +package com.github.nullptroma.wallenc.usecases.fakes + +import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.interfaces.IStorage +import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo +import com.github.nullptroma.wallenc.domain.tasks.TaskProgress +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import java.time.Instant +import java.util.UUID + +class FakeStorage( + override val uuid: UUID = UUID.randomUUID(), + private val accessorImpl: FakeStorageAccessor = FakeStorageAccessor(), + private val meta: FakeMetaInfo = FakeMetaInfo(), +) : IStorage { + override val isAvailable: StateFlow = MutableStateFlow(true) + override val size: StateFlow = MutableStateFlow(0L) + override val numberOfFiles: StateFlow = MutableStateFlow(0) + override val isEmpty: Flow = flowOf(true) + override val metaInfo: StateFlow = MutableStateFlow(meta) + override val isVirtualStorage: Boolean = false + override val accessor: IStorageAccessor = accessorImpl + + override suspend fun rename(newName: String) = Unit + + override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = Unit + + override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = Unit + + fun setDomainFile(path: String, value: String) { + putFile(path, value.encodeToByteArray()) + } + + fun putFile(path: String, bytes: ByteArray) { + accessorImpl.dataFiles[path] = bytes + } + + fun fileBytes(path: String): ByteArray? = accessorImpl.dataFiles[path] + + fun addSyncJournalEntry(entry: StorageSyncJournalEntry) { + accessorImpl.syncJournal.add(entry) + } + + fun syncJournalEntries(): List = accessorImpl.syncJournal.toList() + + fun setAcquireLockResult(result: Boolean) { + accessorImpl.acquireLockResult = result + } +} + +class FakeMetaInfo( + override val encInfo: StorageEncryptionInfo? = null, + override val name: String = "Fake", + override val lastModified: Instant = Instant.now(), +) : IStorageMetaInfo diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt new file mode 100644 index 0000000..9a03410 --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageAccessor.kt @@ -0,0 +1,140 @@ +package com.github.nullptroma.wallenc.usecases.fakes + +import com.github.nullptroma.wallenc.domain.datatypes.DataPage +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry +import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock +import com.github.nullptroma.wallenc.domain.interfaces.IDirectory +import com.github.nullptroma.wallenc.domain.interfaces.IFile +import com.github.nullptroma.wallenc.domain.interfaces.IMetaInfo +import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.io.OutputStream +import java.time.Instant + +class FakeStorageAccessor : IStorageAccessor { + val dataFiles: MutableMap = mutableMapOf() + private val systemFiles: MutableMap = mutableMapOf() + private val _filesUpdates = MutableSharedFlow>(extraBufferCapacity = 16) + + var syncJournal: MutableList = mutableListOf() + var syncLock: StorageSyncLock? = null + var acquireLockResult: Boolean = true + + override val size: StateFlow = MutableStateFlow(0L) + override val numberOfFiles: StateFlow = MutableStateFlow(0) + override val isAvailable: StateFlow = MutableStateFlow(true) + override val filesUpdates: SharedFlow> = _filesUpdates + override val dirsUpdates: SharedFlow> = MutableSharedFlow() + + override suspend fun getAllFiles(): List = emptyList() + + override suspend fun getFiles(path: String): List = emptyList() + + override fun getFilesFlow(path: String): Flow> = emptyFlow() + + override suspend fun getAllDirs(): List = emptyList() + + override suspend fun getDirs(path: String): List = emptyList() + + override fun getDirsFlow(path: String): Flow> = emptyFlow() + + override suspend fun getFileInfo(path: String): IFile { + error("Not implemented in tests") + } + + override suspend fun getDirInfo(path: String): IDirectory { + error("Not implemented in tests") + } + + override suspend fun setHidden(path: String, hidden: Boolean) = Unit + + override suspend fun touchFile(path: String) = Unit + + override suspend fun touchDir(path: String) = Unit + + override suspend fun delete(path: String) { + dataFiles.remove(path) + } + + override suspend fun openWrite(path: String): OutputStream { + return object : ByteArrayOutputStream() { + override fun close() { + dataFiles[path] = toByteArray() + _filesUpdates.tryEmit( + DataPage( + listOf(FakeFile(path)), + pageLength = 1, + pageIndex = 0, + ), + ) + } + } + } + + override suspend fun openRead(path: String): InputStream { + val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path") + return ByteArrayInputStream(bytes) + } + + override suspend fun moveToTrash(path: String) = Unit + + override suspend fun openReadSystemFile(name: String): InputStream { + val bytes = systemFiles[name] ?: ByteArray(0) + return ByteArrayInputStream(bytes) + } + + override suspend fun openWriteSystemFile(name: String): OutputStream { + return object : ByteArrayOutputStream() { + override fun close() { + systemFiles[name] = toByteArray() + } + } + } + + override suspend fun readSyncJournal(): List = syncJournal.toList() + + override suspend fun appendSyncJournal(entries: List) { + syncJournal.addAll(entries) + } + + override suspend fun rewriteSyncJournal(entries: List) { + syncJournal.clear() + syncJournal.addAll(entries) + } + + override suspend fun readSyncLock(): StorageSyncLock? = syncLock + + override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean { + if (!acquireLockResult) return false + syncLock = StorageSyncLock(holderId, leaseUntil, Instant.now()) + return true + } + + override suspend fun releaseSyncLock(holderId: String) { + if (syncLock?.holderId == holderId) { + syncLock = null + } + } + + override suspend fun forceClearSyncLock() { + syncLock = null + } +} + +class FakeFile(path: String) : IFile { + override val metaInfo: IMetaInfo = object : IMetaInfo { + override val size: Long = 0L + override val isDeleted: Boolean = false + override val isHidden: Boolean = false + override val lastModified: Instant = Instant.now() + override val path: String = path + } +} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageSyncGroupStore.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageSyncGroupStore.kt new file mode 100644 index 0000000..28cc2ea --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeStorageSyncGroupStore.kt @@ -0,0 +1,18 @@ +package com.github.nullptroma.wallenc.usecases.fakes + +import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore +import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup + +class FakeStorageSyncGroupStore( + private var groups: List = emptyList(), +) : IStorageSyncGroupStore { + override suspend fun getGroups(): List = groups + + override suspend fun putGroup(group: StorageSyncGroup) { + groups = groups.filterNot { it.id == group.id } + group + } + + override suspend fun removeGroup(groupId: String) { + groups = groups.filterNot { it.id == groupId } + } +} diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeVaultsManager.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeVaultsManager.kt new file mode 100644 index 0000000..e98a72a --- /dev/null +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/fakes/FakeVaultsManager.kt @@ -0,0 +1,30 @@ +package com.github.nullptroma.wallenc.usecases.fakes + +import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey +import com.github.nullptroma.wallenc.domain.interfaces.IStorage +import com.github.nullptroma.wallenc.domain.interfaces.IUnlockManager +import com.github.nullptroma.wallenc.domain.interfaces.IVault +import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.util.UUID + +class FakeVaultsManager( + storages: List, +) : IVaultsManager { + override val vaults: StateFlow> = MutableStateFlow(emptyList()) + override val allStorages: StateFlow> = MutableStateFlow(storages) + override val unlockManager: IUnlockManager = FakeUnlockManager() +} + +class FakeUnlockManager : IUnlockManager { + override val openedStorages: StateFlow> = MutableStateFlow(emptyMap()) + + override fun getOpenedStorageKey(uuid: UUID): EncryptKey? = null + + override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage + + override suspend fun close(storage: IStorage) = Unit + + override suspend fun close(uuid: UUID) = Unit +}