Первые тесты
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
15
app/src/main/res/values-ru/plurals.xml
Normal file
15
app/src/main/res/values-ru/plurals.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@ kotlin {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
1
infrastructure-android/consumer-rules.pro
Normal file
1
infrastructure-android/consumer-rules.pro
Normal file
@@ -0,0 +1 @@
|
||||
# Consumer ProGuard rules for infrastructure-android (empty).
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
@@ -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
|
||||
) {
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,4 +17,5 @@ dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,101 +47,21 @@ 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(),
|
||||
TextSecretListCard(
|
||||
secret = secret,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,28 +122,12 @@ 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 ->
|
||||
Card(
|
||||
@@ -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 },
|
||||
@@ -284,7 +249,6 @@ fun TwoFaTokensScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creating) {
|
||||
TwoFaTokenEditDialog(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,5 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.java.otp)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user