From a972ce34ca97d2a3258022cc541e46ea824465b8 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Thu, 14 May 2026 21:30:55 +0200 Subject: [PATCH] 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 --- .../data/export/ImportExportRepositoryImpl.kt | 64 ++++-- .../repository/ImportExportRepository.kt | 4 + .../app/ui/settings/SettingsScreen.kt | 109 ++++++++++ .../app/ui/settings/SettingsUiState.kt | 13 +- .../app/ui/settings/SettingsViewModel.kt | 108 ++++++++++ .../app/ui/settings/SettingsViewModelTest.kt | 204 ++++++++++++++++++ 6 files changed, 478 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt index e54f015..74b5a3d 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/export/ImportExportRepositoryImpl.kt @@ -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 = withContext(Dispatchers.IO) { runCatching { val exportData = jsonSerializer.decodeFromString(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 = 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) }) } } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt index 65c9ced..a6bb5e8 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/ImportExportRepository.kt @@ -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 suspend fun exportToMarkdown(): String + suspend fun exportToInventoryDto(): InventoryDto + suspend fun importFromInventoryDto(dto: InventoryDto): Result } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt index edc3090..7a11594 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsScreen.kt @@ -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 + ) + } } } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt index 4282450..5bbd471 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsUiState.kt @@ -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 diff --git a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt index 24f24a0..f1343cb 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/settings/SettingsViewModel.kt @@ -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" } } diff --git a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt index f94d0ad..c239817 100644 --- a/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/ui/settings/SettingsViewModelTest.kt @@ -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 { + 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 { + if (downloadShouldFail) return Result.failure(downloadError) + return Result.success(downloadResult) + } + + override suspend fun uploadInventory(inventory: InventoryDto): Result { + if (uploadShouldFail) return Result.failure(uploadError) + return Result.success(uploadResult) + } } // endregion