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:
parent
6711a0e056
commit
eb9ab6aa54
20 changed files with 1155 additions and 6 deletions
|
|
@ -10,7 +10,9 @@ internal data class LoginResponse(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val username: String
|
val username: String,
|
||||||
|
val inventoryId: String? = null,
|
||||||
|
val inventoryName: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import de.krisenvorrat.app.domain.model.SettingsKeys
|
||||||
import de.krisenvorrat.app.domain.model.SyncError
|
import de.krisenvorrat.app.domain.model.SyncError
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SyncService
|
import de.krisenvorrat.app.domain.repository.SyncService
|
||||||
|
import de.krisenvorrat.shared.model.CreateInventoryRequest
|
||||||
import de.krisenvorrat.shared.model.InventoryDto
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
|
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||||
import de.krisenvorrat.shared.model.ItemDto
|
import de.krisenvorrat.shared.model.ItemDto
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
import io.ktor.client.call.body
|
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_REFRESH_TOKEN, loginResponse.refreshToken)
|
||||||
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
|
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
|
||||||
settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId)
|
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)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
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 {
|
private companion object {
|
||||||
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
||||||
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
|
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
|
||||||
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
|
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
|
||||||
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
|
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
|
||||||
val KEY_AUTH_USER_ID = SettingsKeys.AUTH_USER_ID
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ internal object SettingsKeys {
|
||||||
const val AUTH_USER_ID = "auth_user_id"
|
const val AUTH_USER_ID = "auth_user_id"
|
||||||
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||||
const val OPENAI_API_KEY = "openai_api_key"
|
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(
|
val SENSITIVE_KEYS: Set<String> = setOf(
|
||||||
AUTH_ACCESS_TOKEN,
|
AUTH_ACCESS_TOKEN,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package de.krisenvorrat.app.domain.repository
|
package de.krisenvorrat.app.domain.repository
|
||||||
|
|
||||||
import de.krisenvorrat.shared.model.InventoryDto
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
|
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||||
import de.krisenvorrat.shared.model.ItemDto
|
import de.krisenvorrat.shared.model.ItemDto
|
||||||
|
|
||||||
internal interface SyncService {
|
internal interface SyncService {
|
||||||
|
|
@ -10,4 +11,7 @@ internal interface SyncService {
|
||||||
suspend fun logout()
|
suspend fun logout()
|
||||||
suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit>
|
suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit>
|
||||||
suspend fun deleteItem(itemId: String): 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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,34 @@ package de.krisenvorrat.app.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
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.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavDestination.Companion.hasRoute
|
import androidx.navigation.NavDestination.Companion.hasRoute
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
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.KrisenvorratNavGraph
|
||||||
import de.krisenvorrat.app.ui.navigation.TopLevelDestination
|
import de.krisenvorrat.app.ui.navigation.TopLevelDestination
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MainScreen() {
|
internal fun MainScreen() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
@ -27,7 +40,30 @@ internal fun MainScreen() {
|
||||||
currentDestination?.hasRoute(topLevel.route::class) == true
|
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(
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
if (showTopBar) {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(text = inventoryState.activeInventoryName)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { isInventoryPickerVisible = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.SwapHoriz,
|
||||||
|
contentDescription = "Inventar wechseln"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
if (showBottomBar) {
|
if (showBottomBar) {
|
||||||
NavigationBar {
|
NavigationBar {
|
||||||
|
|
@ -69,4 +105,11 @@ internal fun MainScreen() {
|
||||||
.consumeWindowInsets(innerPadding)
|
.consumeWindowInsets(innerPadding)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isInventoryPickerVisible) {
|
||||||
|
InventoryPickerSheet(
|
||||||
|
onDismiss = { isInventoryPickerVisible = false },
|
||||||
|
viewModel = inventoryPickerViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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 */ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -150,6 +150,11 @@ private class FakeSyncService : SyncService {
|
||||||
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> =
|
override suspend fun login(serverUrl: String, username: String, password: String): Result<Unit> =
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
override suspend fun logout() {}
|
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 {
|
private class FakeWebSocketClient : WebSocketClient {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -788,6 +788,11 @@ private class FakeSyncService : SyncService {
|
||||||
override suspend fun logout() {}
|
override suspend fun logout() {}
|
||||||
override suspend fun patchItem(itemId: String, item: de.krisenvorrat.shared.model.ItemDto): Result<Unit> = Result.success(Unit)
|
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 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 {
|
private class FakeWebSocketClient : WebSocketClient {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import org.jetbrains.exposed.sql.Table
|
||||||
|
|
||||||
internal object Inventories : Table("inventories") {
|
internal object Inventories : Table("inventories") {
|
||||||
val id = varchar("id", 36)
|
val id = varchar("id", 36)
|
||||||
|
val name = varchar("name", 255).default("")
|
||||||
val createdAt = long("created_at")
|
val createdAt = long("created_at")
|
||||||
override val primaryKey = PrimaryKey(id)
|
override val primaryKey = PrimaryKey(id)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ internal data class LoginResponse(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val refreshToken: String,
|
val refreshToken: String,
|
||||||
val userId: String,
|
val userId: String,
|
||||||
val username: String
|
val username: String,
|
||||||
|
val inventoryId: String? = null,
|
||||||
|
val inventoryName: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ internal fun Application.configureRouting(
|
||||||
|
|
||||||
// Public auth endpoints (10 req/min per IP)
|
// Public auth endpoints (10 req/min per IP)
|
||||||
rateLimit(RATE_LIMIT_AUTH) {
|
rateLimit(RATE_LIMIT_AUTH) {
|
||||||
authRoutes(userRepository, jwtService)
|
authRoutes(userRepository, jwtService, inventoryRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import de.krisenvorrat.server.model.InventoryWithUsersDto
|
||||||
import de.krisenvorrat.server.model.UserDto
|
import de.krisenvorrat.server.model.UserDto
|
||||||
import de.krisenvorrat.shared.model.CategoryDto
|
import de.krisenvorrat.shared.model.CategoryDto
|
||||||
import de.krisenvorrat.shared.model.InventoryDto
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
|
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||||
import de.krisenvorrat.shared.model.ItemDto
|
import de.krisenvorrat.shared.model.ItemDto
|
||||||
import de.krisenvorrat.shared.model.LocationDto
|
import de.krisenvorrat.shared.model.LocationDto
|
||||||
import de.krisenvorrat.shared.model.SettingDto
|
import de.krisenvorrat.shared.model.SettingDto
|
||||||
|
|
@ -45,11 +46,12 @@ internal class InventoryRepository {
|
||||||
.singleOrNull() ?: userId
|
.singleOrNull() ?: userId
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createInventory(): String {
|
fun createInventory(name: String = ""): String {
|
||||||
val inventoryId = UUID.randomUUID().toString()
|
val inventoryId = UUID.randomUUID().toString()
|
||||||
transaction {
|
transaction {
|
||||||
Inventories.insert {
|
Inventories.insert {
|
||||||
it[id] = inventoryId
|
it[id] = inventoryId
|
||||||
|
it[Inventories.name] = name
|
||||||
it[createdAt] = System.currentTimeMillis()
|
it[createdAt] = System.currentTimeMillis()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +91,30 @@ internal class InventoryRepository {
|
||||||
Inventories.deleteWhere { Inventories.id eq inventoryId }
|
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 {
|
fun listInventoriesWithUsers(): List<InventoryWithUsersDto> = transaction {
|
||||||
Inventories.selectAll().map { invRow ->
|
Inventories.selectAll().map { invRow ->
|
||||||
val invId = invRow[Inventories.id]
|
val invId = invRow[Inventories.id]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import de.krisenvorrat.server.model.ErrorResponse
|
||||||
import de.krisenvorrat.server.model.LoginRequest
|
import de.krisenvorrat.server.model.LoginRequest
|
||||||
import de.krisenvorrat.server.model.LoginResponse
|
import de.krisenvorrat.server.model.LoginResponse
|
||||||
import de.krisenvorrat.server.model.RefreshRequest
|
import de.krisenvorrat.server.model.RefreshRequest
|
||||||
|
import de.krisenvorrat.server.repository.InventoryRepository
|
||||||
import de.krisenvorrat.server.repository.UserRepository
|
import de.krisenvorrat.server.repository.UserRepository
|
||||||
import de.krisenvorrat.server.security.JwtService
|
import de.krisenvorrat.server.security.JwtService
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
|
|
@ -12,7 +13,11 @@ import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import org.mindrot.jbcrypt.BCrypt
|
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") {
|
route("/api/auth") {
|
||||||
post("/login") {
|
post("/login") {
|
||||||
val request = call.receive<LoginRequest>()
|
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 accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
|
||||||
val refreshToken = jwtService.generateRefreshToken(user.id)
|
val refreshToken = jwtService.generateRefreshToken(user.id)
|
||||||
|
val inventoryId = user.inventoryId
|
||||||
|
val inventoryName = if (inventoryId != null) inventoryRepository.getInventoryName(inventoryId) else null
|
||||||
call.respond(
|
call.respond(
|
||||||
HttpStatusCode.OK,
|
HttpStatusCode.OK,
|
||||||
LoginResponse(
|
LoginResponse(
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
refreshToken = refreshToken,
|
refreshToken = refreshToken,
|
||||||
userId = user.id,
|
userId = user.id,
|
||||||
username = user.username
|
username = user.username,
|
||||||
|
inventoryId = inventoryId,
|
||||||
|
inventoryName = inventoryName
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import de.krisenvorrat.server.model.ErrorResponse
|
||||||
import de.krisenvorrat.server.repository.InventoryRepository
|
import de.krisenvorrat.server.repository.InventoryRepository
|
||||||
import de.krisenvorrat.server.security.UserPrincipal
|
import de.krisenvorrat.server.security.UserPrincipal
|
||||||
import de.krisenvorrat.server.websocket.WebSocketManager
|
import de.krisenvorrat.server.websocket.WebSocketManager
|
||||||
|
import de.krisenvorrat.shared.model.CreateInventoryRequest
|
||||||
import de.krisenvorrat.shared.model.InventoryDto
|
import de.krisenvorrat.shared.model.InventoryDto
|
||||||
|
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||||
import de.krisenvorrat.shared.model.ItemDto
|
import de.krisenvorrat.shared.model.ItemDto
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.auth.*
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package de.krisenvorrat.shared.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateInventoryRequest(
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue