feat(settings): add sync UI with server configuration and push/pull actions
Closes #45 SettingsScreen: - Server-URL and API-Key input fields (API-Key masked as password) - Push (upload) and Pull (download) sync buttons - Sync status indicator (running/success/error) - Last sync timestamp display (persisted via Room settings) SettingsViewModel: - Inject SyncService for server communication - pushSync(): exports local inventory to InventoryDto, uploads via SyncService - pullSync(): downloads InventoryDto from server, imports into local DB - Persists server_url, api_key, sync_last_timestamp in Room settings table ImportExportRepository: - New methods exportToInventoryDto() and importFromInventoryDto() - Refactored to eliminate code duplication via buildInventoryDto() and applyInventoryDto() helper methods Tests: - 8 new ViewModel tests covering sync settings loading, push/pull success, push/pull error scenarios, and sync status dismissal - FakeSyncService and extended FakeImportExportRepository for testing
This commit is contained in:
parent
215790d68e
commit
a972ce34ca
6 changed files with 478 additions and 24 deletions
|
|
@ -37,12 +37,21 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
|||
}
|
||||
|
||||
override suspend fun exportToJson(): String = withContext(Dispatchers.IO) {
|
||||
val exportData = buildInventoryDto()
|
||||
jsonSerializer.encodeToString(InventoryDto.serializer(), exportData)
|
||||
}
|
||||
|
||||
override suspend fun exportToInventoryDto(): InventoryDto = withContext(Dispatchers.IO) {
|
||||
buildInventoryDto()
|
||||
}
|
||||
|
||||
private suspend fun buildInventoryDto(): InventoryDto {
|
||||
val categories = categoryDao.getAll().first()
|
||||
val locations = locationDao.getAll().first()
|
||||
val items = itemDao.getAll().first()
|
||||
val settings = settingsDao.getAll().first()
|
||||
|
||||
val exportData = InventoryDto(
|
||||
return InventoryDto(
|
||||
categories = categories.map { CategoryDto(id = it.id, name = it.name) },
|
||||
locations = locations.map { LocationDto(id = it.id, name = it.name) },
|
||||
items = items.map { item ->
|
||||
|
|
@ -63,34 +72,43 @@ internal class ImportExportRepositoryImpl @Inject constructor(
|
|||
},
|
||||
settings = settings.map { SettingDto(key = it.key, value = it.value) }
|
||||
)
|
||||
jsonSerializer.encodeToString(InventoryDto.serializer(), exportData)
|
||||
}
|
||||
|
||||
override suspend fun importFromJson(json: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val exportData = jsonSerializer.decodeFromString<InventoryDto>(json)
|
||||
check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" }
|
||||
transaction.execute {
|
||||
categoryDao.upsertAll(exportData.categories.map { CategoryEntity(id = it.id, name = it.name) })
|
||||
locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) })
|
||||
itemDao.upsertAll(exportData.items.map { item ->
|
||||
ItemEntity(
|
||||
id = item.id,
|
||||
name = item.name,
|
||||
categoryId = item.categoryId,
|
||||
quantity = item.quantity,
|
||||
unit = item.unit,
|
||||
unitPrice = item.unitPrice,
|
||||
kcalPer100g = item.kcalPer100g,
|
||||
expiryDate = item.expiryDate?.let { LocalDate.parse(it) },
|
||||
locationId = item.locationId,
|
||||
minStock = item.minStock,
|
||||
notes = item.notes,
|
||||
lastUpdated = item.lastUpdated
|
||||
)
|
||||
})
|
||||
settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) })
|
||||
}
|
||||
applyInventoryDto(exportData)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
applyInventoryDto(dto)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyInventoryDto(dto: InventoryDto) {
|
||||
transaction.execute {
|
||||
categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) })
|
||||
locationDao.upsertAll(dto.locations.map { LocationEntity(id = it.id, name = it.name) })
|
||||
itemDao.upsertAll(dto.items.map { item ->
|
||||
ItemEntity(
|
||||
id = item.id,
|
||||
name = item.name,
|
||||
categoryId = item.categoryId,
|
||||
quantity = item.quantity,
|
||||
unit = item.unit,
|
||||
unitPrice = item.unitPrice,
|
||||
kcalPer100g = item.kcalPer100g,
|
||||
expiryDate = item.expiryDate?.let { LocalDate.parse(it) },
|
||||
locationId = item.locationId,
|
||||
minStock = item.minStock,
|
||||
notes = item.notes,
|
||||
lastUpdated = item.lastUpdated
|
||||
)
|
||||
})
|
||||
settingsDao.upsertAll(dto.settings.map { SettingsEntity(key = it.key, value = it.value) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
package de.krisenvorrat.app.domain.repository
|
||||
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
|
||||
internal interface ImportExportRepository {
|
||||
suspend fun exportToJson(): String
|
||||
suspend fun importFromJson(json: String): Result<Unit>
|
||||
suspend fun exportToMarkdown(): String
|
||||
suspend fun exportToInventoryDto(): InventoryDto
|
||||
suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,12 +4,16 @@ import android.content.Intent
|
|||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
|
@ -27,9 +31,11 @@ import androidx.compose.material3.TopAppBar
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
|
|
@ -184,6 +190,109 @@ internal fun SettingsScreen(
|
|||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = "Server-Synchronisierung",
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.serverUrl,
|
||||
onValueChange = viewModel::onServerUrlChanged,
|
||||
label = { Text("Server-URL") },
|
||||
placeholder = { Text("https://example.com") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = uiState.apiKey,
|
||||
onValueChange = viewModel::onApiKeyChanged,
|
||||
label = { Text("API-Key") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val isSyncEnabled = uiState.serverUrl.isNotBlank() &&
|
||||
uiState.apiKey.isNotBlank() &&
|
||||
uiState.syncStatus !is SyncStatus.Running
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::pushSync,
|
||||
enabled = isSyncEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Push (Hochladen)")
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = viewModel::pullSync,
|
||||
enabled = isSyncEnabled,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Pull (Herunterladen)")
|
||||
}
|
||||
}
|
||||
|
||||
when (val syncStatus = uiState.syncStatus) {
|
||||
is SyncStatus.Running -> {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Synchronisierung läuft…",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
is SyncStatus.Success -> {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Synchronisierung erfolgreich",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
is SyncStatus.Error -> {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = syncStatus.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
is SyncStatus.Idle -> { /* no indicator */ }
|
||||
}
|
||||
|
||||
if (uiState.lastSyncTime != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Letzte Synchronisierung: ${uiState.lastSyncTime}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,20 @@ internal data class SettingsUiState(
|
|||
val exportError: String? = null,
|
||||
val isImporting: Boolean = false,
|
||||
val importResult: ImportResult? = null,
|
||||
val pendingImportUri: Uri? = null
|
||||
val pendingImportUri: Uri? = null,
|
||||
val serverUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
val syncStatus: SyncStatus = SyncStatus.Idle,
|
||||
val lastSyncTime: String? = null
|
||||
)
|
||||
|
||||
internal sealed interface SyncStatus {
|
||||
data object Idle : SyncStatus
|
||||
data object Running : SyncStatus
|
||||
data object Success : SyncStatus
|
||||
data class Error(val message: String) : SyncStatus
|
||||
}
|
||||
|
||||
internal sealed interface ImportResult {
|
||||
data object Success : ImportResult
|
||||
data class Error(val message: String) : ImportResult
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.app.domain.repository.SyncService
|
||||
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -16,12 +17,17 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class SettingsViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val importExportRepository: ImportExportRepository,
|
||||
private val syncService: SyncService,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
|
|
@ -39,11 +45,17 @@ internal class SettingsViewModel @Inject constructor(
|
|||
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString()
|
||||
val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)
|
||||
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString()
|
||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL) ?: ""
|
||||
val apiKey = settingsRepository.getValue(KEY_API_KEY) ?: ""
|
||||
val lastSyncTimestamp = settingsRepository.getValue(KEY_SYNC_LAST_TIMESTAMP)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
householdSize = householdSize,
|
||||
dailyKcalPerPerson = dailyKcal,
|
||||
serverUrl = serverUrl,
|
||||
apiKey = apiKey,
|
||||
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
|
||||
isLoading = false
|
||||
)
|
||||
}
|
||||
|
|
@ -67,6 +79,14 @@ internal class SettingsViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) }
|
||||
}
|
||||
|
||||
fun onServerUrlChanged(value: String) {
|
||||
_uiState.update { it.copy(serverUrl = value, isSaved = false) }
|
||||
}
|
||||
|
||||
fun onApiKeyChanged(value: String) {
|
||||
_uiState.update { it.copy(apiKey = value, isSaved = false) }
|
||||
}
|
||||
|
||||
fun saveSettings() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
|
|
@ -80,6 +100,9 @@ internal class SettingsViewModel @Inject constructor(
|
|||
settingsRepository.setValue(KEY_DAILY_KCAL_PER_PERSON, dailyKcal)
|
||||
}
|
||||
|
||||
settingsRepository.setValue(KEY_SERVER_URL, _uiState.value.serverUrl)
|
||||
settingsRepository.setValue(KEY_API_KEY, _uiState.value.apiKey)
|
||||
|
||||
_uiState.update { it.copy(isSaved = true) }
|
||||
} catch (e: Exception) {
|
||||
// Fehler beim Speichern – UI zeigt keinen Saved-Status
|
||||
|
|
@ -166,8 +189,93 @@ internal class SettingsViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(importResult = null) }
|
||||
}
|
||||
|
||||
fun pushSync() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(syncStatus = SyncStatus.Running) }
|
||||
try {
|
||||
val inventoryDto = importExportRepository.exportToInventoryDto()
|
||||
val result = syncService.uploadInventory(inventoryDto)
|
||||
result.fold(
|
||||
onSuccess = {
|
||||
val now = Instant.now().toEpochMilli().toString()
|
||||
settingsRepository.setValue(KEY_SYNC_LAST_TIMESTAMP, now)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
syncStatus = SyncStatus.Success,
|
||||
lastSyncTime = formatTimestamp(now)
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update {
|
||||
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Push fehlgeschlagen"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Push fehlgeschlagen"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pullSync() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(syncStatus = SyncStatus.Running) }
|
||||
try {
|
||||
val result = syncService.downloadInventory()
|
||||
result.fold(
|
||||
onSuccess = { inventoryDto ->
|
||||
importExportRepository.importFromInventoryDto(inventoryDto).fold(
|
||||
onSuccess = {
|
||||
val now = Instant.now().toEpochMilli().toString()
|
||||
settingsRepository.setValue(KEY_SYNC_LAST_TIMESTAMP, now)
|
||||
loadSettings()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
syncStatus = SyncStatus.Success,
|
||||
lastSyncTime = formatTimestamp(now)
|
||||
)
|
||||
}
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update {
|
||||
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Import fehlgeschlagen"))
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
onFailure = { e ->
|
||||
_uiState.update {
|
||||
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Pull fehlgeschlagen"))
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(syncStatus = SyncStatus.Error(e.message ?: "Pull fehlgeschlagen"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSyncStatusDismissed() {
|
||||
_uiState.update { it.copy(syncStatus = SyncStatus.Idle) }
|
||||
}
|
||||
|
||||
private fun formatTimestamp(epochMillisStr: String): String? {
|
||||
val millis = epochMillisStr.toLongOrNull() ?: return null
|
||||
val instant = Instant.ofEpochMilli(millis)
|
||||
val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm", Locale.GERMAN)
|
||||
return formatter.format(instant.atZone(ZoneId.systemDefault()))
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY_HOUSEHOLD_SIZE = "household_size"
|
||||
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person"
|
||||
const val KEY_SERVER_URL = "server_url"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import androidx.core.content.FileProvider
|
|||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import de.krisenvorrat.app.data.db.entity.SettingsEntity
|
||||
import de.krisenvorrat.app.domain.model.SyncError
|
||||
import de.krisenvorrat.app.domain.repository.ImportExportRepository
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.app.domain.repository.SyncService
|
||||
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -38,6 +41,7 @@ class SettingsViewModelTest {
|
|||
private val testDispatcher = StandardTestDispatcher()
|
||||
private lateinit var fakeSettingsRepository: FakeSettingsRepository
|
||||
private lateinit var fakeImportExportRepository: FakeImportExportRepository
|
||||
private lateinit var fakeSyncService: FakeSyncService
|
||||
private lateinit var mockContext: Context
|
||||
private lateinit var tempDir: File
|
||||
private lateinit var viewModel: SettingsViewModel
|
||||
|
|
@ -47,6 +51,7 @@ class SettingsViewModelTest {
|
|||
Dispatchers.setMain(testDispatcher)
|
||||
fakeSettingsRepository = FakeSettingsRepository()
|
||||
fakeImportExportRepository = FakeImportExportRepository()
|
||||
fakeSyncService = FakeSyncService()
|
||||
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
|
||||
tempDir.mkdirs()
|
||||
mockContext = mockk(relaxed = true) {
|
||||
|
|
@ -64,6 +69,7 @@ class SettingsViewModelTest {
|
|||
private fun createViewModel() = SettingsViewModel(
|
||||
settingsRepository = fakeSettingsRepository,
|
||||
importExportRepository = fakeImportExportRepository,
|
||||
syncService = fakeSyncService,
|
||||
context = mockContext
|
||||
)
|
||||
|
||||
|
|
@ -446,6 +452,159 @@ class SettingsViewModelTest {
|
|||
// Then
|
||||
assertNull(viewModel.uiState.value.importResult)
|
||||
}
|
||||
|
||||
// --- Sync Tests ---
|
||||
|
||||
@Test
|
||||
fun test_init_withStoredSyncSettings_loadsSyncFields() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL] = "https://example.com"
|
||||
fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY] = "my-secret-key"
|
||||
fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP] = "1715700000000"
|
||||
|
||||
viewModel = createViewModel()
|
||||
|
||||
// When
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertEquals("https://example.com", state.serverUrl)
|
||||
assertEquals("my-secret-key", state.apiKey)
|
||||
assertNotNull(state.lastSyncTime)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_onServerUrlChanged_updatesState() = runTest(testDispatcher) {
|
||||
// Given
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onServerUrlChanged("https://new-server.com")
|
||||
|
||||
// Then
|
||||
assertEquals("https://new-server.com", viewModel.uiState.value.serverUrl)
|
||||
assertFalse(viewModel.uiState.value.isSaved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_onApiKeyChanged_updatesState() = runTest(testDispatcher) {
|
||||
// Given
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onApiKeyChanged("new-api-key")
|
||||
|
||||
// Then
|
||||
assertEquals("new-api-key", viewModel.uiState.value.apiKey)
|
||||
assertFalse(viewModel.uiState.value.isSaved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_saveSettings_persistsServerUrlAndApiKey() = runTest(testDispatcher) {
|
||||
// Given
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.onHouseholdSizeChanged("2")
|
||||
viewModel.onServerUrlChanged("https://myserver.com")
|
||||
viewModel.onApiKeyChanged("secret-key-123")
|
||||
|
||||
// When
|
||||
viewModel.saveSettings()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.isSaved)
|
||||
assertEquals("https://myserver.com", fakeSettingsRepository.store[SettingsViewModel.KEY_SERVER_URL])
|
||||
assertEquals("secret-key-123", fakeSettingsRepository.store[SettingsViewModel.KEY_API_KEY])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_pushSync_success_updatesSyncStatusAndTimestamp() = runTest(testDispatcher) {
|
||||
// Given
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.pushSync()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.syncStatus is SyncStatus.Success)
|
||||
assertNotNull(state.lastSyncTime)
|
||||
assertNotNull(fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_pushSync_failure_setsSyncStatusError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeSyncService.uploadShouldFail = true
|
||||
fakeSyncService.uploadError = SyncError.ConnectionError()
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.pushSync()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.syncStatus is SyncStatus.Error)
|
||||
assertTrue((state.syncStatus as SyncStatus.Error).message.contains("Server nicht erreichbar"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_pullSync_success_updatesSyncStatusAndTimestamp() = runTest(testDispatcher) {
|
||||
// Given
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.pullSync()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.syncStatus is SyncStatus.Success)
|
||||
assertNotNull(state.lastSyncTime)
|
||||
assertNotNull(fakeSettingsRepository.store[SettingsViewModel.KEY_SYNC_LAST_TIMESTAMP])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_pullSync_authError_setsSyncStatusError() = runTest(testDispatcher) {
|
||||
// Given
|
||||
fakeSyncService.downloadShouldFail = true
|
||||
fakeSyncService.downloadError = SyncError.AuthError()
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.pullSync()
|
||||
advanceUntilIdle()
|
||||
|
||||
// Then
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.syncStatus is SyncStatus.Error)
|
||||
assertTrue((state.syncStatus as SyncStatus.Error).message.contains("Authentifizierung"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_onSyncStatusDismissed_resetsSyncStatus() = runTest(testDispatcher) {
|
||||
// Given
|
||||
viewModel = createViewModel()
|
||||
advanceUntilIdle()
|
||||
viewModel.pushSync()
|
||||
advanceUntilIdle()
|
||||
|
||||
// When
|
||||
viewModel.onSyncStatusDismissed()
|
||||
|
||||
// Then
|
||||
assertTrue(viewModel.uiState.value.syncStatus is SyncStatus.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
// region Test Helpers
|
||||
|
|
@ -472,6 +631,12 @@ private class FakeImportExportRepository : ImportExportRepository {
|
|||
var markdownResult = "# Krisenvorrat Inventar\n"
|
||||
var shouldThrow = false
|
||||
var importShouldFail = false
|
||||
var inventoryDto = InventoryDto(
|
||||
categories = emptyList(),
|
||||
locations = emptyList(),
|
||||
items = emptyList(),
|
||||
settings = emptyList()
|
||||
)
|
||||
|
||||
override suspend fun exportToJson(): String {
|
||||
if (shouldThrow) throw RuntimeException("Test error")
|
||||
|
|
@ -487,6 +652,45 @@ private class FakeImportExportRepository : ImportExportRepository {
|
|||
if (shouldThrow) throw RuntimeException("Test error")
|
||||
return markdownResult
|
||||
}
|
||||
|
||||
override suspend fun exportToInventoryDto(): InventoryDto {
|
||||
if (shouldThrow) throw RuntimeException("Test error")
|
||||
return inventoryDto
|
||||
}
|
||||
|
||||
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> {
|
||||
if (shouldThrow || importShouldFail) return Result.failure(RuntimeException("Test error"))
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSyncService : SyncService {
|
||||
var uploadShouldFail = false
|
||||
var downloadShouldFail = false
|
||||
var uploadError: Exception = RuntimeException("Upload failed")
|
||||
var downloadError: Exception = RuntimeException("Download failed")
|
||||
var downloadResult = InventoryDto(
|
||||
categories = emptyList(),
|
||||
locations = emptyList(),
|
||||
items = emptyList(),
|
||||
settings = emptyList()
|
||||
)
|
||||
var uploadResult = InventoryDto(
|
||||
categories = emptyList(),
|
||||
locations = emptyList(),
|
||||
items = emptyList(),
|
||||
settings = emptyList()
|
||||
)
|
||||
|
||||
override suspend fun downloadInventory(): Result<InventoryDto> {
|
||||
if (downloadShouldFail) return Result.failure(downloadError)
|
||||
return Result.success(downloadResult)
|
||||
}
|
||||
|
||||
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> {
|
||||
if (uploadShouldFail) return Result.failure(uploadError)
|
||||
return Result.success(uploadResult)
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
|
|
|||
Loading…
Reference in a new issue