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 refreshToken: String,
|
||||
val userId: String,
|
||||
val username: String
|
||||
val username: String,
|
||||
val inventoryId: String? = null,
|
||||
val inventoryName: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import de.krisenvorrat.app.domain.model.SettingsKeys
|
|||
import de.krisenvorrat.app.domain.model.SyncError
|
||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||
import de.krisenvorrat.app.domain.repository.SyncService
|
||||
import de.krisenvorrat.shared.model.CreateInventoryRequest
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||
import de.krisenvorrat.shared.model.ItemDto
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
|
|
@ -88,6 +90,12 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
|
||||
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
|
||||
settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId)
|
||||
loginResponse.inventoryId?.let {
|
||||
settingsRepository.setValue(KEY_ACTIVE_INVENTORY_ID, it)
|
||||
}
|
||||
loginResponse.inventoryName?.let {
|
||||
settingsRepository.setValue(KEY_ACTIVE_INVENTORY_NAME, it)
|
||||
}
|
||||
Result.success(Unit)
|
||||
}
|
||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
||||
|
|
@ -212,11 +220,104 @@ internal class SyncServiceImpl @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
override suspend fun listInventories(): Result<List<InventoryInfoDto>> =
|
||||
executeInventoryInfoListRequest { serverUrl, token ->
|
||||
val response = httpClient.get("$serverUrl/api/inventories") {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> Result.success(response.body())
|
||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
||||
else -> Result.failure(
|
||||
SyncError.ServerError(response.status.value, response.status.description)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun createInventory(name: String): Result<InventoryInfoDto> =
|
||||
executeInventoryInfoRequest { serverUrl, token ->
|
||||
val response = httpClient.post("$serverUrl/api/inventories") {
|
||||
header("Authorization", "Bearer $token")
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(CreateInventoryRequest(name))
|
||||
}
|
||||
when (response.status) {
|
||||
HttpStatusCode.Created -> Result.success(response.body())
|
||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
||||
else -> Result.failure(
|
||||
SyncError.ServerError(response.status.value, response.status.description)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun switchInventory(inventoryId: String): Result<InventoryInfoDto> =
|
||||
executeInventoryInfoRequest { serverUrl, token ->
|
||||
val response = httpClient.post("$serverUrl/api/inventories/$inventoryId/switch") {
|
||||
header("Authorization", "Bearer $token")
|
||||
}
|
||||
when (response.status) {
|
||||
HttpStatusCode.OK -> Result.success(response.body())
|
||||
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
|
||||
HttpStatusCode.NotFound -> Result.failure(
|
||||
SyncError.ServerError(404, "Inventar nicht gefunden")
|
||||
)
|
||||
else -> Result.failure(
|
||||
SyncError.ServerError(response.status.value, response.status.description)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeInventoryInfoRequest(
|
||||
block: suspend (serverUrl: String, token: String) -> Result<InventoryInfoDto>
|
||||
): Result<InventoryInfoDto> = withContext(Dispatchers.IO) {
|
||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
|
||||
if (serverUrl.isNullOrBlank()) {
|
||||
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
|
||||
}
|
||||
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
|
||||
if (token.isNullOrBlank()) {
|
||||
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
|
||||
}
|
||||
try {
|
||||
block(serverUrl.trimEnd('/'), token)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(SyncError.Timeout(e))
|
||||
} catch (e: ConnectException) {
|
||||
Result.failure(SyncError.ConnectionError(e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(SyncError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeInventoryInfoListRequest(
|
||||
block: suspend (serverUrl: String, token: String) -> Result<List<InventoryInfoDto>>
|
||||
): Result<List<InventoryInfoDto>> = withContext(Dispatchers.IO) {
|
||||
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
|
||||
if (serverUrl.isNullOrBlank()) {
|
||||
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
|
||||
}
|
||||
val token = settingsRepository.getValue(KEY_ACCESS_TOKEN)
|
||||
if (token.isNullOrBlank()) {
|
||||
return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
|
||||
}
|
||||
try {
|
||||
block(serverUrl.trimEnd('/'), token)
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.failure(SyncError.Timeout(e))
|
||||
} catch (e: ConnectException) {
|
||||
Result.failure(SyncError.ConnectionError(e))
|
||||
} catch (e: Exception) {
|
||||
Result.failure(SyncError.Unknown(e))
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY_SERVER_URL = SettingsKeys.SERVER_URL
|
||||
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
|
||||
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
|
||||
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
|
||||
val KEY_AUTH_USER_ID = SettingsKeys.AUTH_USER_ID
|
||||
val KEY_ACTIVE_INVENTORY_ID = SettingsKeys.ACTIVE_INVENTORY_ID
|
||||
val KEY_ACTIVE_INVENTORY_NAME = SettingsKeys.ACTIVE_INVENTORY_NAME
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ internal object SettingsKeys {
|
|||
const val AUTH_USER_ID = "auth_user_id"
|
||||
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
|
||||
const val OPENAI_API_KEY = "openai_api_key"
|
||||
const val ACTIVE_INVENTORY_ID = "active_inventory_id"
|
||||
const val ACTIVE_INVENTORY_NAME = "active_inventory_name"
|
||||
|
||||
val SENSITIVE_KEYS: Set<String> = setOf(
|
||||
AUTH_ACCESS_TOKEN,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package de.krisenvorrat.app.domain.repository
|
||||
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||
import de.krisenvorrat.shared.model.ItemDto
|
||||
|
||||
internal interface SyncService {
|
||||
|
|
@ -10,4 +11,7 @@ internal interface SyncService {
|
|||
suspend fun logout()
|
||||
suspend fun patchItem(itemId: String, item: ItemDto): Result<Unit>
|
||||
suspend fun deleteItem(itemId: String): Result<Unit>
|
||||
suspend fun listInventories(): Result<List<InventoryInfoDto>>
|
||||
suspend fun createInventory(name: String): Result<InventoryInfoDto>
|
||||
suspend fun switchInventory(inventoryId: String): Result<InventoryInfoDto>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> =
|
||||
Result.success(Unit)
|
||||
override suspend fun logout() {}
|
||||
override suspend fun listInventories(): Result<List<de.krisenvorrat.shared.model.InventoryInfoDto>> = Result.success(emptyList())
|
||||
override suspend fun createInventory(name: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
|
||||
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = "new-inv", name = name, isActive = true))
|
||||
override suspend fun switchInventory(inventoryId: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
|
||||
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true))
|
||||
}
|
||||
|
||||
private class FakeWebSocketClient : WebSocketClient {
|
||||
|
|
|
|||
|
|
@ -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 patchItem(itemId: String, item: de.krisenvorrat.shared.model.ItemDto): Result<Unit> = Result.success(Unit)
|
||||
override suspend fun deleteItem(itemId: String): Result<Unit> = Result.success(Unit)
|
||||
override suspend fun listInventories(): Result<List<de.krisenvorrat.shared.model.InventoryInfoDto>> = Result.success(emptyList())
|
||||
override suspend fun createInventory(name: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
|
||||
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = "new-inv", name = name, isActive = true))
|
||||
override suspend fun switchInventory(inventoryId: String): Result<de.krisenvorrat.shared.model.InventoryInfoDto> =
|
||||
Result.success(de.krisenvorrat.shared.model.InventoryInfoDto(id = inventoryId, name = "Switched", isActive = true))
|
||||
}
|
||||
|
||||
private class FakeWebSocketClient : WebSocketClient {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import de.krisenvorrat.server.model.InventoryWithUsersDto
|
|||
import de.krisenvorrat.server.model.UserDto
|
||||
import de.krisenvorrat.shared.model.CategoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||
import de.krisenvorrat.shared.model.ItemDto
|
||||
import de.krisenvorrat.shared.model.LocationDto
|
||||
import de.krisenvorrat.shared.model.SettingDto
|
||||
|
|
@ -45,11 +46,12 @@ internal class InventoryRepository {
|
|||
.singleOrNull() ?: userId
|
||||
}
|
||||
|
||||
fun createInventory(): String {
|
||||
fun createInventory(name: String = ""): String {
|
||||
val inventoryId = UUID.randomUUID().toString()
|
||||
transaction {
|
||||
Inventories.insert {
|
||||
it[id] = inventoryId
|
||||
it[Inventories.name] = name
|
||||
it[createdAt] = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
|
@ -89,6 +91,30 @@ internal class InventoryRepository {
|
|||
Inventories.deleteWhere { Inventories.id eq inventoryId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all inventories visible to the given user, marking the user's active one.
|
||||
*/
|
||||
fun getUserInventories(userId: String): List<InventoryInfoDto> = transaction {
|
||||
val activeInventoryId = Users.selectAll()
|
||||
.where { Users.id eq userId }
|
||||
.map { it[Users.inventoryId] }
|
||||
.singleOrNull()
|
||||
Inventories.selectAll().map { row ->
|
||||
InventoryInfoDto(
|
||||
id = row[Inventories.id],
|
||||
name = row[Inventories.name],
|
||||
isActive = row[Inventories.id] == activeInventoryId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getInventoryName(inventoryId: String): String = transaction {
|
||||
Inventories.selectAll()
|
||||
.where { Inventories.id eq inventoryId }
|
||||
.map { it[Inventories.name] }
|
||||
.singleOrNull() ?: ""
|
||||
}
|
||||
|
||||
fun listInventoriesWithUsers(): List<InventoryWithUsersDto> = transaction {
|
||||
Inventories.selectAll().map { invRow ->
|
||||
val invId = invRow[Inventories.id]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import de.krisenvorrat.server.model.ErrorResponse
|
|||
import de.krisenvorrat.server.model.LoginRequest
|
||||
import de.krisenvorrat.server.model.LoginResponse
|
||||
import de.krisenvorrat.server.model.RefreshRequest
|
||||
import de.krisenvorrat.server.repository.InventoryRepository
|
||||
import de.krisenvorrat.server.repository.UserRepository
|
||||
import de.krisenvorrat.server.security.JwtService
|
||||
import io.ktor.http.*
|
||||
|
|
@ -12,7 +13,11 @@ import io.ktor.server.response.*
|
|||
import io.ktor.server.routing.*
|
||||
import org.mindrot.jbcrypt.BCrypt
|
||||
|
||||
internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtService) {
|
||||
internal fun Route.authRoutes(
|
||||
userRepository: UserRepository,
|
||||
jwtService: JwtService,
|
||||
inventoryRepository: InventoryRepository
|
||||
) {
|
||||
route("/api/auth") {
|
||||
post("/login") {
|
||||
val request = call.receive<LoginRequest>()
|
||||
|
|
@ -26,13 +31,17 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer
|
|||
}
|
||||
val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
|
||||
val refreshToken = jwtService.generateRefreshToken(user.id)
|
||||
val inventoryId = user.inventoryId
|
||||
val inventoryName = if (inventoryId != null) inventoryRepository.getInventoryName(inventoryId) else null
|
||||
call.respond(
|
||||
HttpStatusCode.OK,
|
||||
LoginResponse(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
userId = user.id,
|
||||
username = user.username
|
||||
username = user.username,
|
||||
inventoryId = inventoryId,
|
||||
inventoryName = inventoryName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import de.krisenvorrat.server.model.ErrorResponse
|
|||
import de.krisenvorrat.server.repository.InventoryRepository
|
||||
import de.krisenvorrat.server.security.UserPrincipal
|
||||
import de.krisenvorrat.server.websocket.WebSocketManager
|
||||
import de.krisenvorrat.shared.model.CreateInventoryRequest
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import de.krisenvorrat.shared.model.InventoryInfoDto
|
||||
import de.krisenvorrat.shared.model.ItemDto
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.auth.*
|
||||
|
|
@ -154,6 +156,47 @@ internal fun Route.inventoryRoutes(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
route("/api/inventories") {
|
||||
get {
|
||||
val userId = call.principal<UserPrincipal>()?.userId
|
||||
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
||||
val inventories = repository.getUserInventories(userId)
|
||||
call.respond(HttpStatusCode.OK, inventories)
|
||||
}
|
||||
|
||||
post {
|
||||
val userId = call.principal<UserPrincipal>()?.userId
|
||||
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
||||
val request = call.receive<CreateInventoryRequest>()
|
||||
if (request.name.isBlank()) {
|
||||
call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Inventory name must not be empty"))
|
||||
return@post
|
||||
}
|
||||
if (request.name.length > 255) {
|
||||
call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Inventory name too long (max 255 characters)"))
|
||||
return@post
|
||||
}
|
||||
val inventoryId = repository.createInventory(request.name)
|
||||
repository.assignUserToInventory(userId, inventoryId)
|
||||
call.respond(HttpStatusCode.Created, InventoryInfoDto(id = inventoryId, name = request.name, isActive = true))
|
||||
}
|
||||
|
||||
post("/{id}/switch") {
|
||||
val userId = call.principal<UserPrincipal>()?.userId
|
||||
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
|
||||
val targetId = call.parameters["id"] ?: return@post call.respond(
|
||||
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing inventory id")
|
||||
)
|
||||
if (!repository.inventoryExists(targetId)) {
|
||||
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Inventory not found"))
|
||||
return@post
|
||||
}
|
||||
repository.assignUserToInventory(userId, targetId)
|
||||
val name = repository.getInventoryName(targetId)
|
||||
call.respond(HttpStatusCode.OK, InventoryInfoDto(id = targetId, name = name, isActive = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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