refactor(settings): type-safe Settings-Keys mit SettingsKey sealed class

Neue SettingsKey<T> sealed class mit typisierten StringKey-Objekten,
Default-Werten und Compile-Time-Checks auf gueltige Keys. Alte String-
basierte API bleibt fuer Import/Export-Filter (SENSITIVE_KEYS) erhalten.

SettingsRepository: Neue typisierte Methoden getString(), getStringOrNull(),
setString(), observeString() mit SettingsKey.StringKey-Parameter.

Alle Caller migriert:
- SettingsViewModel: KEY_*-Companion entfernt, StringKey.* direkt genutzt
- CameraViewModel, DashboardViewModel, InventoryPickerViewModel: analog
- SyncServiceImpl: KEY_*-Companion entfernt, getString()/setString()
- ItemRepositoryImpl, MessageRepositoryImpl: getString()/getStringOrNull()

Tests: Alle FakeSettingsRepository-Klassen um typisierte Methoden
erweitert, Mock-Calls in CameraViewModelTest und SyncServiceImplTest
angepasst, 5 neue Tests fuer typisierte API in SettingsRepositoryImplTest.
72 Tests gruen.

Closes #82
This commit is contained in:
Jens Reinemann 2026-05-17 04:26:27 +02:00
parent d81acfbb4f
commit ec41a64b5e
19 changed files with 288 additions and 132 deletions

View file

@ -7,7 +7,7 @@ import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.di.ApplicationScope
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
@ -90,8 +90,8 @@ internal class ItemRepositoryImpl @Inject constructor(
withContext(Dispatchers.IO) { dao.getLastUsedLocationId() }
private suspend fun attemptPatch(itemId: String, item: ItemDto) {
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
if (token.isNullOrBlank()) return
val token = settingsRepository.getString(StringKey.AuthAccessToken)
if (token.isBlank()) return
val result = syncService.patchItem(itemId, item)
val error = result.exceptionOrNull()
if (error is SyncError.Timeout || error is SyncError.ConnectionError || error is SyncError.Unknown) {
@ -108,8 +108,8 @@ internal class ItemRepositoryImpl @Inject constructor(
}
private suspend fun attemptDelete(itemId: String) {
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
if (token.isNullOrBlank()) return
val token = settingsRepository.getString(StringKey.AuthAccessToken)
if (token.isBlank()) return
val result = syncService.deleteItem(itemId)
val error = result.exceptionOrNull()
if (error is SyncError.Timeout || error is SyncError.ConnectionError || error is SyncError.Unknown) {

View file

@ -5,7 +5,7 @@ import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.di.ApplicationScope
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
@ -77,8 +77,8 @@ internal class MessageRepositoryImpl @Inject constructor(
dao.getConversation(myId, otherId)
override suspend fun sendMessage(recipientId: String, body: String) {
val myId = settingsRepository.getValue(SettingsKeys.AUTH_USER_ID) ?: return
val myUsername = settingsRepository.getValue(SettingsKeys.AUTH_USERNAME) ?: ""
val myId = settingsRepository.getStringOrNull(StringKey.AuthUserId) ?: return
val myUsername = settingsRepository.getString(StringKey.AuthUsername)
val localId = UUID.randomUUID().toString()
val sentAt = System.currentTimeMillis()
@ -102,9 +102,9 @@ internal class MessageRepositoryImpl @Inject constructor(
override suspend fun fetchUsers(): Result<List<UserListItemDto>> =
withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL)
val serverUrl = settingsRepository.getStringOrNull(StringKey.ServerUrl)
?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
val token = settingsRepository.getStringOrNull(StringKey.AuthAccessToken)
?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
try {
val response = httpClient.get("${serverUrl.trimEnd('/')}/api/users") {
@ -125,7 +125,7 @@ internal class MessageRepositoryImpl @Inject constructor(
}
override suspend fun getMyUserId(): String? =
settingsRepository.getValue(SettingsKeys.AUTH_USER_ID)
settingsRepository.getStringOrNull(StringKey.AuthUserId)
override suspend fun drainPendingMessages() {
val pending = withContext(Dispatchers.IO) { dao.getPendingMessages() }
@ -143,9 +143,9 @@ internal class MessageRepositoryImpl @Inject constructor(
body: String,
sentAt: Long
): Result<Unit> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL)
val serverUrl = settingsRepository.getStringOrNull(StringKey.ServerUrl)
?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
val token = settingsRepository.getStringOrNull(StringKey.AuthAccessToken)
?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
try {
val response = httpClient.post("${serverUrl.trimEnd('/')}/api/messages") {

View file

@ -3,10 +3,12 @@ package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.data.security.SecureTokenStorage
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.repository.SettingsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ -38,4 +40,16 @@ internal class SettingsRepositoryImpl @Inject constructor(
override fun observeValue(key: String): Flow<String?> = dao.observeValue(key)
override fun getAll(): Flow<List<SettingsEntity>> = dao.getAll()
override suspend fun getString(key: SettingsKey.StringKey): String =
getValue(key.key) ?: key.defaultValue
override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? =
getValue(key.key)
override suspend fun setString(key: SettingsKey.StringKey, value: String) =
setValue(key.key, value)
override fun observeString(key: SettingsKey.StringKey): Flow<String> =
observeValue(key.key).map { it ?: key.defaultValue }
}

View file

@ -1,6 +1,6 @@
package de.krisenvorrat.app.data.sync
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
@ -85,16 +85,16 @@ internal class SyncServiceImpl @Inject constructor(
when (response.status) {
HttpStatusCode.OK -> {
val loginResponse: LoginResponse = response.body()
settingsRepository.setValue(KEY_SERVER_URL, serverUrl)
settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken)
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId)
settingsRepository.setString(StringKey.ServerUrl, serverUrl)
settingsRepository.setString(StringKey.AuthAccessToken, loginResponse.accessToken)
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
settingsRepository.setString(StringKey.AuthUsername, username)
settingsRepository.setString(StringKey.AuthUserId, loginResponse.userId)
loginResponse.inventoryId?.let {
settingsRepository.setValue(KEY_ACTIVE_INVENTORY_ID, it)
settingsRepository.setString(StringKey.ActiveInventoryId, it)
}
loginResponse.inventoryName?.let {
settingsRepository.setValue(KEY_ACTIVE_INVENTORY_NAME, it)
settingsRepository.setString(StringKey.ActiveInventoryName, it)
}
Result.success(Unit)
}
@ -113,20 +113,20 @@ internal class SyncServiceImpl @Inject constructor(
}
override suspend fun logout() {
settingsRepository.setValue(KEY_ACCESS_TOKEN, "")
settingsRepository.setValue(KEY_REFRESH_TOKEN, "")
settingsRepository.setValue(KEY_AUTH_USERNAME, "")
settingsRepository.setString(StringKey.AuthAccessToken, "")
settingsRepository.setString(StringKey.AuthRefreshToken, "")
settingsRepository.setString(StringKey.AuthUsername, "")
}
private suspend fun executeRequest(
block: suspend (serverUrl: String, token: String) -> Result<InventoryDto>
): Result<InventoryDto> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
if (serverUrl.isNullOrBlank()) {
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
if (serverUrl.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
val token = settingsRepository.getString(StringKey.AuthAccessToken)
if (token.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
@ -135,8 +135,8 @@ internal class SyncServiceImpl @Inject constructor(
if (result.exceptionOrNull() is SyncError.AuthError) {
val refreshed = refreshToken(serverUrl.trimEnd('/'))
if (refreshed) {
val newToken = settingsRepository.getValue(KEY_ACCESS_TOKEN)
?: return@withContext result
val newToken = settingsRepository.getString(StringKey.AuthAccessToken)
if (newToken.isBlank()) return@withContext result
block(serverUrl.trimEnd('/'), newToken)
} else {
result
@ -156,12 +156,12 @@ internal class SyncServiceImpl @Inject constructor(
private suspend fun executeItemRequest(
block: suspend (serverUrl: String, token: String) -> Result<Unit>
): Result<Unit> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
if (serverUrl.isNullOrBlank()) {
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
if (serverUrl.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
val token = settingsRepository.getString(StringKey.AuthAccessToken)
if (token.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
@ -176,8 +176,8 @@ internal class SyncServiceImpl @Inject constructor(
}
private suspend fun refreshToken(serverUrl: String): Boolean {
val refreshToken = settingsRepository.getValue(KEY_REFRESH_TOKEN)
if (refreshToken.isNullOrBlank()) return false
val refreshToken = settingsRepository.getString(StringKey.AuthRefreshToken)
if (refreshToken.isBlank()) return false
return try {
val response = httpClient.post("$serverUrl/api/auth/refresh") {
contentType(ContentType.Application.Json)
@ -185,8 +185,8 @@ internal class SyncServiceImpl @Inject constructor(
}
if (response.status == HttpStatusCode.OK) {
val loginResponse: LoginResponse = response.body()
settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken)
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
settingsRepository.setString(StringKey.AuthAccessToken, loginResponse.accessToken)
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
true
} else {
false
@ -270,12 +270,12 @@ internal class SyncServiceImpl @Inject constructor(
private suspend fun executeInventoryInfoRequest(
block: suspend (serverUrl: String, token: String) -> Result<InventoryInfoDto>
): Result<InventoryInfoDto> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
if (serverUrl.isNullOrBlank()) {
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
if (serverUrl.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
val token = settingsRepository.getString(StringKey.AuthAccessToken)
if (token.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
@ -292,12 +292,12 @@ internal class SyncServiceImpl @Inject constructor(
private suspend fun executeInventoryInfoListRequest(
block: suspend (serverUrl: String, token: String) -> Result<List<InventoryInfoDto>>
): Result<List<InventoryInfoDto>> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
if (serverUrl.isNullOrBlank()) {
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
if (serverUrl.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
val token = settingsRepository.getString(StringKey.AuthAccessToken)
if (token.isBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
@ -310,14 +310,4 @@ internal class SyncServiceImpl @Inject constructor(
Result.failure(SyncError.Unknown(e))
}
}
private companion object {
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
val KEY_AUTH_USER_ID = SettingsKeys.AUTH_USER_ID
val KEY_ACTIVE_INVENTORY_ID = SettingsKeys.ACTIVE_INVENTORY_ID
val KEY_ACTIVE_INVENTORY_NAME = SettingsKeys.ACTIVE_INVENTORY_NAME
}
}

View file

@ -0,0 +1,36 @@
package de.krisenvorrat.app.domain.model
/**
* Type-safe Settings-Key-Definition.
* Jeder Key hat einen festen DB-Schlüssel, einen Default-Wert und einen Typ.
* Compile-Time-Fehler bei ungültigen Keys sind garantiert.
*/
internal sealed class SettingsKey<T>(val key: String, val defaultValue: T) {
sealed class StringKey(key: String, defaultValue: String = "") : SettingsKey<String>(key, defaultValue) {
data object HouseholdSize : StringKey("household_size")
data object DailyKcalPerPerson : StringKey("daily_kcal_per_person")
data object AgeGroups : StringKey("age_groups")
data object ServerUrl : StringKey("server_url")
data object AuthAccessToken : StringKey("auth_access_token")
data object AuthRefreshToken : StringKey("auth_refresh_token")
data object AuthUsername : StringKey("auth_username")
data object AuthUserId : StringKey("auth_user_id")
data object SyncLastTimestamp : StringKey("sync_last_timestamp")
data object OpenAiApiKey : StringKey("openai_api_key")
data object ActiveInventoryId : StringKey("active_inventory_id")
data object ActiveInventoryName : StringKey("active_inventory_name")
}
companion object {
val SENSITIVE_KEYS: Set<StringKey> = setOf(
StringKey.AuthAccessToken,
StringKey.AuthRefreshToken,
StringKey.AuthUsername,
StringKey.AuthUserId,
StringKey.OpenAiApiKey
)
val SENSITIVE_KEY_STRINGS: Set<String> = SENSITIVE_KEYS.map { it.key }.toSet()
}
}

View file

@ -1,5 +1,10 @@
package de.krisenvorrat.app.domain.model
/**
* Kompatibilitätsschicht: leitet alle Konstanten an [SettingsKey] weiter.
* Bestehender Code, der nur den String-Key braucht (Import/Export-Filter),
* kann weiterhin [SettingsKeys.SENSITIVE_KEYS] nutzen.
*/
internal object SettingsKeys {
const val HOUSEHOLD_SIZE = "household_size"
const val DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
@ -14,11 +19,5 @@ internal object SettingsKeys {
const val ACTIVE_INVENTORY_ID = "active_inventory_id"
const val ACTIVE_INVENTORY_NAME = "active_inventory_name"
val SENSITIVE_KEYS: Set<String> = setOf(
AUTH_ACCESS_TOKEN,
AUTH_REFRESH_TOKEN,
AUTH_USERNAME,
AUTH_USER_ID,
OPENAI_API_KEY
)
val SENSITIVE_KEYS: Set<String> = SettingsKey.SENSITIVE_KEY_STRINGS
}

View file

@ -1,6 +1,7 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.SettingsKey
import kotlinx.coroutines.flow.Flow
internal interface SettingsRepository {
@ -8,4 +9,9 @@ internal interface SettingsRepository {
suspend fun setValue(key: String, value: String)
fun observeValue(key: String): Flow<String?>
fun getAll(): Flow<List<SettingsEntity>>
suspend fun getString(key: SettingsKey.StringKey): String
suspend fun getStringOrNull(key: SettingsKey.StringKey): String?
suspend fun setString(key: SettingsKey.StringKey, value: String)
fun observeString(key: SettingsKey.StringKey): Flow<String>
}

View file

@ -11,7 +11,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.data.remote.OpenAiVisionService
import de.krisenvorrat.app.di.IoDispatcher
import de.krisenvorrat.app.domain.model.ItemFormPrefill
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.repository.SettingsRepository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
@ -42,7 +42,7 @@ internal class CameraViewModel @Inject constructor(
private fun checkApiKey() {
viewModelScope.launch {
val apiKey = settingsRepository.getValue(SettingsKeys.OPENAI_API_KEY) ?: ""
val apiKey = settingsRepository.getString(StringKey.OpenAiApiKey)
_uiState.update { it.copy(hasApiKey = apiKey.isNotBlank()) }
}
}
@ -78,7 +78,7 @@ internal class CameraViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(isAnalyzing = true, errorMessage = null) }
try {
val apiKey = settingsRepository.getValue(SettingsKeys.OPENAI_API_KEY) ?: ""
val apiKey = settingsRepository.getString(StringKey.OpenAiApiKey)
if (apiKey.isBlank()) {
_uiState.update {
it.copy(

View file

@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.totalDailyKcal
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
@ -40,7 +40,7 @@ internal class DashboardViewModel @Inject constructor(
combine(
itemRepository.getAll(),
categoryRepository.getAll(),
settingsRepository.observeValue(SettingsKeys.AGE_GROUPS)
settingsRepository.observeString(StringKey.AgeGroups)
) { items, categories, ageGroupsJson ->
val ageGroups = parseAgeGroupsFromJson(ageGroupsJson)
val totalDailyKcal = ageGroups.totalDailyKcal().let {

View file

@ -3,7 +3,7 @@ package de.krisenvorrat.app.ui.inventory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
@ -30,7 +30,7 @@ internal class InventoryPickerViewModel @Inject constructor(
private fun loadActiveInventoryName() {
viewModelScope.launch {
val name = settingsRepository.getValue(SettingsKeys.ACTIVE_INVENTORY_NAME) ?: ""
val name = settingsRepository.getString(StringKey.ActiveInventoryName)
_uiState.update { it.copy(activeInventoryName = name) }
}
}
@ -70,8 +70,8 @@ internal class InventoryPickerViewModel @Inject constructor(
_uiState.update { it.copy(isCreating = true, error = null) }
syncService.createInventory(name).fold(
onSuccess = { info ->
settingsRepository.setValue(SettingsKeys.ACTIVE_INVENTORY_ID, info.id)
settingsRepository.setValue(SettingsKeys.ACTIVE_INVENTORY_NAME, info.name)
settingsRepository.setString(StringKey.ActiveInventoryId, info.id)
settingsRepository.setString(StringKey.ActiveInventoryName, info.name)
_uiState.update {
it.copy(
isCreating = false,
@ -99,8 +99,8 @@ internal class InventoryPickerViewModel @Inject constructor(
_uiState.update { it.copy(isSwitching = true, error = null) }
syncService.switchInventory(inventoryId).fold(
onSuccess = { info ->
settingsRepository.setValue(SettingsKeys.ACTIVE_INVENTORY_ID, info.id)
settingsRepository.setValue(SettingsKeys.ACTIVE_INVENTORY_NAME, info.name)
settingsRepository.setString(StringKey.ActiveInventoryId, info.id)
settingsRepository.setString(StringKey.ActiveInventoryName, info.name)
_uiState.update {
it.copy(isSwitching = false, activeInventoryName = info.name)
}
@ -121,7 +121,7 @@ internal class InventoryPickerViewModel @Inject constructor(
}
private suspend fun resyncFromServer() {
settingsRepository.setValue(SettingsKeys.SYNC_LAST_TIMESTAMP, "")
settingsRepository.setString(StringKey.SyncLastTimestamp, "")
syncService.downloadInventory().fold(
onSuccess = { inventoryDto ->
importExportRepository.importFromInventoryDto(inventoryDto)

View file

@ -11,7 +11,7 @@ import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.defaultAgeGroups
import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson
import de.krisenvorrat.app.domain.model.toJson
@ -69,12 +69,12 @@ internal class SettingsViewModel @Inject constructor(
viewModelScope.launch {
try {
val ageGroups = loadAgeGroupsWithMigration()
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
val authUsername = settingsRepository.getValue(KEY_AUTH_USERNAME) ?: ""
val accessToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) ?: ""
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
val authUsername = settingsRepository.getString(StringKey.AuthUsername)
val accessToken = settingsRepository.getString(StringKey.AuthAccessToken)
val isLoggedIn = authUsername.isNotBlank() && accessToken.isNotBlank()
val openAiApiKey = settingsRepository.getValue(KEY_OPENAI_API_KEY) ?: ""
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
val openAiApiKey = settingsRepository.getString(StringKey.OpenAiApiKey)
val lastSyncTimestamp = settingsRepository.getStringOrNull(StringKey.SyncLastTimestamp)
_uiState.update {
it.copy(
@ -103,13 +103,13 @@ internal class SettingsViewModel @Inject constructor(
}
private suspend fun loadAgeGroupsWithMigration(): List<AgeGroupEntry> {
val ageGroupsJsonValue = settingsRepository.getValue(KEY_AGE_GROUPS)
val ageGroupsJsonValue = settingsRepository.getStringOrNull(StringKey.AgeGroups)
if (ageGroupsJsonValue != null) {
return parseAgeGroupsFromJson(ageGroupsJsonValue)
}
val legacyHouseholdSize = settingsRepository.getValue(KEY_HOUSEHOLD_SIZE)?.toIntOrNull()
val legacyHouseholdSize = settingsRepository.getStringOrNull(StringKey.HouseholdSize)?.toIntOrNull()
if (legacyHouseholdSize != null) {
val legacyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)?.toIntOrNull()
val legacyKcal = settingsRepository.getStringOrNull(StringKey.DailyKcalPerPerson)?.toIntOrNull()
?: AgeGroup.ERWACHSENER.defaultKcal
return defaultAgeGroups().map { entry ->
if (entry.ageGroup == AgeGroup.ERWACHSENER) {
@ -170,7 +170,7 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(isLoggingIn = true, loginError = null) }
syncService.login(serverUrl, username, password).fold(
onSuccess = {
val accessToken = settingsRepository.getValue(KEY_ACCESS_TOKEN) ?: ""
val accessToken = settingsRepository.getString(StringKey.AuthAccessToken)
webSocketClient.connect(serverUrl, accessToken)
_uiState.update {
it.copy(
@ -215,9 +215,9 @@ internal class SettingsViewModel @Inject constructor(
fun saveSettings() {
viewModelScope.launch {
try {
settingsRepository.setValue(KEY_AGE_GROUPS, _uiState.value.ageGroups.toJson())
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
settingsRepository.setValue(KEY_OPENAI_API_KEY, _uiState.value.openAiApiKey)
settingsRepository.setString(StringKey.AgeGroups, _uiState.value.ageGroups.toJson())
settingsRepository.setString(StringKey.ServerUrl, _uiState.value.serverUrl)
settingsRepository.setString(StringKey.OpenAiApiKey, _uiState.value.openAiApiKey)
_uiState.update { it.copy(isSaved = true) }
} catch (e: Exception) {
@ -355,7 +355,7 @@ internal class SettingsViewModel @Inject constructor(
result.fold(
onSuccess = {
val now = Instant.now().toEpochMilli().toString()
settingsRepository.setValue(KEY_SYNC_LAST_TIMESTAMP, now)
settingsRepository.setString(StringKey.SyncLastTimestamp, now)
_uiState.update {
it.copy(
syncStatus = SyncStatus.Success,
@ -381,14 +381,14 @@ internal class SettingsViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(syncStatus = SyncStatus.Running) }
try {
val since = if (fullSync) null else settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)?.toLongOrNull()
val since = if (fullSync) null else settingsRepository.getStringOrNull(StringKey.SyncLastTimestamp)?.toLongOrNull()
val result = syncService.downloadInventory(since)
result.fold(
onSuccess = { inventoryDto ->
importExportRepository.importFromInventoryDto(inventoryDto).fold(
onSuccess = {
val now = Instant.now().toEpochMilli().toString()
settingsRepository.setValue(KEY_SYNC_LAST_TIMESTAMP, now)
settingsRepository.setString(StringKey.SyncLastTimestamp, now)
loadSettings()
_uiState.update {
it.copy(
@ -429,15 +429,4 @@ internal class SettingsViewModel @Inject constructor(
return formatter.format(instant.atZone(ZoneId.systemDefault()))
}
companion object {
val KEY_HOUSEHOLD_SIZE = SettingsKeys.HOUSEHOLD_SIZE
val KEY_DAILY_KCAL_PER_PERSON = SettingsKeys.DAILY_KCAL_PER_PERSON
val KEY_AGE_GROUPS = SettingsKeys.AGE_GROUPS
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
val KEY_SYNC_LAST_TIMESTAMP = SettingsKeys.SYNC_LAST_TIMESTAMP
val KEY_OPENAI_API_KEY = SettingsKeys.OPENAI_API_KEY
}
}

View file

@ -7,6 +7,7 @@ import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
@ -124,6 +125,15 @@ private class FakeSettingsRepository : SettingsRepository {
override suspend fun setValue(key: String, value: String) { store[key] = value }
override fun observeValue(key: String): Flow<String?> = MutableStateFlow(store[key])
override fun getAll(): Flow<List<SettingsEntity>> = MutableStateFlow(emptyList())
override suspend fun getString(key: SettingsKey.StringKey): String =
getValue(key.key) ?: key.defaultValue
override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? =
getValue(key.key)
override suspend fun setString(key: SettingsKey.StringKey, value: String) =
setValue(key.key, value)
override fun observeString(key: SettingsKey.StringKey): Flow<String> =
MutableStateFlow(store[key.key] ?: key.defaultValue)
}
private class FakeSyncService : SyncService {

View file

@ -4,6 +4,7 @@ import de.krisenvorrat.app.data.db.dao.MessageDao
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.shared.model.MessageDto
@ -64,6 +65,15 @@ private class FakeMessageSettingsRepository : SettingsRepository {
override fun observeValue(key: String): Flow<String?> = MutableStateFlow(store[key])
override fun getAll(): Flow<List<de.krisenvorrat.app.data.db.entity.SettingsEntity>> =
MutableStateFlow(emptyList())
override suspend fun getString(key: SettingsKey.StringKey): String =
getValue(key.key) ?: key.defaultValue
override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? =
getValue(key.key)
override suspend fun setString(key: SettingsKey.StringKey, value: String) =
setValue(key.key, value)
override fun observeString(key: SettingsKey.StringKey): Flow<String> =
MutableStateFlow(store[key.key] ?: key.defaultValue)
}
private class FakeMessageWsClient : WebSocketClient {

View file

@ -3,6 +3,7 @@ package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.data.security.SecureTokenStorage
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SettingsKeys
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -158,4 +159,62 @@ class SettingsRepositoryImplTest {
assertNull(fakeDao.getValue(key))
}
}
@Test
fun test_getString_withDefaultValue_returnsDefault() = runBlocking {
// Given no value set
// When
val result = repository.getString(SettingsKey.StringKey.ServerUrl)
// Then
assertEquals("", result)
}
@Test
fun test_getString_withStoredValue_returnsStoredValue() = runBlocking {
// Given
repository.setString(SettingsKey.StringKey.ServerUrl, "https://example.com")
// When
val result = repository.getString(SettingsKey.StringKey.ServerUrl)
// Then
assertEquals("https://example.com", result)
}
@Test
fun test_getStringOrNull_withNoValue_returnsNull() = runBlocking {
// Given no value set
// When
val result = repository.getStringOrNull(SettingsKey.StringKey.AgeGroups)
// Then
assertNull(result)
}
@Test
fun test_setString_withSensitiveKey_routesToSecureStorage() = runBlocking {
// Given
val token = "secure-token-value"
// When
repository.setString(SettingsKey.StringKey.AuthAccessToken, token)
// Then
assertEquals(token, repository.getString(SettingsKey.StringKey.AuthAccessToken))
assertEquals(token, fakeSecureStorage.get(SettingsKey.StringKey.AuthAccessToken.key))
}
@Test
fun test_observeString_returnsDefaultWhenNotSet() = runBlocking {
// Given no value set
// When
val result = repository.observeString(SettingsKey.StringKey.ServerUrl).first()
// Then
assertEquals("", result)
}
}

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.app.data.sync
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.shared.model.CategoryDto
@ -74,9 +75,9 @@ class SyncServiceImplTest {
}
private fun setupSettings(serverUrl: String? = "http://localhost:8080", accessToken: String? = "test-token") {
coEvery { settingsRepository.getValue("server_url") } returns serverUrl
coEvery { settingsRepository.getValue("auth_access_token") } returns accessToken
coEvery { settingsRepository.getValue("auth_refresh_token") } returns null
coEvery { settingsRepository.getString(StringKey.ServerUrl) } returns (serverUrl ?: "")
coEvery { settingsRepository.getString(StringKey.AuthAccessToken) } returns (accessToken ?: "")
coEvery { settingsRepository.getString(StringKey.AuthRefreshToken) } returns ""
}
// --- downloadInventory ---

View file

@ -5,7 +5,7 @@ import android.content.Context
import android.net.Uri
import de.krisenvorrat.app.data.remote.OpenAiVisionService
import de.krisenvorrat.app.domain.model.ItemFormPrefill
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.repository.SettingsRepository
import io.mockk.coEvery
import io.mockk.every
@ -59,7 +59,7 @@ class CameraViewModelTest {
@Test
fun test_initialState_noPhoto_stateIsEmpty() = runTest(testDispatcher) {
// Given
coEvery { mockSettingsRepository.getValue(any()) } returns ""
coEvery { mockSettingsRepository.getString(any()) } returns ""
viewModel = createViewModel()
// When
@ -78,7 +78,7 @@ class CameraViewModelTest {
@Test
fun test_analyzePhoto_noApiKey_setsErrorMessage() = runTest(testDispatcher) {
// Given
coEvery { mockSettingsRepository.getValue(any()) } returns ""
coEvery { mockSettingsRepository.getString(any()) } returns ""
viewModel = createViewModel()
advanceUntilIdle()
val mockUri = mockk<Uri>()
@ -98,7 +98,7 @@ class CameraViewModelTest {
@Test
fun test_toggleItemSelection_selectsItem() = runTest(testDispatcher) {
// Given
coEvery { mockSettingsRepository.getValue(any()) } returns ""
coEvery { mockSettingsRepository.getString(any()) } returns ""
viewModel = createViewModel()
advanceUntilIdle()
assertFalse(viewModel.uiState.value.selectedItems.contains(0))
@ -113,7 +113,7 @@ class CameraViewModelTest {
@Test
fun test_toggleItemSelection_deselectsItem() = runTest(testDispatcher) {
// Given
coEvery { mockSettingsRepository.getValue(any()) } returns ""
coEvery { mockSettingsRepository.getString(any()) } returns ""
viewModel = createViewModel()
advanceUntilIdle()
viewModel.toggleItemSelection(1)
@ -134,7 +134,7 @@ class CameraViewModelTest {
ItemFormPrefill(name = "Wasser"),
ItemFormPrefill(name = "Konserven")
)
coEvery { mockSettingsRepository.getValue(any()) } returns "test-api-key"
coEvery { mockSettingsRepository.getString(any()) } returns "test-api-key"
coEvery { mockOpenAiVisionService.analyzeImage(any(), any()) } returns items
val mockUri = mockk<Uri>()
@ -158,7 +158,7 @@ class CameraViewModelTest {
fun test_analyzePhoto_apiReturnsItems_updatesRecognizedItems() = runTest(testDispatcher) {
// Given
val items = listOf(ItemFormPrefill(name = "Brot"), ItemFormPrefill(name = "Wasser"))
coEvery { mockSettingsRepository.getValue(any()) } returns "test-api-key"
coEvery { mockSettingsRepository.getString(any()) } returns "test-api-key"
coEvery { mockOpenAiVisionService.analyzeImage(any(), any()) } returns items
every { mockContentResolver.openInputStream(any()) } answers { ByteArrayInputStream(ByteArray(100)) }
val mockUri = mockk<Uri>()
@ -180,7 +180,7 @@ class CameraViewModelTest {
@Test
fun test_analyzePhoto_photoNotReadable_setsError() = runTest(testDispatcher) {
// Given onPhotoCaptured was NOT called, capturedImageUri = null
coEvery { mockSettingsRepository.getValue(any()) } returns "test-api-key"
coEvery { mockSettingsRepository.getString(any()) } returns "test-api-key"
viewModel = createViewModel()
advanceUntilIdle()
@ -195,7 +195,7 @@ class CameraViewModelTest {
@Test
fun test_onAddFirstSelectedItem_noSelection_doesNotSetNavigation() = runTest(testDispatcher) {
// Given selectedItems is empty
coEvery { mockSettingsRepository.getValue(any()) } returns ""
coEvery { mockSettingsRepository.getString(any()) } returns ""
viewModel = createViewModel()
advanceUntilIdle()

View file

@ -4,12 +4,12 @@ import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.defaultAgeGroups
import de.krisenvorrat.app.domain.model.toJson
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.ui.settings.SettingsViewModel
import de.krisenvorrat.app.domain.usecase.CalculateCategorySummaryUseCase
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.app.domain.usecase.CalculateTotalValueUseCase
@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -143,7 +144,7 @@ class DashboardViewModelTest {
else entry
}
fakeSettingsRepository.setValue(
de.krisenvorrat.app.ui.settings.SettingsViewModel.KEY_AGE_GROUPS,
SettingsKey.StringKey.AgeGroups.key,
ageGroups.toJson()
)
fakeItemRepository.emit(
@ -167,7 +168,7 @@ class DashboardViewModelTest {
// → supplyRangeDays = 4000 / 4000 = 1.0
val allZeroAgeGroups = defaultAgeGroups().map { it.copy(count = 0) }
fakeSettingsRepository.setValue(
de.krisenvorrat.app.ui.settings.SettingsViewModel.KEY_AGE_GROUPS,
SettingsKey.StringKey.AgeGroups.key,
allZeroAgeGroups.toJson()
)
fakeItemRepository.emit(
@ -342,6 +343,20 @@ private class FakeSettingsRepository : SettingsRepository {
observeFlows.getOrPut(key) { MutableStateFlow(store[key]) }
override fun getAll(): Flow<List<SettingsEntity>> = allFlow
override suspend fun getString(key: SettingsKey.StringKey): String =
getValue(key.key) ?: key.defaultValue
override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? =
getValue(key.key)
override suspend fun setString(key: SettingsKey.StringKey, value: String) =
setValue(key.key, value)
override fun observeString(key: SettingsKey.StringKey): Flow<String> {
val flow = observeFlows.getOrPut(key.key) { MutableStateFlow(store[key.key]) }
return flow.map { it ?: key.defaultValue }
}
}
// endregion

View file

@ -1,6 +1,7 @@
package de.krisenvorrat.app.ui.inventory
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.ImportExportRepository
@ -223,6 +224,18 @@ private class FakeSettingsRepository : SettingsRepository {
override fun getAll(): Flow<List<SettingsEntity>> =
MutableStateFlow(store.map { SettingsEntity(key = it.key, value = it.value) })
override suspend fun getString(key: SettingsKey.StringKey): String =
getValue(key.key) ?: key.defaultValue
override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? =
getValue(key.key)
override suspend fun setString(key: SettingsKey.StringKey, value: String) =
setValue(key.key, value)
override fun observeString(key: SettingsKey.StringKey): Flow<String> =
MutableStateFlow(store[key.key] ?: key.defaultValue)
}
private class FakeSyncService : SyncService {

View file

@ -7,6 +7,7 @@ import android.net.Uri
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
@ -20,6 +21,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -102,7 +104,7 @@ class SettingsViewModelTest {
if (entry.ageGroup == AgeGroup.ERWACHSENER) entry.copy(count = 3, kcalPerDay = 2500)
else entry
}
fakeSettingsRepository.store[SettingsViewModel.KEY_AGE_GROUPS] = stored.toJson()
fakeSettingsRepository.store[SettingsKey.StringKey.AgeGroups.key] = stored.toJson()
viewModel = createViewModel()
// When
@ -117,8 +119,8 @@ class SettingsViewModelTest {
@Test
fun test_init_withLegacyHouseholdSize_migratesErwachsener() = runTest(testDispatcher) {
// Given legacy keys present, no age_groups key
fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "3"
fakeSettingsRepository.store[SettingsViewModel.KEY_DAILY_KCAL_PER_PERSON] = "2500"
fakeSettingsRepository.store[SettingsKey.StringKey.HouseholdSize.key] = "3"
fakeSettingsRepository.store[SettingsKey.StringKey.DailyKcalPerPerson.key] = "2500"
viewModel = createViewModel()
// When
@ -133,7 +135,7 @@ class SettingsViewModelTest {
@Test
fun test_init_withLegacyHouseholdSizeOnly_migratesWithDefaultKcal() = runTest(testDispatcher) {
// Given nur KEY_HOUSEHOLD_SIZE gesetzt, kein KEY_DAILY_KCAL_PER_PERSON
fakeSettingsRepository.store[SettingsViewModel.KEY_HOUSEHOLD_SIZE] = "3"
fakeSettingsRepository.store[SettingsKey.StringKey.HouseholdSize.key] = "3"
viewModel = createViewModel()
// When
@ -215,7 +217,7 @@ class SettingsViewModelTest {
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertTrue(fakeSettingsRepository.store.containsKey(SettingsViewModel.KEY_AGE_GROUPS))
assertTrue(fakeSettingsRepository.store.containsKey(SettingsKey.StringKey.AgeGroups.key))
}
@Test
@ -568,10 +570,10 @@ class SettingsViewModelTest {
@Test
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com"
fakeSettingsRepository.store[SettingsViewModel.KEY_AUTH_USERNAME] = "testuser"
fakeSettingsRepository.store[SettingsViewModel.KEY_ACCESS_TOKEN] = "access-token-123"
fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000"
fakeSettingsRepository.store[SettingsKey.StringKey.ServerUrl.key] = "https://example.com"
fakeSettingsRepository.store[SettingsKey.StringKey.AuthUsername.key] = "testuser"
fakeSettingsRepository.store[SettingsKey.StringKey.AuthAccessToken.key] = "access-token-123"
fakeSettingsRepository.store[SettingsKey.StringKey.SyncLastTimestamp.key] = "1715700000000"
viewModel = createViewModel()
@ -657,9 +659,9 @@ class SettingsViewModelTest {
@Test
fun test_logout_clearsLoginState() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsViewModel.KEY_AUTH_USERNAME] = "testuser"
fakeSettingsRepository.store[SettingsViewModel.KEY_ACCESS_TOKEN] = "token-123"
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://server.com"
fakeSettingsRepository.store[SettingsKey.StringKey.AuthUsername.key] = "testuser"
fakeSettingsRepository.store[SettingsKey.StringKey.AuthAccessToken.key] = "token-123"
fakeSettingsRepository.store[SettingsKey.StringKey.ServerUrl.key] = "https://server.com"
viewModel = createViewModel()
advanceUntilIdle()
@ -686,7 +688,7 @@ class SettingsViewModelTest {
// Then
assertTrue(viewModel.uiState.value.isSaved)
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsKey.StringKey.ServerUrl.key])
}
@Test
@ -703,7 +705,7 @@ class SettingsViewModelTest {
val state = viewModel.uiState.value
assertTrue(state.syncStatus is SyncStatus.Success)
assertNotNull(state.lastSyncTime)
assertNotNull(fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP])
assertNotNull(fakeSettingsRepository.store[SettingsKey.StringKey.SyncLastTimestamp.key])
}
@Test
@ -738,7 +740,7 @@ class SettingsViewModelTest {
val state = viewModel.uiState.value
assertTrue(state.syncStatus is SyncStatus.Success)
assertNotNull(state.lastSyncTime)
assertNotNull(fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP])
assertNotNull(fakeSettingsRepository.store[SettingsKey.StringKey.SyncLastTimestamp.key])
}
@Test
@ -792,6 +794,18 @@ private class FakeSettingsRepository : SettingsRepository {
MutableStateFlow(store[key])
override fun getAll(): Flow<List<SettingsEntity>> = allFlow
override suspend fun getString(key: SettingsKey.StringKey): String =
getValue(key.key) ?: key.defaultValue
override suspend fun getStringOrNull(key: SettingsKey.StringKey): String? =
getValue(key.key)
override suspend fun setString(key: SettingsKey.StringKey, value: String) =
setValue(key.key, value)
override fun observeString(key: SettingsKey.StringKey): Flow<String> =
observeValue(key.key).map { it ?: key.defaultValue }
}
private class FakeImportExportRepository : ImportExportRepository {