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:
Jens Reinemann 2026-05-14 21:30:55 +02:00
parent 215790d68e
commit a972ce34ca
6 changed files with 478 additions and 24 deletions

View file

@ -37,12 +37,21 @@ internal class ImportExportRepositoryImpl @Inject constructor(
} }
override suspend fun exportToJson(): String = withContext(Dispatchers.IO) { 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 categories = categoryDao.getAll().first()
val locations = locationDao.getAll().first() val locations = locationDao.getAll().first()
val items = itemDao.getAll().first() val items = itemDao.getAll().first()
val settings = settingsDao.getAll().first() val settings = settingsDao.getAll().first()
val exportData = InventoryDto( return InventoryDto(
categories = categories.map { CategoryDto(id = it.id, name = it.name) }, categories = categories.map { CategoryDto(id = it.id, name = it.name) },
locations = locations.map { LocationDto(id = it.id, name = it.name) }, locations = locations.map { LocationDto(id = it.id, name = it.name) },
items = items.map { item -> items = items.map { item ->
@ -63,17 +72,27 @@ internal class ImportExportRepositoryImpl @Inject constructor(
}, },
settings = settings.map { SettingDto(key = it.key, value = it.value) } 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) { override suspend fun importFromJson(json: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val exportData = jsonSerializer.decodeFromString<InventoryDto>(json) val exportData = jsonSerializer.decodeFromString<InventoryDto>(json)
check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" } check(exportData.version == 1) { "Unsupported export format version: ${exportData.version}" }
applyInventoryDto(exportData)
}
}
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
applyInventoryDto(dto)
}
}
private suspend fun applyInventoryDto(dto: InventoryDto) {
transaction.execute { transaction.execute {
categoryDao.upsertAll(exportData.categories.map { CategoryEntity(id = it.id, name = it.name) }) categoryDao.upsertAll(dto.categories.map { CategoryEntity(id = it.id, name = it.name) })
locationDao.upsertAll(exportData.locations.map { LocationEntity(id = it.id, name = it.name) }) locationDao.upsertAll(dto.locations.map { LocationEntity(id = it.id, name = it.name) })
itemDao.upsertAll(exportData.items.map { item -> itemDao.upsertAll(dto.items.map { item ->
ItemEntity( ItemEntity(
id = item.id, id = item.id,
name = item.name, name = item.name,
@ -89,8 +108,7 @@ internal class ImportExportRepositoryImpl @Inject constructor(
lastUpdated = item.lastUpdated lastUpdated = item.lastUpdated
) )
}) })
settingsDao.upsertAll(exportData.settings.map { SettingsEntity(key = it.key, value = it.value) }) settingsDao.upsertAll(dto.settings.map { SettingsEntity(key = it.key, value = it.value) })
}
} }
} }

View file

@ -1,7 +1,11 @@
package de.krisenvorrat.app.domain.repository package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.shared.model.InventoryDto
internal interface ImportExportRepository { internal interface ImportExportRepository {
suspend fun exportToJson(): String suspend fun exportToJson(): String
suspend fun importFromJson(json: String): Result<Unit> suspend fun importFromJson(json: String): Result<Unit>
suspend fun exportToMarkdown(): String suspend fun exportToMarkdown(): String
suspend fun exportToInventoryDto(): InventoryDto
suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit>
} }

View file

@ -4,12 +4,16 @@ import android.content.Intent
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@ -27,9 +31,11 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -184,6 +190,109 @@ internal fun SettingsScreen(
modifier = Modifier.padding(8.dp) 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
)
}
} }
} }
} }

View file

@ -12,9 +12,20 @@ internal data class SettingsUiState(
val exportError: String? = null, val exportError: String? = null,
val isImporting: Boolean = false, val isImporting: Boolean = false,
val importResult: ImportResult? = null, 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 { internal sealed interface ImportResult {
data object Success : ImportResult data object Success : ImportResult
data class Error(val message: String) : ImportResult data class Error(val message: String) : ImportResult

View file

@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.domain.repository.ImportExportRepository import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -16,12 +17,17 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File 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 import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class SettingsViewModel @Inject constructor( internal class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository, private val importExportRepository: ImportExportRepository,
private val syncService: SyncService,
@ApplicationContext private val context: Context @ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
@ -39,11 +45,17 @@ internal class SettingsViewModel @Inject constructor(
?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString() ?: CalculateSupplyRangeUseCase.DEFAULT_HOUSEHOLD_SIZE.toString()
val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON) val dailyKcal = settingsRepository.getValue(KEY_DAILY_KCAL_PER_PERSON)
?: CalculateSupplyRangeUseCase.DEFAULT_DAILY_KCAL_PER_PERSON.toString() ?: 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 { _uiState.update {
it.copy( it.copy(
householdSize = householdSize, householdSize = householdSize,
dailyKcalPerPerson = dailyKcal, dailyKcalPerPerson = dailyKcal,
serverUrl = serverUrl,
apiKey = apiKey,
lastSyncTime = lastSyncTimestamp?.let { ts -> formatTimestamp(ts) },
isLoading = false isLoading = false
) )
} }
@ -67,6 +79,14 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(dailyKcalPerPerson = value, isSaved = false) } _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() { fun saveSettings() {
viewModelScope.launch { viewModelScope.launch {
try { try {
@ -80,6 +100,9 @@ internal class SettingsViewModel @Inject constructor(
settingsRepository.setValue(KEY_DAILY_KCAL_PER_PERSON, dailyKcal) 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) } _uiState.update { it.copy(isSaved = true) }
} catch (e: Exception) { } catch (e: Exception) {
// Fehler beim Speichern UI zeigt keinen Saved-Status // Fehler beim Speichern UI zeigt keinen Saved-Status
@ -166,8 +189,93 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(importResult = null) } _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 { companion object {
const val KEY_HOUSEHOLD_SIZE = "household_size" const val KEY_HOUSEHOLD_SIZE = "household_size"
const val KEY_DAILY_KCAL_PER_PERSON = "daily_kcal_per_person" 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"
} }
} }

View file

@ -5,9 +5,12 @@ import androidx.core.content.FileProvider
import android.content.ContentResolver import android.content.ContentResolver
import android.net.Uri import android.net.Uri
import de.krisenvorrat.app.data.db.entity.SettingsEntity 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.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase import de.krisenvorrat.app.domain.usecase.CalculateSupplyRangeUseCase
import de.krisenvorrat.shared.model.InventoryDto
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -38,6 +41,7 @@ class SettingsViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeSettingsRepository: FakeSettingsRepository private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var fakeImportExportRepository: FakeImportExportRepository private lateinit var fakeImportExportRepository: FakeImportExportRepository
private lateinit var fakeSyncService: FakeSyncService
private lateinit var mockContext: Context private lateinit var mockContext: Context
private lateinit var tempDir: File private lateinit var tempDir: File
private lateinit var viewModel: SettingsViewModel private lateinit var viewModel: SettingsViewModel
@ -47,6 +51,7 @@ class SettingsViewModelTest {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
fakeSettingsRepository = FakeSettingsRepository() fakeSettingsRepository = FakeSettingsRepository()
fakeImportExportRepository = FakeImportExportRepository() fakeImportExportRepository = FakeImportExportRepository()
fakeSyncService = FakeSyncService()
tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports") tempDir = File(System.getProperty("java.io.tmpdir"), "test_exports")
tempDir.mkdirs() tempDir.mkdirs()
mockContext = mockk(relaxed = true) { mockContext = mockk(relaxed = true) {
@ -64,6 +69,7 @@ class SettingsViewModelTest {
private fun createViewModel() = SettingsViewModel( private fun createViewModel() = SettingsViewModel(
settingsRepository = fakeSettingsRepository, settingsRepository = fakeSettingsRepository,
importExportRepository = fakeImportExportRepository, importExportRepository = fakeImportExportRepository,
syncService = fakeSyncService,
context = mockContext context = mockContext
) )
@ -446,6 +452,159 @@ class SettingsViewModelTest {
// Then // Then
assertNull(viewModel.uiState.value.importResult) 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 // region Test Helpers
@ -472,6 +631,12 @@ private class FakeImportExportRepository : ImportExportRepository {
var markdownResult = "# Krisenvorrat Inventar\n" var markdownResult = "# Krisenvorrat Inventar\n"
var shouldThrow = false var shouldThrow = false
var importShouldFail = false var importShouldFail = false
var inventoryDto = InventoryDto(
categories = emptyList(),
locations = emptyList(),
items = emptyList(),
settings = emptyList()
)
override suspend fun exportToJson(): String { override suspend fun exportToJson(): String {
if (shouldThrow) throw RuntimeException("Test error") if (shouldThrow) throw RuntimeException("Test error")
@ -487,6 +652,45 @@ private class FakeImportExportRepository : ImportExportRepository {
if (shouldThrow) throw RuntimeException("Test error") if (shouldThrow) throw RuntimeException("Test error")
return markdownResult 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 // endregion