feat: Multi-Inventar auf Client-Seite (#79)

Server:
- Inventories-Tabelle um name-Spalte erweitert
- Neue User-facing REST-Routes: GET/POST /api/inventories,
  POST /api/inventories/{id}/switch
- LoginResponse enthält inventoryId + inventoryName
- InventoryRepository: createInventory(name), getUserInventories(),
  getInventoryName()
- AuthRoutes: inventoryRepository injiziert für Login-Response

Shared:
- InventoryInfoDto (id, name, isActive)
- CreateInventoryRequest (name)

Client:
- SettingsKeys: ACTIVE_INVENTORY_ID, ACTIVE_INVENTORY_NAME
- SyncService: listInventories(), createInventory(), switchInventory()
- SyncServiceImpl: Implementierung der drei Endpunkte + Login
  speichert inventoryId/Name
- InventoryPickerViewModel: Laden, Erstellen, Wechseln von Inventaren
  mit automatischem Re-Sync
- InventoryPickerSheet: ModalBottomSheet mit Inventarliste, Auswahl
  und Erstellen-Dialog
- MainScreen: TopAppBar zeigt aktiven Inventar-Namen mit Wechsel-Button

Tests:
- 8 neue Server-Tests (InventoryManagementTest)
- 8 neue Client-Tests (InventoryPickerViewModelTest)
- Bestehende FakeSyncService in 2 Test-Dateien aktualisiert

Closes #79
This commit is contained in:
Jens Reinemann 2026-05-17 03:55:08 +02:00
parent 6711a0e056
commit eb9ab6aa54
20 changed files with 1155 additions and 6 deletions

View file

@ -10,7 +10,9 @@ internal data class LoginResponse(
val accessToken: String,
val refreshToken: String,
val userId: String,
val username: String
val username: String,
val inventoryId: String? = null,
val inventoryName: String? = null
)
@Serializable

View file

@ -4,7 +4,9 @@ import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.shared.model.CreateInventoryRequest
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.InventoryInfoDto
import de.krisenvorrat.shared.model.ItemDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
@ -88,6 +90,12 @@ internal class SyncServiceImpl @Inject constructor(
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId)
loginResponse.inventoryId?.let {
settingsRepository.setValue(KEY_ACTIVE_INVENTORY_ID, it)
}
loginResponse.inventoryName?.let {
settingsRepository.setValue(KEY_ACTIVE_INVENTORY_NAME, it)
}
Result.success(Unit)
}
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
@ -212,11 +220,104 @@ internal class SyncServiceImpl @Inject constructor(
)
}
override suspend fun listInventories(): Result<List<InventoryInfoDto>> =
executeInventoryInfoListRequest { serverUrl, token ->
val response = httpClient.get("$serverUrl/api/inventories") {
header("Authorization", "Bearer $token")
}
when (response.status) {
HttpStatusCode.OK -> Result.success(response.body())
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
else -> Result.failure(
SyncError.ServerError(response.status.value, response.status.description)
)
}
}
override suspend fun createInventory(name: String): Result<InventoryInfoDto> =
executeInventoryInfoRequest { serverUrl, token ->
val response = httpClient.post("$serverUrl/api/inventories") {
header("Authorization", "Bearer $token")
contentType(ContentType.Application.Json)
setBody(CreateInventoryRequest(name))
}
when (response.status) {
HttpStatusCode.Created -> Result.success(response.body())
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
else -> Result.failure(
SyncError.ServerError(response.status.value, response.status.description)
)
}
}
override suspend fun switchInventory(inventoryId: String): Result<InventoryInfoDto> =
executeInventoryInfoRequest { serverUrl, token ->
val response = httpClient.post("$serverUrl/api/inventories/$inventoryId/switch") {
header("Authorization", "Bearer $token")
}
when (response.status) {
HttpStatusCode.OK -> Result.success(response.body())
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
HttpStatusCode.NotFound -> Result.failure(
SyncError.ServerError(404, "Inventar nicht gefunden")
)
else -> Result.failure(
SyncError.ServerError(response.status.value, response.status.description)
)
}
}
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()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
block(serverUrl.trimEnd('/'), token)
} catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) {
Result.failure(SyncError.ConnectionError(e))
} catch (e: Exception) {
Result.failure(SyncError.Unknown(e))
}
}
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()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
if (token.isNullOrBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
}
try {
block(serverUrl.trimEnd('/'), token)
} catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) {
Result.failure(SyncError.ConnectionError(e))
} catch (e: Exception) {
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

@ -11,6 +11,8 @@ internal object SettingsKeys {
const val AUTH_USER_ID = "auth_user_id"
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
const val OPENAI_API_KEY = "openai_api_key"
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,

View file

@ -1,6 +1,7 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.InventoryInfoDto
import de.krisenvorrat.shared.model.ItemDto
internal interface SyncService {
@ -10,4 +11,7 @@ internal interface SyncService {
suspend fun logout()
suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit>
suspend fun deleteItem(itemId: String): Result<Unit>
suspend fun listInventories(): Result<List<InventoryInfoDto>>
suspend fun createInventory(name: String): Result<InventoryInfoDto>
suspend fun switchInventory(inventoryId: String): Result<InventoryInfoDto>
}

View file

@ -2,21 +2,34 @@ package de.krisenvorrat.app.ui
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.krisenvorrat.app.ui.inventory.InventoryPickerSheet
import de.krisenvorrat.app.ui.inventory.InventoryPickerViewModel
import de.krisenvorrat.app.ui.navigation.KrisenvorratNavGraph
import de.krisenvorrat.app.ui.navigation.TopLevelDestination
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MainScreen() {
val navController = rememberNavController()
@ -27,7 +40,30 @@ internal fun MainScreen() {
currentDestination?.hasRoute(topLevel.route::class) == true
}
val inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
var isInventoryPickerVisible by remember { mutableStateOf(false) }
val showTopBar = showBottomBar && inventoryState.activeInventoryName.isNotBlank()
Scaffold(
topBar = {
if (showTopBar) {
TopAppBar(
title = {
Text(text = inventoryState.activeInventoryName)
},
actions = {
IconButton(onClick = { isInventoryPickerVisible = true }) {
Icon(
imageVector = Icons.Default.SwapHoriz,
contentDescription = "Inventar wechseln"
)
}
}
)
}
},
bottomBar = {
if (showBottomBar) {
NavigationBar {
@ -69,4 +105,11 @@ internal fun MainScreen() {
.consumeWindowInsets(innerPadding)
)
}
if (isInventoryPickerVisible) {
InventoryPickerSheet(
onDismiss = { isInventoryPickerVisible = false },
viewModel = inventoryPickerViewModel
)
}
}

View file

@ -0,0 +1,223 @@
package de.krisenvorrat.app.ui.inventory
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
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.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InventoryPickerSheet(
onDismiss: () -> Unit,
viewModel: InventoryPickerViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
LaunchedEffect(Unit) {
viewModel.loadInventories()
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 24.dp)
) {
Text(
text = "Inventar wählen",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 16.dp)
)
when {
uiState.isLoading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
uiState.error != null -> {
Text(
text = uiState.error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
}
else -> {
if (uiState.inventories.isEmpty()) {
Text(
text = "Keine Inventare vorhanden",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp)
)
} else {
LazyColumn {
items(uiState.inventories, key = { it.id }) { inventory ->
ListItem(
headlineContent = {
Text(
text = inventory.name.ifBlank { inventory.id.take(8) },
style = if (inventory.isActive) {
MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primary
)
} else {
MaterialTheme.typography.bodyLarge
}
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Inventory2,
contentDescription = null,
tint = if (inventory.isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
},
trailingContent = {
if (inventory.isActive) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Aktiv",
tint = MaterialTheme.colorScheme.primary
)
}
},
colors = ListItemDefaults.colors(
containerColor = Color.Transparent
),
modifier = Modifier.clickable(
enabled = !inventory.isActive && !uiState.isSwitching
) {
viewModel.switchInventory(inventory.id)
}
)
}
}
}
}
}
if (uiState.isSwitching) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Wechsle Inventar…",
style = MaterialTheme.typography.bodySmall
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
ListItem(
headlineContent = {
Text(
text = "Neues Inventar erstellen",
color = MaterialTheme.colorScheme.primary
)
},
leadingContent = {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { viewModel.showCreateDialog() }
)
}
}
if (uiState.isCreateDialogVisible) {
AlertDialog(
onDismissRequest = { viewModel.dismissCreateDialog() },
title = { Text("Neues Inventar") },
text = {
OutlinedTextField(
value = uiState.newInventoryName,
onValueChange = { viewModel.onNewInventoryNameChanged(it) },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = { viewModel.createInventory() },
enabled = uiState.newInventoryName.isNotBlank() && !uiState.isCreating
) {
if (uiState.isCreating) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
} else {
Text("Erstellen")
}
}
},
dismissButton = {
TextButton(onClick = { viewModel.dismissCreateDialog() }) {
Text("Abbrechen")
}
}
)
}
}

View file

@ -0,0 +1,14 @@
package de.krisenvorrat.app.ui.inventory
import de.krisenvorrat.shared.model.InventoryInfoDto
internal data class InventoryPickerUiState(
val inventories: List<InventoryInfoDto> = emptyList(),
val activeInventoryName: String = "",
val isLoading: Boolean = false,
val isCreating: Boolean = false,
val isSwitching: Boolean = false,
val newInventoryName: String = "",
val isCreateDialogVisible: Boolean = false,
val error: String? = null
)

View file

@ -0,0 +1,132 @@
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.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
internal class InventoryPickerViewModel @Inject constructor(
private val syncService: SyncService,
private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(InventoryPickerUiState())
val uiState: StateFlow<InventoryPickerUiState> = _uiState.asStateFlow()
init {
loadActiveInventoryName()
}
private fun loadActiveInventoryName() {
viewModelScope.launch {
val name = settingsRepository.getValue(SettingsKeys.ACTIVE_INVENTORY_NAME) ?: ""
_uiState.update { it.copy(activeInventoryName = name) }
}
}
fun loadInventories() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
syncService.listInventories().fold(
onSuccess = { inventories ->
_uiState.update { it.copy(inventories = inventories, isLoading = false) }
},
onFailure = { e ->
_uiState.update {
it.copy(isLoading = false, error = e.message ?: "Inventare konnten nicht geladen werden")
}
}
)
}
}
fun showCreateDialog() {
_uiState.update { it.copy(isCreateDialogVisible = true, newInventoryName = "") }
}
fun dismissCreateDialog() {
_uiState.update { it.copy(isCreateDialogVisible = false, newInventoryName = "") }
}
fun onNewInventoryNameChanged(name: String) {
_uiState.update { it.copy(newInventoryName = name) }
}
fun createInventory() {
val name = _uiState.value.newInventoryName.trim()
if (name.isBlank()) return
viewModelScope.launch {
_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)
_uiState.update {
it.copy(
isCreating = false,
isCreateDialogVisible = false,
newInventoryName = "",
activeInventoryName = info.name
)
}
resyncFromServer()
loadInventories()
},
onFailure = { e ->
_uiState.update {
it.copy(isCreating = false, error = e.message ?: "Inventar konnte nicht erstellt werden")
}
}
)
}
}
fun switchInventory(inventoryId: String) {
val currentId = _uiState.value.inventories.find { it.isActive }?.id
if (inventoryId == currentId) return
viewModelScope.launch {
_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)
_uiState.update {
it.copy(isSwitching = false, activeInventoryName = info.name)
}
resyncFromServer()
loadInventories()
},
onFailure = { e ->
_uiState.update {
it.copy(isSwitching = false, error = e.message ?: "Inventar konnte nicht gewechselt werden")
}
}
)
}
}
fun dismissError() {
_uiState.update { it.copy(error = null) }
}
private suspend fun resyncFromServer() {
settingsRepository.setValue(SettingsKeys.SYNC_LAST_TIMESTAMP, "")
syncService.downloadInventory().fold(
onSuccess = { inventoryDto ->
importExportRepository.importFromInventoryDto(inventoryDto)
},
onFailure = { /* Sync-Fehler werden nicht blockierend behandelt */ }
)
}
}

View file

@ -150,6 +150,11 @@ private class FakeSyncService : SyncService {
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> =
Result.success(Unit)
override suspend fun logout() {}
override suspend fun listInventories(): Result<List<de.krisenvorrat.shared.model.InventoryInfoDto>> = Result.success(emptyList())
override suspend fun createInventory(name: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = "new-inv", name = name, isActive = true))
override suspend fun switchInventory(inventoryId: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true))
}
private class FakeWebSocketClient : WebSocketClient {

View file

@ -0,0 +1,268 @@
package de.krisenvorrat.app.ui.inventory
import de.krisenvorrat.app.data.db.entity.SettingsEntity
import de.krisenvorrat.app.domain.model.SettingsKeys
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.shared.model.InventoryDto
import de.krisenvorrat.shared.model.InventoryInfoDto
import de.krisenvorrat.shared.model.ItemDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InventoryPickerViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var fakeSettingsRepository: FakeSettingsRepository
private lateinit var fakeSyncService: FakeSyncService
private lateinit var fakeImportExportRepository: FakeImportExportRepository
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
fakeSettingsRepository = FakeSettingsRepository()
fakeSyncService = FakeSyncService()
fakeImportExportRepository = FakeImportExportRepository()
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel() = InventoryPickerViewModel(
syncService = fakeSyncService,
settingsRepository = fakeSettingsRepository,
importExportRepository = fakeImportExportRepository
)
@Test
fun test_init_loadsActiveInventoryName() = runTest(testDispatcher) {
// Given
fakeSettingsRepository.store[SettingsKeys.ACTIVE_INVENTORY_NAME] = "Keller-Vorrat"
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
assertEquals("Keller-Vorrat", viewModel.uiState.value.activeInventoryName)
}
@Test
fun test_loadInventories_success_updatesState() = runTest(testDispatcher) {
// Given
val inventories = listOf(
InventoryInfoDto(id = "inv-1", name = "Keller", isActive = true),
InventoryInfoDto(id = "inv-2", name = "Büro", isActive = false)
)
fakeSyncService.listInventoriesResult = Result.success(inventories)
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.loadInventories()
advanceUntilIdle()
// Then
assertEquals(2, viewModel.uiState.value.inventories.size)
assertFalse(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.error)
}
@Test
fun test_loadInventories_failure_showsError() = runTest(testDispatcher) {
// Given
fakeSyncService.listInventoriesResult = Result.failure(SyncError.ConnectionError(Exception("offline")))
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.loadInventories()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.inventories.isEmpty())
assertFalse(viewModel.uiState.value.isLoading)
assertTrue(viewModel.uiState.value.error != null)
}
@Test
fun test_createInventory_success_updatesActiveInventory() = runTest(testDispatcher) {
// Given
fakeSyncService.createInventoryResult =
Result.success(InventoryInfoDto(id = "new-id", name = "Ferienhaus", isActive = true))
fakeSyncService.listInventoriesResult = Result.success(
listOf(InventoryInfoDto(id = "new-id", name = "Ferienhaus", isActive = true))
)
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.showCreateDialog()
viewModel.onNewInventoryNameChanged("Ferienhaus")
viewModel.createInventory()
advanceUntilIdle()
// Then
assertEquals("Ferienhaus", viewModel.uiState.value.activeInventoryName)
assertEquals("new-id", fakeSettingsRepository.store[SettingsKeys.ACTIVE_INVENTORY_ID])
assertEquals("Ferienhaus", fakeSettingsRepository.store[SettingsKeys.ACTIVE_INVENTORY_NAME])
assertFalse(viewModel.uiState.value.isCreateDialogVisible)
}
@Test
fun test_switchInventory_success_updatesActiveInventory() = runTest(testDispatcher) {
// Given
fakeSyncService.switchInventoryResult =
Result.success(InventoryInfoDto(id = "inv-2", name = "Büro", isActive = true))
fakeSyncService.listInventoriesResult = Result.success(
listOf(
InventoryInfoDto(id = "inv-1", name = "Keller", isActive = false),
InventoryInfoDto(id = "inv-2", name = "Büro", isActive = true)
)
)
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.switchInventory("inv-2")
advanceUntilIdle()
// Then
assertEquals("Büro", viewModel.uiState.value.activeInventoryName)
assertEquals("inv-2", fakeSettingsRepository.store[SettingsKeys.ACTIVE_INVENTORY_ID])
assertFalse(viewModel.uiState.value.isSwitching)
}
@Test
fun test_switchInventory_sameInventory_noOp() = runTest(testDispatcher) {
// Given
fakeSyncService.listInventoriesResult = Result.success(
listOf(InventoryInfoDto(id = "inv-1", name = "Keller", isActive = true))
)
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.loadInventories()
advanceUntilIdle()
// When
viewModel.switchInventory("inv-1")
advanceUntilIdle()
// Then no switch was attempted
assertFalse(viewModel.uiState.value.isSwitching)
}
@Test
fun test_createDialog_showAndDismiss() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.showCreateDialog()
// Then
assertTrue(viewModel.uiState.value.isCreateDialogVisible)
// When
viewModel.dismissCreateDialog()
// Then
assertFalse(viewModel.uiState.value.isCreateDialogVisible)
}
@Test
fun test_dismissError_clearsError() = runTest(testDispatcher) {
// Given
fakeSyncService.listInventoriesResult = Result.failure(SyncError.ConnectionError(Exception("offline")))
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.loadInventories()
advanceUntilIdle()
// When
viewModel.dismissError()
// Then
assertNull(viewModel.uiState.value.error)
}
}
// region Fakes
private class FakeSettingsRepository : SettingsRepository {
val store = mutableMapOf<String, String>()
override suspend fun getValue(key: String): String? = store[key]
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(store.map { SettingsEntity(key = it.key, value = it.value) })
}
private class FakeSyncService : SyncService {
var listInventoriesResult: Result<List<InventoryInfoDto>> = Result.success(emptyList())
var createInventoryResult: Result<InventoryInfoDto> =
Result.success(InventoryInfoDto(id = "new-inv", name = "New", isActive = true))
var switchInventoryResult: Result<InventoryInfoDto> =
Result.success(InventoryInfoDto(id = "switched", name = "Switched", isActive = true))
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
Result.success(InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()))
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
Result.success(inventory)
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> =
Result.success(Unit)
override suspend fun logout() {}
override suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit> =
Result.success(Unit)
override suspend fun deleteItem(itemId: String): Result<Unit> =
Result.success(Unit)
override suspend fun listInventories(): Result<List<InventoryInfoDto>> = listInventoriesResult
override suspend fun createInventory(name: String): Result<InventoryInfoDto> = createInventoryResult
override suspend fun switchInventory(inventoryId: String): Result<InventoryInfoDto> = switchInventoryResult
}
private class FakeImportExportRepository : ImportExportRepository {
override suspend fun exportToJson(): String = "{}"
override suspend fun importFromJson(json: String): Result<Unit> = Result.success(Unit)
override suspend fun exportToMarkdown(): String = ""
override suspend fun exportToInventoryDto(): InventoryDto =
InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())
override suspend fun importFromInventoryDto(dto: InventoryDto): Result<Unit> = Result.success(Unit)
}
// endregion

View file

@ -788,6 +788,11 @@ private class FakeSyncService : SyncService {
override suspend fun logout() {}
override suspend fun patchItem(itemId: String, item: de.krisenvorrat.shared.model.ItemDto): Result<Unit> = Result.success(Unit)
override suspend fun deleteItem(itemId: String): Result<Unit> = Result.success(Unit)
override suspend fun listInventories(): Result<List<de.krisenvorrat.shared.model.InventoryInfoDto>> = Result.success(emptyList())
override suspend fun createInventory(name: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = "new-inv", name = name, isActive = true))
override suspend fun switchInventory(inventoryId: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true))
}
private class FakeWebSocketClient : WebSocketClient {

View file

@ -4,6 +4,7 @@ import org.jetbrains.exposed.sql.Table
internal object Inventories : Table("inventories") {
val id = varchar("id", 36)
val name = varchar("name", 255).default("")
val createdAt = long("created_at")
override val primaryKey = PrimaryKey(id)
}

View file

@ -10,7 +10,9 @@ internal data class LoginResponse(
val accessToken: String,
val refreshToken: String,
val userId: String,
val username: String
val username: String,
val inventoryId: String? = null,
val inventoryName: String? = null
)
@Serializable

View file

@ -56,7 +56,7 @@ internal fun Application.configureRouting(
// Public auth endpoints (10 req/min per IP)
rateLimit(RATE_LIMIT_AUTH) {
authRoutes(userRepository, jwtService)
authRoutes(userRepository, jwtService, inventoryRepository)
}
// Protected endpoints

View file

@ -12,6 +12,7 @@ import de.krisenvorrat.server.model.InventoryWithUsersDto
import de.krisenvorrat.server.model.UserDto
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.InventoryInfoDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
@ -45,11 +46,12 @@ internal class InventoryRepository {
.singleOrNull() ?: userId
}
fun createInventory(): String {
fun createInventory(name: String = ""): String {
val inventoryId = UUID.randomUUID().toString()
transaction {
Inventories.insert {
it[id] = inventoryId
it[Inventories.name] = name
it[createdAt] = System.currentTimeMillis()
}
}
@ -89,6 +91,30 @@ internal class InventoryRepository {
Inventories.deleteWhere { Inventories.id eq inventoryId }
}
/**
* Returns all inventories visible to the given user, marking the user's active one.
*/
fun getUserInventories(userId: String): List<InventoryInfoDto> = transaction {
val activeInventoryId = Users.selectAll()
.where { Users.id eq userId }
.map { it[Users.inventoryId] }
.singleOrNull()
Inventories.selectAll().map { row ->
InventoryInfoDto(
id = row[Inventories.id],
name = row[Inventories.name],
isActive = row[Inventories.id] == activeInventoryId
)
}
}
fun getInventoryName(inventoryId: String): String = transaction {
Inventories.selectAll()
.where { Inventories.id eq inventoryId }
.map { it[Inventories.name] }
.singleOrNull() ?: ""
}
fun listInventoriesWithUsers(): List<InventoryWithUsersDto> = transaction {
Inventories.selectAll().map { invRow ->
val invId = invRow[Inventories.id]

View file

@ -4,6 +4,7 @@ import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.model.LoginRequest
import de.krisenvorrat.server.model.LoginResponse
import de.krisenvorrat.server.model.RefreshRequest
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.repository.UserRepository
import de.krisenvorrat.server.security.JwtService
import io.ktor.http.*
@ -12,7 +13,11 @@ import io.ktor.server.response.*
import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt
internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtService) {
internal fun Route.authRoutes(
userRepository: UserRepository,
jwtService: JwtService,
inventoryRepository: InventoryRepository
) {
route("/api/auth") {
post("/login") {
val request = call.receive<LoginRequest>()
@ -26,13 +31,17 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer
}
val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
val refreshToken = jwtService.generateRefreshToken(user.id)
val inventoryId = user.inventoryId
val inventoryName = if (inventoryId != null) inventoryRepository.getInventoryName(inventoryId) else null
call.respond(
HttpStatusCode.OK,
LoginResponse(
accessToken = accessToken,
refreshToken = refreshToken,
userId = user.id,
username = user.username
username = user.username,
inventoryId = inventoryId,
inventoryName = inventoryName
)
)
}

View file

@ -4,7 +4,9 @@ import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.security.UserPrincipal
import de.krisenvorrat.server.websocket.WebSocketManager
import de.krisenvorrat.shared.model.CreateInventoryRequest
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.InventoryInfoDto
import de.krisenvorrat.shared.model.ItemDto
import io.ktor.http.*
import io.ktor.server.auth.*
@ -154,6 +156,47 @@ internal fun Route.inventoryRoutes(
}
}
}
route("/api/inventories") {
get {
val userId = call.principal<UserPrincipal>()?.userId
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventories = repository.getUserInventories(userId)
call.respond(HttpStatusCode.OK, inventories)
}
post {
val userId = call.principal<UserPrincipal>()?.userId
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val request = call.receive<CreateInventoryRequest>()
if (request.name.isBlank()) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Inventory name must not be empty"))
return@post
}
if (request.name.length > 255) {
call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Inventory name too long (max 255 characters)"))
return@post
}
val inventoryId = repository.createInventory(request.name)
repository.assignUserToInventory(userId, inventoryId)
call.respond(HttpStatusCode.Created, InventoryInfoDto(id = inventoryId, name = request.name, isActive = true))
}
post("/{id}/switch") {
val userId = call.principal<UserPrincipal>()?.userId
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val targetId = call.parameters["id"] ?: return@post call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing inventory id")
)
if (!repository.inventoryExists(targetId)) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Inventory not found"))
return@post
}
repository.assignUserToInventory(userId, targetId)
val name = repository.getInventoryName(targetId)
call.respond(HttpStatusCode.OK, InventoryInfoDto(id = targetId, name = name, isActive = true))
}
}
}

View file

@ -0,0 +1,251 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.shared.model.CreateInventoryRequest
import de.krisenvorrat.shared.model.InventoryInfoDto
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.config.*
import io.ktor.server.testing.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class InventoryManagementTest {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val adminToken = createTestAccessToken(
userId = TEST_ADMIN_ID,
username = TEST_ADMIN_USERNAME,
isAdmin = true
)
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:inv_mgmt_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
private suspend fun ApplicationTestBuilder.createUserAndGetToken(
username: String, password: String = "pass123"
): Pair<String, String> {
client.post("/api/admin/users") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(mapOf("username" to username, "password" to password)))
}
val loginRes = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(mapOf("username" to username, "password" to password)))
}
val loginBody = json.parseToJsonElement(loginRes.bodyAsText())
val obj = loginBody as kotlinx.serialization.json.JsonObject
val token = (obj["accessToken"] as kotlinx.serialization.json.JsonPrimitive).content
val userId = (obj["userId"] as kotlinx.serialization.json.JsonPrimitive).content
return Pair(token, userId)
}
// --- Tests ---
@Test
fun test_listInventories_authenticated_returnsInventories() = testApp {
// Given
val (userToken, _) = createUserAndGetToken("invuser")
// When
val response = client.get("/api/inventories") {
bearerAuth(userToken)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val inventories = json.decodeFromString<List<InventoryInfoDto>>(response.bodyAsText())
assertTrue(inventories.isNotEmpty())
assertTrue(inventories.any { it.isActive })
}
@Test
fun test_listInventories_unauthenticated_returns401() = testApp {
// When
val response = client.get("/api/inventories")
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_createInventory_validName_returnsCreated() = testApp {
// Given
val (userToken, _) = createUserAndGetToken("creator")
// When
val response = client.post("/api/inventories") {
bearerAuth(userToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(CreateInventoryRequest("Keller-Vorrat")))
}
// Then
assertEquals(HttpStatusCode.Created, response.status)
val info = json.decodeFromString<InventoryInfoDto>(response.bodyAsText())
assertEquals("Keller-Vorrat", info.name)
assertTrue(info.isActive)
assertNotNull(info.id)
}
@Test
fun test_createInventory_emptyName_returns400() = testApp {
// Given
val (userToken, _) = createUserAndGetToken("emptycreator")
// When
val response = client.post("/api/inventories") {
bearerAuth(userToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(CreateInventoryRequest("")))
}
// Then
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_createInventory_switchesToNewInventory() = testApp {
// Given
val (userToken, _) = createUserAndGetToken("switchuser")
// When create a new inventory
client.post("/api/inventories") {
bearerAuth(userToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(CreateInventoryRequest("Büro")))
}
// Then listing shows the new inventory as active
val listResponse = client.get("/api/inventories") {
bearerAuth(userToken)
}
val inventories = json.decodeFromString<List<InventoryInfoDto>>(listResponse.bodyAsText())
val active = inventories.find { it.isActive }
assertNotNull(active)
assertEquals("Büro", active!!.name)
}
@Test
fun test_switchInventory_existingInventory_switches() = testApp {
// Given create two users, each gets their own inventory
val (user1Token, _) = createUserAndGetToken("user1")
val (user2Token, _) = createUserAndGetToken("user2")
// Get user2's inventory id
val user2Inventories = json.decodeFromString<List<InventoryInfoDto>>(
client.get("/api/inventories") { bearerAuth(user2Token) }.bodyAsText()
)
val user2InventoryId = user2Inventories.find { it.isActive }!!.id
// When user1 switches to user2's inventory
val response = client.post("/api/inventories/$user2InventoryId/switch") {
bearerAuth(user1Token)
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val info = json.decodeFromString<InventoryInfoDto>(response.bodyAsText())
assertEquals(user2InventoryId, info.id)
assertTrue(info.isActive)
}
@Test
fun test_switchInventory_nonExistentInventory_returns404() = testApp {
// Given
val (userToken, _) = createUserAndGetToken("switcher")
// When
val response = client.post("/api/inventories/non-existent-id/switch") {
bearerAuth(userToken)
}
// Then
assertEquals(HttpStatusCode.NotFound, response.status)
}
@Test
fun test_loginResponse_includesInventoryInfo() = testApp {
// Given
client.post("/api/admin/users") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(mapOf("username" to "loginuser", "password" to "pass123")))
}
// When
val response = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(mapOf("username" to "loginuser", "password" to "pass123")))
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val body = json.parseToJsonElement(response.bodyAsText()) as kotlinx.serialization.json.JsonObject
assertTrue(body.containsKey("inventoryId"))
assertFalse(body["inventoryId"].toString() == "null")
}
@Test
fun test_createAndSwitch_inventoryDataIsolated() = testApp {
// Given user creates inventory with data, then switches to a new one
val (userToken, _) = createUserAndGetToken("datauser")
// Upload data to first inventory
val inventory = de.krisenvorrat.shared.model.InventoryDto(
categories = listOf(de.krisenvorrat.shared.model.CategoryDto(1, "Food")),
locations = listOf(de.krisenvorrat.shared.model.LocationDto(1, "Cellar")),
items = listOf(
de.krisenvorrat.shared.model.ItemDto(
id = "item-1", name = "Water", categoryId = 1,
quantity = 10.0, unit = "L", unitPrice = 0.5,
kcalPerKg = null, expiryDate = null,
locationId = 1, notes = "", lastUpdated = 1000
)
),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(userToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(inventory))
}
// Create new inventory
client.post("/api/inventories") {
bearerAuth(userToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(CreateInventoryRequest("Empty Inv")))
}
// When get inventory (should be empty since we switched)
val getResponse = client.get("/api/inventory") {
bearerAuth(userToken)
}
// Then
val loaded = json.decodeFromString<de.krisenvorrat.shared.model.InventoryDto>(getResponse.bodyAsText())
assertTrue("New inventory should be empty", loaded.items.isEmpty())
}
}

View file

@ -0,0 +1,8 @@
package de.krisenvorrat.shared.model
import kotlinx.serialization.Serializable
@Serializable
data class CreateInventoryRequest(
val name: String
)

View file

@ -0,0 +1,10 @@
package de.krisenvorrat.shared.model
import kotlinx.serialization.Serializable
@Serializable
data class InventoryInfoDto(
val id: String,
val name: String,
val isActive: Boolean = false
)