diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt index 8ed2f25..383c1a3 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/AuthModels.kt @@ -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 diff --git a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt index 2c685d1..ef9ee85 100644 --- a/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt +++ b/app/src/main/java/de/krisenvorrat/app/data/sync/SyncServiceImpl.kt @@ -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> = + 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 = + 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 = + 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 + ): Result = 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> + ): Result> = 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 } } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt index be9f6c6..0935fc8 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/SettingsKeys.kt @@ -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 = setOf( AUTH_ACCESS_TOKEN, diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt index b528b2f..63fa9f9 100644 --- a/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/SyncService.kt @@ -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 suspend fun deleteItem(itemId: String): Result + suspend fun listInventories(): Result> + suspend fun createInventory(name: String): Result + suspend fun switchInventory(inventoryId: String): Result } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt b/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt index 0946551..c66229b 100644 --- a/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt +++ b/app/src/main/java/de/krisenvorrat/app/ui/MainScreen.kt @@ -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 + ) + } } diff --git a/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerSheet.kt b/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerSheet.kt new file mode 100644 index 0000000..3035a88 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerSheet.kt @@ -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") + } + } + ) + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerUiState.kt b/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerUiState.kt new file mode 100644 index 0000000..5d3a2ed --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerUiState.kt @@ -0,0 +1,14 @@ +package de.krisenvorrat.app.ui.inventory + +import de.krisenvorrat.shared.model.InventoryInfoDto + +internal data class InventoryPickerUiState( + val inventories: List = 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 +) diff --git a/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerViewModel.kt b/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerViewModel.kt new file mode 100644 index 0000000..a1444aa --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/ui/inventory/InventoryPickerViewModel.kt @@ -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 = _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 */ } + ) + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt index 75eb61a..84072a4 100644 --- a/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/ItemRepositoryImplTest.kt @@ -150,6 +150,11 @@ private class FakeSyncService : SyncService { override suspend fun login(serverUrl: String, username: String, password: String): Result = Result.success(Unit) override suspend fun logout() {} + override suspend fun listInventories(): Result> = Result.success(emptyList()) + override suspend fun createInventory(name: String): Result = + Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = "new-inv", name = name, isActive = true)) + override suspend fun switchInventory(inventoryId: String): Result = + Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true)) } private class FakeWebSocketClient : WebSocketClient { diff --git a/app/src/test/java/de/krisenvorrat/app/ui/inventory/InventoryPickerViewModelTest.kt b/app/src/test/java/de/krisenvorrat/app/ui/inventory/InventoryPickerViewModelTest.kt new file mode 100644 index 0000000..42a6517 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/ui/inventory/InventoryPickerViewModelTest.kt @@ -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() + + 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 = + MutableStateFlow(store[key]) + + override fun getAll(): Flow> = + MutableStateFlow(store.map { SettingsEntity(key = it.key, value = it.value) }) +} + +private class FakeSyncService : SyncService { + var listInventoriesResult: Result> = Result.success(emptyList()) + var createInventoryResult: Result = + Result.success(InventoryInfoDto(id = "new-inv", name = "New", isActive = true)) + var switchInventoryResult: Result = + Result.success(InventoryInfoDto(id = "switched", name = "Switched", isActive = true)) + + override suspend fun downloadInventory(since: Long?): Result = + Result.success(InventoryDto(categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList())) + + override suspend fun uploadInventory(inventory: InventoryDto): Result = + Result.success(inventory) + + override suspend fun login(serverUrl: String, username: String, password: String): Result = + Result.success(Unit) + + override suspend fun logout() {} + + override suspend fun patchItem(itemId: String, item: ItemDto): Result = + Result.success(Unit) + + override suspend fun deleteItem(itemId: String): Result = + Result.success(Unit) + + override suspend fun listInventories(): Result> = listInventoriesResult + + override suspend fun createInventory(name: String): Result = createInventoryResult + + override suspend fun switchInventory(inventoryId: String): Result = switchInventoryResult +} + +private class FakeImportExportRepository : ImportExportRepository { + override suspend fun exportToJson(): String = "{}" + override suspend fun importFromJson(json: String): Result = 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 = Result.success(Unit) +} + +// endregion 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 390e0b7..176d79b 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 @@ -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 = Result.success(Unit) override suspend fun deleteItem(itemId: String): Result = Result.success(Unit) + override suspend fun listInventories(): Result> = Result.success(emptyList()) + override suspend fun createInventory(name: String): Result = + Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = "new-inv", name = name, isActive = true)) + override suspend fun switchInventory(inventoryId: String): Result = + Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true)) } private class FakeWebSocketClient : WebSocketClient { diff --git a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt index e79a5b3..f88b7f2 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/db/Tables.kt @@ -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) } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt b/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt index adda770..43b38b3 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/model/AuthModels.kt @@ -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 diff --git a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt index bbaf68c..bfebbd4 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/plugins/Routing.kt @@ -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 diff --git a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt index 4cf32ae..7180034 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/repository/InventoryRepository.kt @@ -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 = 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 = transaction { Inventories.selectAll().map { invRow -> val invId = invRow[Inventories.id] diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt index acab0e8..daa6485 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/AuthRoutes.kt @@ -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() @@ -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 ) ) } diff --git a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt index 0056321..6aa8887 100644 --- a/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt +++ b/server/src/main/kotlin/de/krisenvorrat/server/routes/InventoryRoutes.kt @@ -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()?.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()?.userId + ?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized")) + val request = call.receive() + 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()?.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)) + } + } } diff --git a/server/src/test/kotlin/de/krisenvorrat/server/InventoryManagementTest.kt b/server/src/test/kotlin/de/krisenvorrat/server/InventoryManagementTest.kt new file mode 100644 index 0000000..2710447 --- /dev/null +++ b/server/src/test/kotlin/de/krisenvorrat/server/InventoryManagementTest.kt @@ -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 { + 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>(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(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>(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>( + 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(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(getResponse.bodyAsText()) + assertTrue("New inventory should be empty", loaded.items.isEmpty()) + } +} diff --git a/shared/src/main/kotlin/de/krisenvorrat/shared/model/CreateInventoryRequest.kt b/shared/src/main/kotlin/de/krisenvorrat/shared/model/CreateInventoryRequest.kt new file mode 100644 index 0000000..67c4a60 --- /dev/null +++ b/shared/src/main/kotlin/de/krisenvorrat/shared/model/CreateInventoryRequest.kt @@ -0,0 +1,8 @@ +package de.krisenvorrat.shared.model + +import kotlinx.serialization.Serializable + +@Serializable +data class CreateInventoryRequest( + val name: String +) diff --git a/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryInfoDto.kt b/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryInfoDto.kt new file mode 100644 index 0000000..8d37321 --- /dev/null +++ b/shared/src/main/kotlin/de/krisenvorrat/shared/model/InventoryInfoDto.kt @@ -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 +)