Первые тесты

This commit is contained in:
2026-05-19 00:48:07 +03:00
parent fd6f2e5879
commit eecaf44b72
58 changed files with 1634 additions and 501 deletions

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
@@ -5,6 +7,13 @@ plugins {
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
val localProps = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
android { android {
namespace = "com.github.nullptroma.wallenc.app" namespace = "com.github.nullptroma.wallenc.app"
compileSdk = 37 compileSdk = 37
@@ -17,6 +26,12 @@ android {
versionName = "1.0" versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
@@ -78,6 +93,14 @@ dependencies {
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) 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(":domain"))
implementation(project(":usecases")) implementation(project(":usecases"))

View File

@@ -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)
}
}

View File

@@ -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<android.content.Context>()
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=<copy full token from app debug storage if needed>")
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"
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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())
}
}

View File

@@ -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.app.di.modules.app.IoDispatcher
import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore import com.github.nullptroma.wallenc.domain.interfaces.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao
import com.github.nullptroma.wallenc.domain.vault.db.app.repository.StorageKeyMapRepository import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.StorageKeyMapRepository
import com.github.nullptroma.wallenc.domain.vault.db.app.repository.StorageSyncGroupRepository import com.github.nullptroma.wallenc.infrastructure.android.db.app.repository.StorageSyncGroupRepository
import com.github.nullptroma.wallenc.domain.vault.db.app.repository.YandexAccountRepository 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.StorageKeyMapStore
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
import dagger.Module import dagger.Module

View File

@@ -1,12 +1,12 @@
package com.github.nullptroma.wallenc.app.di.modules.data package com.github.nullptroma.wallenc.app.di.modules.data
import android.content.Context import android.content.Context
import com.github.nullptroma.wallenc.domain.vault.db.RoomFactory import com.github.nullptroma.wallenc.infrastructure.android.db.RoomFactory
import com.github.nullptroma.wallenc.domain.vault.db.app.IAppDb import com.github.nullptroma.wallenc.infrastructure.android.db.app.IAppDb
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageKeyMapDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageKeyMapDao
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.YandexAccountDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.YandexAccountDao
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="task_notification_group_subtext">
<item quantity="one">Выполняется %d задача</item>
<item quantity="few">Выполняется %d задачи</item>
<item quantity="many">Выполняется %d задач</item>
<item quantity="other">Выполняется %d задач</item>
</plurals>
<plurals name="task_notification_more_tasks">
<item quantity="one">ещё %d</item>
<item quantity="few">ещё %d</item>
<item quantity="many">ещё %d</item>
<item quantity="other">ещё %d</item>
</plurals>
</resources>

View File

@@ -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)
}
}

View File

@@ -21,6 +21,8 @@ dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockwebserver)
implementation(project(":domain")) implementation(project(":domain"))
implementation(project(":vault-contracts")) implementation(project(":vault-contracts"))

View File

@@ -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)
}
}

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk package com.github.nullptroma.wallenc.domain.vault.network.yandexdisk
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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 com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -72,7 +73,30 @@ class YandexDiskApiFactory(
} }
companion object { 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 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)
}
} }
} }

View File

@@ -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<String>(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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -16,4 +16,5 @@ kotlin {
dependencies { dependencies {
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
} }

View File

@@ -5,6 +5,7 @@ kotlin = "2.3.21"
coreKtx = "1.18.0" coreKtx = "1.18.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
androidxTestCore = "1.7.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
kotlinReflect = "2.3.21" kotlinReflect = "2.3.21"
kotlinxCoroutinesCore = "1.11.0" kotlinxCoroutinesCore = "1.11.0"
@@ -27,13 +28,24 @@ cameraX = "1.6.1"
mlkitBarcode = "17.3.0" mlkitBarcode = "17.3.0"
javaOtp = "0.4.0" javaOtp = "0.4.0"
appcompat = "1.7.1" 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] [libraries]
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } 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" } 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" } 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-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" } 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-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" } 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" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } 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-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-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" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }

View File

@@ -9,6 +9,7 @@ android {
defaultConfig { defaultConfig {
minSdk = 26 minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro") consumerProguardFiles("consumer-rules.pro")
} }
@@ -26,7 +27,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
} }
dependencies { dependencies {
@@ -44,4 +44,8 @@ dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.core)
androidTestImplementation(libs.room.testing)
} }

View File

@@ -0,0 +1 @@
# Consumer ProGuard rules for infrastructure-android (empty).

View File

@@ -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<android.content.Context>()
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"))
}
}

View File

@@ -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 android.content.Context
import androidx.room.Room 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) { class RoomFactory(private val context: Context) {
fun buildAppDb(): AppDb { fun buildAppDb(): AppDb {

View File

@@ -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
}

View File

@@ -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.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query 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 @Dao
interface StorageKeyMapDao { interface StorageKeyMapDao {

View File

@@ -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.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query 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 kotlinx.coroutines.flow.Flow
import java.util.UUID import java.util.UUID

View File

@@ -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.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query 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 @Dao
interface StorageSyncGroupDao { interface StorageSyncGroupDao {

View File

@@ -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.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.Query 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 import kotlinx.coroutines.flow.Flow
@Dao @Dao

View File

@@ -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.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey
import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap import com.github.nullptroma.wallenc.domain.vault.model.StorageKeyMap
import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey import com.github.nullptroma.wallenc.domain.datatypes.EncryptKey
import java.util.UUID import java.util.UUID
@Entity(tableName = "storage_key_maps") @Entity(tableName = "storage_key_maps")
data class DbStorageKeyMap( data class DbStorageKeyMap(
@androidx.room.PrimaryKey @PrimaryKey
@ColumnInfo(name = "source_uuid") val sourceUuid: UUID, @ColumnInfo(name = "source_uuid") val sourceUuid: UUID,
@ColumnInfo(name = "key") val key: ByteArray @ColumnInfo(name = "key") val key: ByteArray
) { ) {

View File

@@ -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.ColumnInfo
import androidx.room.Entity import androidx.room.Entity

View File

@@ -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.ColumnInfo
import androidx.room.Entity import androidx.room.Entity

View File

@@ -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.Entity
import androidx.room.Index import androidx.room.Index

View File

@@ -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.infrastructure.android.db.app.dao.StorageKeyMapDao
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageKeyMap 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.model.StorageKeyMap
import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore import com.github.nullptroma.wallenc.domain.vault.ports.StorageKeyMapStore
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher

View File

@@ -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.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageMetaInfoDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageMetaInfoDao
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageMetaInfo 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.vault.utils.IProvider
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher

View File

@@ -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.IStorageSyncGroupStore
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroup
import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind import com.github.nullptroma.wallenc.domain.interfaces.StorageSyncGroupEncryptionKind
import com.github.nullptroma.wallenc.domain.vault.db.app.dao.StorageSyncGroupDao import com.github.nullptroma.wallenc.infrastructure.android.db.app.dao.StorageSyncGroupDao
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbStorageSyncGroup import com.github.nullptroma.wallenc.infrastructure.android.db.app.model.DbStorageSyncGroup
import com.github.nullptroma.wallenc.domain.vault.utils.IProvider import com.github.nullptroma.wallenc.domain.vault.utils.IProvider
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext

View File

@@ -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.infrastructure.android.db.app.dao.YandexAccountDao
import com.github.nullptroma.wallenc.domain.vault.db.app.model.DbYandexAccount 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.model.YandexAccount
import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore import com.github.nullptroma.wallenc.domain.vault.ports.YandexAccountStore
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher

View File

@@ -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
}

View File

@@ -17,4 +17,5 @@ dependencies {
implementation(project(":domain")) implementation(project(":domain"))
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
} }

View File

@@ -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" })
}
}

View File

@@ -36,6 +36,10 @@ android {
compose = true compose = true
} }
testOptions {
unitTests.isIncludeAndroidResources = true
}
packaging { packaging {
resources { resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
@@ -79,6 +83,10 @@ dependencies {
implementation(libs.mlkit.barcode.scanning) implementation(libs.mlkit.barcode.scanning)
testImplementation(libs.junit) 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.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
@@ -87,4 +95,6 @@ dependencies {
implementation(project(":domain")) implementation(project(":domain"))
implementation(project(":usecases")) implementation(project(":usecases"))
implementation(project(":vault-contracts")) implementation(project(":vault-contracts"))
testImplementation(project(":task-runtime"))
} }

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -1,28 +1,16 @@
package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.secrets
import androidx.compose.foundation.layout.Arrangement 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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons 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.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.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -33,7 +21,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.ui.resources.resolveText
@Composable @Composable
fun TextSecretsScreen( fun TextSecretsScreen(
@@ -60,101 +47,21 @@ fun TextSecretsScreen(
} }
}, },
) { innerPadding -> ) { innerPadding ->
Column( TextSecretsScreenContent(
uiState = uiState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding),
.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 {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { secret -> items(uiState.items) { secret ->
Card( TextSecretListCard(
modifier = Modifier.fillMaxWidth(), secret = secret,
onClick = { onOpenSecret(secret) }, onClick = { onOpenSecret(secret) },
enabled = uiState.isAvailable, 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,
) )
} }
} }
} }
} }
} }
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -122,28 +122,12 @@ fun TwoFaTokensScreen(
} }
}, },
) { innerPadding -> ) { innerPadding ->
Column( TwoFaTokensScreenContent(
uiState = uiState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding) .padding(innerPadding),
.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 {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) { LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(uiState.items) { item -> items(uiState.items) { item ->
Card( Card(
@@ -159,30 +143,11 @@ fun TwoFaTokensScreen(
.padding(14.dp), .padding(14.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row( TwoFaTokenListHeader(
issuer = item.issuer,
account = item.account,
modifier = Modifier.weight(1f), 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 { Row {
IconButton( IconButton(
onClick = { editingToken = item }, onClick = { editingToken = item },
@@ -284,7 +249,6 @@ fun TwoFaTokensScreen(
} }
} }
} }
}
if (creating) { if (creating) {
TwoFaTokenEditDialog( TwoFaTokenEditDialog(

View File

@@ -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,
)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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(),
)
}
}

View File

@@ -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"))
}
}

View File

@@ -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<UiStringResolver>()
every { uiStrings.invoke(any<Int>(), any()) } returns "Test task"
every { uiStrings.invoke(any<Int>()) } 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)
}
}

View File

@@ -20,4 +20,5 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.java.otp) implementation(libs.java.otp)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
} }

View File

@@ -1,42 +1,19 @@
package com.github.nullptroma.wallenc.usecases 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.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry import com.github.nullptroma.wallenc.usecases.fakes.FakeStorage
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 kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test 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 { class StorageDomainUseCasesTest {
@Test @Test
fun twoFaCrudWorksAndPersists() = runBlocking { fun twoFaCrudWorksAndPersists() = runTest {
val storage = FakeStorage() val storage = FakeStorage()
val useCase = ManageTwoFaTokensUseCase() val useCase = ManageTwoFaTokensUseCase()
@@ -63,7 +40,7 @@ class StorageDomainUseCasesTest {
} }
@Test @Test
fun twoFaInvalidJsonFallsBackToEmptyList() = runBlocking { fun twoFaInvalidJsonFallsBackToEmptyList() = runTest {
val storage = FakeStorage().apply { val storage = FakeStorage().apply {
setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json") setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json")
} }
@@ -72,7 +49,7 @@ class StorageDomainUseCasesTest {
} }
@Test @Test
fun textSecretsCrudWorksWithOptionalLabels() = runBlocking { fun textSecretsCrudWorksWithOptionalLabels() = runTest {
val storage = FakeStorage() val storage = FakeStorage()
val useCase = ManageTextSecretsUseCase() val useCase = ManageTextSecretsUseCase()
@@ -104,7 +81,7 @@ class StorageDomainUseCasesTest {
} }
@Test @Test
fun textSecretsInvalidJsonFallsBackToEmptyList() = runBlocking { fun textSecretsInvalidJsonFallsBackToEmptyList() = runTest {
val storage = FakeStorage().apply { val storage = FakeStorage().apply {
setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken") setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken")
} }
@@ -112,131 +89,3 @@ class StorageDomainUseCasesTest {
assertTrue(useCase.observe(storage).first().isEmpty()) assertTrue(useCase.observe(storage).first().isEmpty())
} }
} }
private class FakeStorage : IStorage {
private val accessorImpl = FakeStorageAccessor()
override val uuid: UUID = UUID.randomUUID()
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isEmpty: Flow<Boolean?> = flowOf(true)
override val metaInfo: StateFlow<IStorageMetaInfo> = 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<String, ByteArray> = mutableMapOf()
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
override suspend fun getAllFiles(): List<IFile> = emptyList()
override suspend fun getFiles(path: String): List<IFile> = emptyList()
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = emptyFlow()
override suspend fun getAllDirs(): List<IDirectory> = emptyList()
override suspend fun getDirs(path: String): List<IDirectory> = emptyList()
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = 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<StorageSyncJournalEntry> = emptyList()
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
override suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>) = 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
}
}

View File

@@ -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<TaskProgressLabel?>()
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<TaskProgressLabel?>()
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<TaskProgressLabel?>()
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<TaskProgressLabel?>()
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<FakeStorage>,
groups: List<StorageSyncGroup>,
): StorageSyncEngine {
val vaultsManager = FakeVaultsManager(storages)
val findStorage = FindStorageUseCase(vaultsManager)
return StorageSyncEngine(
vaultsManager = vaultsManager,
groupStore = FakeStorageSyncGroupStore(groups),
findStorageUseCase = findStorage,
)
}
}

View File

@@ -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<Byte>()
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()
}
}

View File

@@ -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<Boolean> = MutableStateFlow(true)
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isEmpty: Flow<Boolean?> = flowOf(true)
override val metaInfo: StateFlow<IStorageMetaInfo> = 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<StorageSyncJournalEntry> = 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

View File

@@ -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<String, ByteArray> = mutableMapOf()
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val _filesUpdates = MutableSharedFlow<DataPage<IFile>>(extraBufferCapacity = 16)
var syncJournal: MutableList<StorageSyncJournalEntry> = mutableListOf()
var syncLock: StorageSyncLock? = null
var acquireLockResult: Boolean = true
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val filesUpdates: SharedFlow<DataPage<IFile>> = _filesUpdates
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
override suspend fun getAllFiles(): List<IFile> = emptyList()
override suspend fun getFiles(path: String): List<IFile> = emptyList()
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = emptyFlow()
override suspend fun getAllDirs(): List<IDirectory> = emptyList()
override suspend fun getDirs(path: String): List<IDirectory> = emptyList()
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = 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<StorageSyncJournalEntry> = syncJournal.toList()
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) {
syncJournal.addAll(entries)
}
override suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>) {
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
}
}

View File

@@ -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<StorageSyncGroup> = emptyList(),
) : IStorageSyncGroupStore {
override suspend fun getGroups(): List<StorageSyncGroup> = 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 }
}
}

View File

@@ -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<IStorage>,
) : IVaultsManager {
override val vaults: StateFlow<List<IVault>> = MutableStateFlow(emptyList())
override val allStorages: StateFlow<List<IStorage>> = MutableStateFlow(storages)
override val unlockManager: IUnlockManager = FakeUnlockManager()
}
class FakeUnlockManager : IUnlockManager {
override val openedStorages: StateFlow<Map<UUID, IStorage>> = 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
}