feat(server): Inventory Sharing – User:Inventory N:1

- Inventories-Tabelle neu: id, created_at
- Users.inventory_id FK → Inventories
- Items/Categories/Locations/Settings: user_id → inventory_id
- DatabaseFactory: Schema-Migration + Data-Migration (user_id→inventory_id)
- InventoryRepository: getEffectiveInventoryId(), createInventory(),
  assignUserToInventory(), cleanupOrphanedInventory(),
  listInventoriesWithUsers(); alle Ops auf inventoryId umgestellt
- UserRepository.create(): legt automatisch neues Inventory an
- InventoryRoutes: löst inventoryId via getEffectiveInventoryId()
- AdminRoutes: PUT /users/{id}/inventory (zuweisen),
  POST /users/{id}/inventory/new (trennen), GET /admin/inventories
- Admin-UI: Inventar-Spalte, 'Inventar wechseln'-Modal, 'Neues
  Inventar'-Button, Inventar-Übersicht (gruppiert)
- InventorySharingTest: 8 neue Integrationstests (Sharing, Isolation,
  Cleanup, Berechtigungen)
- Alle 48 Server-Tests gruen (inkl. bestehende Tests unveraendert)
This commit is contained in:
Jens Reinemann 2026-05-17 00:30:06 +02:00
parent 8576fffdb7
commit c03475e7e5
10 changed files with 919 additions and 31 deletions

View file

@ -3,6 +3,7 @@ package de.krisenvorrat.server.db
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.update
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
@ -20,12 +21,47 @@ internal object DatabaseFactory {
) { ) {
Database.connect(jdbcUrl, driver) Database.connect(jdbcUrl, driver)
transaction { transaction {
SchemaUtils.create(Users, Categories, Locations, Items, Settings, Messages) SchemaUtils.create(Inventories, Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings, Messages) SchemaUtils.createMissingTablesAndColumns(Inventories, Users, Categories, Locations, Items, Settings, Messages)
} }
migrateUserInventories()
seedAdmin(adminPassword) seedAdmin(adminPassword)
} }
/**
* Migration: For each existing user without an inventoryId, create an inventory
* and migrate their data from the old user_id column to the new inventory_id column.
*/
private fun migrateUserInventories() {
transaction {
val usersWithoutInventory = Users.selectAll()
.where { Users.inventoryId.isNull() }
.map { it[Users.id] }
for (userId in usersWithoutInventory) {
val inventoryId = UUID.randomUUID().toString()
Inventories.insert {
it[id] = inventoryId
it[createdAt] = System.currentTimeMillis()
}
Users.update({ Users.id eq userId }) {
it[Users.inventoryId] = inventoryId
}
// Migrate existing data from old user_id column to new inventory_id column.
// The exec() calls are wrapped in try/catch because the user_id column may not
// exist in fresh databases (only in upgraded databases from older schema).
try {
exec("UPDATE ITEMS SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
exec("UPDATE CATEGORIES SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
exec("UPDATE LOCATIONS SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
exec("UPDATE SETTINGS SET INVENTORY_ID = '$inventoryId' WHERE USER_ID = '$userId' AND INVENTORY_ID IS NULL")
} catch (_: Exception) {
// user_id column doesn't exist fresh database, nothing to migrate
}
}
}
}
private fun seedAdmin(adminPassword: String?) { private fun seedAdmin(adminPassword: String?) {
transaction { transaction {
val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0 val adminExists = Users.selectAll().where { Users.username eq "admin" }.count() > 0
@ -40,12 +76,19 @@ internal object DatabaseFactory {
logger.warn("==============================================") logger.warn("==============================================")
generated generated
} }
val adminId = UUID.randomUUID().toString()
val inventoryId = UUID.randomUUID().toString()
Inventories.insert {
it[id] = inventoryId
it[createdAt] = System.currentTimeMillis()
}
Users.insert { Users.insert {
it[id] = UUID.randomUUID().toString() it[id] = adminId
it[username] = "admin" it[username] = "admin"
it[passwordHash] = BCrypt.hashpw(password, BCrypt.gensalt()) it[passwordHash] = BCrypt.hashpw(password, BCrypt.gensalt())
it[createdAt] = System.currentTimeMillis() it[createdAt] = System.currentTimeMillis()
it[isAdmin] = true it[isAdmin] = true
it[Users.inventoryId] = inventoryId
} }
} }
} }

View file

@ -2,12 +2,19 @@ package de.krisenvorrat.server.db
import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.Table
internal object Inventories : Table("inventories") {
val id = varchar("id", 36)
val createdAt = long("created_at")
override val primaryKey = PrimaryKey(id)
}
internal object Users : Table("users") { internal object Users : Table("users") {
val id = varchar("id", 36) val id = varchar("id", 36)
val username = varchar("username", 255).uniqueIndex() val username = varchar("username", 255).uniqueIndex()
val passwordHash = varchar("password_hash", 255) val passwordHash = varchar("password_hash", 255)
val createdAt = long("created_at") val createdAt = long("created_at")
val isAdmin = bool("is_admin").default(false) val isAdmin = bool("is_admin").default(false)
val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
@ -16,7 +23,7 @@ internal object Categories : Table("categories") {
val pk = integer("pk").autoIncrement() val pk = integer("pk").autoIncrement()
val id = integer("id") val id = integer("id")
val name = varchar("name", 255) val name = varchar("name", 255)
val userId = varchar("user_id", 36).nullable() val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(pk) override val primaryKey = PrimaryKey(pk)
} }
@ -25,7 +32,7 @@ internal object Locations : Table("locations") {
val pk = integer("pk").autoIncrement() val pk = integer("pk").autoIncrement()
val id = integer("id") val id = integer("id")
val name = varchar("name", 255) val name = varchar("name", 255)
val userId = varchar("user_id", 36).nullable() val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(pk) override val primaryKey = PrimaryKey(pk)
} }
@ -42,7 +49,7 @@ internal object Items : Table("items") {
val locationId = integer("location_id") val locationId = integer("location_id")
val notes = text("notes") val notes = text("notes")
val lastUpdated = long("last_updated") val lastUpdated = long("last_updated")
val userId = varchar("user_id", 36).nullable() val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }
@ -51,7 +58,7 @@ internal object Settings : Table("settings") {
val id = integer("id").autoIncrement() val id = integer("id").autoIncrement()
val key = varchar("key", 255) val key = varchar("key", 255)
val value = text("value") val value = text("value")
val userId = varchar("user_id", 36).nullable() val inventoryId = varchar("inventory_id", 36).nullable()
override val primaryKey = PrimaryKey(id) override val primaryKey = PrimaryKey(id)
} }

View file

@ -21,7 +21,8 @@ internal data class UserDto(
val id: String, val id: String,
val username: String, val username: String,
val createdAt: Long, val createdAt: Long,
val isAdmin: Boolean val isAdmin: Boolean,
val inventoryId: String? = null
) )
@Serializable @Serializable
@ -29,3 +30,13 @@ internal data class CreateUserRequest(val username: String, val password: String
@Serializable @Serializable
internal data class UpdatePasswordRequest(val password: String) internal data class UpdatePasswordRequest(val password: String)
@Serializable
internal data class InventoryWithUsersDto(
val inventoryId: String,
val users: List<UserDto>
)
@Serializable
internal data class AssignInventoryRequest(val inventoryId: String)

View file

@ -43,7 +43,7 @@ internal fun Application.configureRouting(
// Protected endpoints // Protected endpoints
authenticate("auth-jwt") { authenticate("auth-jwt") {
inventoryRoutes(inventoryRepository, wsManager) inventoryRoutes(inventoryRepository, wsManager)
adminRoutes(userRepository) adminRoutes(userRepository, inventoryRepository)
messageRoutes(messageRepository, userRepository, wsManager) messageRoutes(messageRepository, userRepository, wsManager)
userRoutes(userRepository) userRoutes(userRepository)
} }

View file

@ -1,9 +1,13 @@
package de.krisenvorrat.server.repository package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Categories import de.krisenvorrat.server.db.Categories
import de.krisenvorrat.server.db.Inventories
import de.krisenvorrat.server.db.Items import de.krisenvorrat.server.db.Items
import de.krisenvorrat.server.db.Locations import de.krisenvorrat.server.db.Locations
import de.krisenvorrat.server.db.Settings import de.krisenvorrat.server.db.Settings
import de.krisenvorrat.server.db.Users
import de.krisenvorrat.server.model.InventoryWithUsersDto
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.ItemDto import de.krisenvorrat.shared.model.ItemDto
@ -16,21 +20,96 @@ import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import java.util.UUID
internal class InventoryRepository { internal class InventoryRepository {
fun saveInventory(userId: String, inventory: InventoryDto) { /**
* Resolves the effective inventoryId for a given userId.
* If the user exists in the DB, returns their assigned inventoryId.
* Falls back to the userId itself for JWT-only users (e.g. test tokens).
*/
fun getEffectiveInventoryId(userId: String): String = transaction {
Users.selectAll()
.where { Users.id eq userId }
.map { it[Users.inventoryId] }
.singleOrNull() ?: userId
}
fun createInventory(): String {
val inventoryId = UUID.randomUUID().toString()
transaction { transaction {
Items.deleteWhere { Items.userId eq userId } Inventories.insert {
Settings.deleteWhere { Settings.userId eq userId } it[id] = inventoryId
Categories.deleteWhere { Categories.userId eq userId } it[createdAt] = System.currentTimeMillis()
Locations.deleteWhere { Locations.userId eq userId } }
}
return inventoryId
}
fun inventoryExists(inventoryId: String): Boolean = transaction {
Inventories.selectAll().where { Inventories.id eq inventoryId }.count() > 0
}
/**
* Assigns a user to a different inventory. Returns the old inventoryId.
*/
fun assignUserToInventory(userId: String, newInventoryId: String): String? = transaction {
val oldInventoryId = Users.selectAll()
.where { Users.id eq userId }
.map { it[Users.inventoryId] }
.singleOrNull()
Users.update({ Users.id eq userId }) {
it[Users.inventoryId] = newInventoryId
}
oldInventoryId
}
/**
* Deletes an inventory if no users are assigned to it and it has no items.
*/
fun cleanupOrphanedInventory(inventoryId: String) = transaction {
val userCount = Users.selectAll()
.where { Users.inventoryId eq inventoryId }
.count()
if (userCount > 0) return@transaction
val hasItems = Items.selectAll()
.where { Items.inventoryId eq inventoryId }
.count() > 0
if (hasItems) return@transaction
Inventories.deleteWhere { Inventories.id eq inventoryId }
}
fun listInventoriesWithUsers(): List<InventoryWithUsersDto> = transaction {
Inventories.selectAll().map { invRow ->
val invId = invRow[Inventories.id]
val users = Users.selectAll()
.where { Users.inventoryId eq invId }
.map {
UserDto(
id = it[Users.id],
username = it[Users.username],
createdAt = it[Users.createdAt],
isAdmin = it[Users.isAdmin],
inventoryId = invId
)
}
InventoryWithUsersDto(inventoryId = invId, users = users)
}
}
fun saveInventory(inventoryId: String, inventory: InventoryDto) {
transaction {
Items.deleteWhere { Items.inventoryId eq inventoryId }
Settings.deleteWhere { Settings.inventoryId eq inventoryId }
Categories.deleteWhere { Categories.inventoryId eq inventoryId }
Locations.deleteWhere { Locations.inventoryId eq inventoryId }
for (category in inventory.categories) { for (category in inventory.categories) {
Categories.insert { Categories.insert {
it[id] = category.id it[id] = category.id
it[name] = category.name it[name] = category.name
it[Categories.userId] = userId it[Categories.inventoryId] = inventoryId
} }
} }
@ -38,7 +117,7 @@ internal class InventoryRepository {
Locations.insert { Locations.insert {
it[id] = location.id it[id] = location.id
it[name] = location.name it[name] = location.name
it[Locations.userId] = userId it[Locations.inventoryId] = inventoryId
} }
} }
@ -55,7 +134,7 @@ internal class InventoryRepository {
it[locationId] = item.locationId it[locationId] = item.locationId
it[notes] = item.notes it[notes] = item.notes
it[lastUpdated] = item.lastUpdated it[lastUpdated] = item.lastUpdated
it[Items.userId] = userId it[Items.inventoryId] = inventoryId
} }
} }
@ -63,24 +142,24 @@ internal class InventoryRepository {
Settings.insert { Settings.insert {
it[key] = setting.key it[key] = setting.key
it[value] = setting.value it[value] = setting.value
it[Settings.userId] = userId it[Settings.inventoryId] = inventoryId
} }
} }
} }
} }
fun loadInventory(userId: String): InventoryDto { fun loadInventory(inventoryId: String): InventoryDto {
return transaction { return transaction {
val categories = Categories.selectAll() val categories = Categories.selectAll()
.where { Categories.userId eq userId } .where { Categories.inventoryId eq inventoryId }
.map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) } .map { CategoryDto(id = it[Categories.id], name = it[Categories.name]) }
val locations = Locations.selectAll() val locations = Locations.selectAll()
.where { Locations.userId eq userId } .where { Locations.inventoryId eq inventoryId }
.map { LocationDto(id = it[Locations.id], name = it[Locations.name]) } .map { LocationDto(id = it[Locations.id], name = it[Locations.name]) }
val items = Items.selectAll() val items = Items.selectAll()
.where { Items.userId eq userId } .where { Items.inventoryId eq inventoryId }
.map { .map {
ItemDto( ItemDto(
id = it[Items.id], id = it[Items.id],
@ -98,17 +177,17 @@ internal class InventoryRepository {
} }
val settings = Settings.selectAll() val settings = Settings.selectAll()
.where { Settings.userId eq userId } .where { Settings.inventoryId eq inventoryId }
.map { SettingDto(key = it[Settings.key], value = it[Settings.value]) } .map { SettingDto(key = it[Settings.key], value = it[Settings.value]) }
InventoryDto(categories = categories, locations = locations, items = items, settings = settings) InventoryDto(categories = categories, locations = locations, items = items, settings = settings)
} }
} }
fun patchItem(userId: String, itemId: String, item: ItemDto): Boolean { fun patchItem(inventoryId: String, itemId: String, item: ItemDto): Boolean {
return transaction { return transaction {
val updated = Items.update({ val updated = Items.update({
(Items.id eq itemId) and (Items.userId eq userId) (Items.id eq itemId) and (Items.inventoryId eq inventoryId)
}) { }) {
it[name] = item.name it[name] = item.name
it[categoryId] = item.categoryId it[categoryId] = item.categoryId

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.server.repository package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Inventories
import de.krisenvorrat.server.db.Users import de.krisenvorrat.server.db.Users
import de.krisenvorrat.server.model.UserDto import de.krisenvorrat.server.model.UserDto
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
@ -8,6 +9,7 @@ import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jetbrains.exposed.sql.update
import java.util.UUID
internal data class UserRow( internal data class UserRow(
val id: String, val id: String,
@ -21,12 +23,18 @@ internal class UserRepository {
fun create(id: String, username: String, passwordHash: String, isAdmin: Boolean = false): Boolean { fun create(id: String, username: String, passwordHash: String, isAdmin: Boolean = false): Boolean {
return transaction { return transaction {
val inventoryId = UUID.randomUUID().toString()
Inventories.insert {
it[Inventories.id] = inventoryId
it[Inventories.createdAt] = System.currentTimeMillis()
}
val inserted = Users.insert { val inserted = Users.insert {
it[Users.id] = id it[Users.id] = id
it[Users.username] = username it[Users.username] = username
it[Users.passwordHash] = passwordHash it[Users.passwordHash] = passwordHash
it[Users.createdAt] = System.currentTimeMillis() it[Users.createdAt] = System.currentTimeMillis()
it[Users.isAdmin] = isAdmin it[Users.isAdmin] = isAdmin
it[Users.inventoryId] = inventoryId
}.insertedCount }.insertedCount
inserted > 0 inserted > 0
} }
@ -52,7 +60,8 @@ internal class UserRepository {
id = it[Users.id], id = it[Users.id],
username = it[Users.username], username = it[Users.username],
createdAt = it[Users.createdAt], createdAt = it[Users.createdAt],
isAdmin = it[Users.isAdmin] isAdmin = it[Users.isAdmin],
inventoryId = it[Users.inventoryId]
) )
} }
} }
@ -77,3 +86,4 @@ internal class UserRepository {
isAdmin = this[Users.isAdmin] isAdmin = this[Users.isAdmin]
) )
} }

View file

@ -1,8 +1,10 @@
package de.krisenvorrat.server.routes package de.krisenvorrat.server.routes
import de.krisenvorrat.server.model.AssignInventoryRequest
import de.krisenvorrat.server.model.CreateUserRequest import de.krisenvorrat.server.model.CreateUserRequest
import de.krisenvorrat.server.model.ErrorResponse import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.model.UpdatePasswordRequest import de.krisenvorrat.server.model.UpdatePasswordRequest
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.repository.UserRepository import de.krisenvorrat.server.repository.UserRepository
import de.krisenvorrat.server.security.UserPrincipal import de.krisenvorrat.server.security.UserPrincipal
import io.ktor.http.* import io.ktor.http.*
@ -13,7 +15,7 @@ import io.ktor.server.routing.*
import org.mindrot.jbcrypt.BCrypt import org.mindrot.jbcrypt.BCrypt
import java.util.UUID import java.util.UUID
internal fun Route.adminRoutes(userRepository: UserRepository) { internal fun Route.adminRoutes(userRepository: UserRepository, inventoryRepository: InventoryRepository) {
route("/api/admin/users") { route("/api/admin/users") {
get { get {
val principal = call.principal<UserPrincipal>() val principal = call.principal<UserPrincipal>()
@ -60,10 +62,15 @@ internal fun Route.adminRoutes(userRepository: UserRepository) {
call.respond(HttpStatusCode.Conflict, ErrorResponse(status = 409, message = "Cannot delete your own account")) call.respond(HttpStatusCode.Conflict, ErrorResponse(status = 409, message = "Cannot delete your own account"))
return@delete return@delete
} }
val userDto = userRepository.listAll().find { it.id == id }
val oldInventoryId = userDto?.inventoryId
val deleted = userRepository.deleteById(id) val deleted = userRepository.deleteById(id)
if (!deleted) { if (!deleted) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "User not found")) call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "User not found"))
} else { } else {
if (oldInventoryId != null) {
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
}
call.respond(HttpStatusCode.NoContent) call.respond(HttpStatusCode.NoContent)
} }
} }
@ -91,5 +98,59 @@ internal fun Route.adminRoutes(userRepository: UserRepository) {
call.respond(HttpStatusCode.OK, mapOf("message" to "Password updated")) call.respond(HttpStatusCode.OK, mapOf("message" to "Password updated"))
} }
} }
// Assign user to an existing inventory
put("/{id}/inventory") {
val principal = call.principal<UserPrincipal>()
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
if (!principal.isAdmin) {
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
return@put
}
val id = call.parameters["id"] ?: return@put call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing user id")
)
val request = call.receive<AssignInventoryRequest>()
if (!inventoryRepository.inventoryExists(request.inventoryId)) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Inventory not found"))
return@put
}
val oldInventoryId = inventoryRepository.assignUserToInventory(id, request.inventoryId)
if (oldInventoryId != null && oldInventoryId != request.inventoryId) {
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
}
call.respond(HttpStatusCode.OK, mapOf("inventoryId" to request.inventoryId))
}
// Create a new inventory for a user (disconnect from shared inventory)
post("/{id}/inventory/new") {
val principal = call.principal<UserPrincipal>()
?: return@post call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
if (!principal.isAdmin) {
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
return@post
}
val id = call.parameters["id"] ?: return@post call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing user id")
)
val newInventoryId = inventoryRepository.createInventory()
val oldInventoryId = inventoryRepository.assignUserToInventory(id, newInventoryId)
if (oldInventoryId != null && oldInventoryId != newInventoryId) {
inventoryRepository.cleanupOrphanedInventory(oldInventoryId)
}
call.respond(HttpStatusCode.Created, mapOf("inventoryId" to newInventoryId))
}
}
// List all inventories with their assigned users
get("/api/admin/inventories") {
val principal = call.principal<UserPrincipal>()
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
if (!principal.isAdmin) {
call.respond(HttpStatusCode.Forbidden, ErrorResponse(status = 403, message = "Admin access required"))
return@get
}
call.respond(HttpStatusCode.OK, inventoryRepository.listInventoriesWithUsers())
} }
} }

View file

@ -20,16 +20,18 @@ internal fun Route.inventoryRoutes(
get { get {
val userId = call.principal<UserPrincipal>()?.userId val userId = call.principal<UserPrincipal>()?.userId
?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized")) ?: return@get call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventory = repository.loadInventory(userId) val inventoryId = repository.getEffectiveInventoryId(userId)
val inventory = repository.loadInventory(inventoryId)
call.respond(HttpStatusCode.OK, inventory) call.respond(HttpStatusCode.OK, inventory)
} }
put { put {
val userId = call.principal<UserPrincipal>()?.userId val userId = call.principal<UserPrincipal>()?.userId
?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized")) ?: return@put call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventoryId = repository.getEffectiveInventoryId(userId)
val inventory = call.receive<InventoryDto>() val inventory = call.receive<InventoryDto>()
repository.saveInventory(userId, inventory) repository.saveInventory(inventoryId, inventory)
val saved = repository.loadInventory(userId) val saved = repository.loadInventory(inventoryId)
wsManager.notifyFullSyncRequired(userId) wsManager.notifyFullSyncRequired(userId)
call.respond(HttpStatusCode.OK, saved) call.respond(HttpStatusCode.OK, saved)
} }
@ -37,11 +39,12 @@ internal fun Route.inventoryRoutes(
patch("/items/{id}") { patch("/items/{id}") {
val userId = call.principal<UserPrincipal>()?.userId val userId = call.principal<UserPrincipal>()?.userId
?: return@patch call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized")) ?: return@patch call.respond(HttpStatusCode.Unauthorized, ErrorResponse(status = 401, message = "Unauthorized"))
val inventoryId = repository.getEffectiveInventoryId(userId)
val itemId = call.parameters["id"] ?: return@patch call.respond( val itemId = call.parameters["id"] ?: return@patch call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing item id") HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Missing item id")
) )
val item = call.receive<ItemDto>() val item = call.receive<ItemDto>()
val updated = repository.patchItem(userId, itemId, item) val updated = repository.patchItem(inventoryId, itemId, item)
if (!updated) { if (!updated) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found")) call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found"))
} else { } else {
@ -51,3 +54,4 @@ internal fun Route.inventoryRoutes(
} }
} }
} }

View file

@ -1,5 +1,383 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Krisenvorrat Admin</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f5f5f5; color: #222; }
header { background: #1a1a2e; color: #fff; padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; }
header h1 { font-size: 1.2rem; }
#logout-btn { background: transparent; border: 1px solid #ccc; color: #ccc; padding: 6px 14px; border-radius: 4px; cursor: pointer; }
main { max-width: 960px; margin: 32px auto; padding: 0 16px; }
.card { background: #fff; border-radius: 8px; padding: 32px; box-shadow: 0 1px 4px rgba(0,0,0,.1); margin-bottom: 24px; }
h2 { margin-bottom: 20px; font-size: 1.1rem; }
label { display: block; margin-bottom: 4px; font-size: .875rem; color: #555; }
input, select { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; margin-bottom: 14px; font-size: 1rem; }
button.primary { background: #1a1a2e; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-size: 1rem; }
button.primary:hover { background: #2d2d5e; }
button.danger { background: #c0392b; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button.secondary { background: #eee; color: #333; border: 1px solid #ccc; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
button.info { background: #2980b9; color: #fff; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
.error { color: #c0392b; margin-bottom: 12px; font-size: .875rem; }
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid #eee; font-size: .9rem; }
th { background: #f9f9f9; font-weight: 600; }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.inv-badge { display: inline-block; font-size: .75rem; background: #e8f4f8; color: #2980b9; border: 1px solid #aad4e8; border-radius: 4px; padding: 2px 7px; font-family: monospace; cursor: default; }
.inv-group { background: #f0f7ff; border: 1px solid #c3dff7; border-radius: 6px; padding: 14px 18px; margin-bottom: 12px; }
.inv-group h3 { font-size: .85rem; color: #555; margin-bottom: 8px; font-weight: 600; }
.inv-group ul { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; }
.inv-group ul li { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 4px 10px; font-size: .875rem; }
#add-user-form { display: none; background: #f9f9f9; border: 1px solid #ddd; border-radius: 6px; padding: 20px; margin-top: 16px; }
#add-user-form.open { display: block; }
#add-user-form h3 { margin-bottom: 14px; font-size: 1rem; }
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: #fff; border-radius: 8px; padding: 28px; width: 400px; max-width: 95vw; }
.modal h3 { margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; }
</style>
</head>
<body>
<header>
<h1>Krisenvorrat Admin</h1>
<button id="logout-btn" style="display:none" onclick="logout()">Abmelden</button>
</header>
<main>
<section id="login-section" class="card">
<h2>Anmelden</h2>
<div id="login-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="login-username" type="text" autocomplete="username">
<label>Passwort</label>
<input id="login-password" type="password" autocomplete="current-password">
<button class="primary" onclick="login()">Anmelden</button>
</section>
<div id="admin-section" style="display:none">
<!-- User Management -->
<section class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
<h2>Benutzerverwaltung</h2>
<button class="primary" onclick="toggleAddForm()">+ Benutzer anlegen</button>
</div>
<div id="add-user-form">
<h3>Neuen Benutzer anlegen</h3>
<div id="add-user-error" class="error" style="display:none"></div>
<label>Benutzername</label>
<input id="new-username" type="text">
<label>Passwort</label>
<input id="new-password" type="password">
<div style="display:flex; gap:10px">
<button class="primary" onclick="createUser()">Anlegen</button>
<button class="secondary" onclick="toggleAddForm()">Abbrechen</button>
</div>
</div>
<table id="users-table">
<thead><tr><th>Benutzername</th><th>Erstellt</th><th>Admin</th><th>Inventar</th><th>Aktionen</th></tr></thead>
<tbody id="users-body"></tbody>
</table>
</section>
<!-- Inventory Overview -->
<section class="card">
<h2>Inventar-Übersicht</h2>
<div id="inventories-body"></div>
</section>
</div>
</main>
<!-- Change Password Modal -->
<div id="pw-modal" class="modal-overlay">
<div class="modal">
<h3>Passwort ändern</h3>
<div id="pw-error" class="error" style="display:none"></div>
<label>Neues Passwort</label>
<input id="pw-input" type="password">
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmPasswordChange()">Speichern</button>
</div>
</div>
</div>
<!-- Delete Confirm Modal -->
<div id="del-modal" class="modal-overlay">
<div class="modal">
<h3>Benutzer löschen?</h3>
<p id="del-confirm-text" style="margin-bottom:16px; font-size:.9rem; color:#555"></p>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="danger" onclick="confirmDelete()">Löschen</button>
</div>
</div>
</div>
<!-- Assign Inventory Modal -->
<div id="assign-modal" class="modal-overlay">
<div class="modal">
<h3>Inventar zuweisen</h3>
<p style="margin-bottom:14px; font-size:.875rem; color:#555">Wähle ein bestehendes Inventar für diesen Benutzer:</p>
<div id="assign-error" class="error" style="display:none"></div>
<label>Inventar</label>
<select id="assign-inventory-select"></select>
<div class="modal-actions">
<button class="secondary" onclick="closeModal()">Abbrechen</button>
<button class="primary" onclick="confirmAssign()">Zuweisen</button>
</div>
</div>
</div>
<script>
let accessToken = sessionStorage.getItem('accessToken') || '';
let pendingUserId = null;
let allInventories = [];
if (accessToken) tryLoadUsers();
async function login() {
const username = document.getElementById('login-username').value;
const password = document.getElementById('login-password').value;
const err = document.getElementById('login-error');
err.style.display = 'none';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Login fehlgeschlagen';
err.style.display = 'block';
return;
}
const data = await res.json();
accessToken = data.accessToken;
sessionStorage.setItem('accessToken', accessToken);
showAdmin();
} catch (e) {
err.textContent = 'Verbindungsfehler';
err.style.display = 'block';
}
}
function showAdmin() {
document.getElementById('login-section').style.display = 'none';
document.getElementById('admin-section').style.display = 'block';
document.getElementById('logout-btn').style.display = 'inline-block';
loadAll();
}
function tryLoadUsers() {
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } })
.then(r => r.ok ? (showAdmin(), r.json()) : Promise.reject())
.then(renderUsers)
.catch(() => { accessToken = ''; sessionStorage.removeItem('accessToken'); });
}
async function loadAll() {
const [usersRes, inventoriesRes] = await Promise.all([
fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + accessToken } }),
fetch('/api/admin/inventories', { headers: { 'Authorization': 'Bearer ' + accessToken } })
]);
if (!usersRes.ok) { logout(); return; }
renderUsers(await usersRes.json());
if (inventoriesRes.ok) {
allInventories = await inventoriesRes.json();
renderInventories(allInventories);
}
}
function renderUsers(users) {
const tbody = document.getElementById('users-body');
tbody.innerHTML = '';
users.forEach(u => {
const tr = document.createElement('tr');
const tdName = document.createElement('td'); tdName.textContent = u.username;
const tdDate = document.createElement('td'); tdDate.textContent = new Date(u.createdAt).toLocaleDateString('de-DE');
const tdAdmin = document.createElement('td'); tdAdmin.textContent = u.isAdmin ? '✓' : '';
const tdInv = document.createElement('td');
if (u.inventoryId) {
const badge = document.createElement('span');
badge.className = 'inv-badge';
badge.title = u.inventoryId;
badge.textContent = u.inventoryId.slice(0, 8) + '…';
tdInv.appendChild(badge);
} else {
tdInv.textContent = '—';
}
const tdActions = document.createElement('td'); tdActions.className = 'actions';
const btnPw = document.createElement('button'); btnPw.className = 'secondary'; btnPw.textContent = 'PW ändern'; btnPw.onclick = () => openPasswordModal(u.id);
const btnAssign = document.createElement('button'); btnAssign.className = 'info'; btnAssign.textContent = 'Inventar wechseln'; btnAssign.onclick = () => openAssignModal(u.id);
const btnNew = document.createElement('button'); btnNew.className = 'secondary'; btnNew.textContent = 'Neues Inventar'; btnNew.onclick = () => createNewInventory(u.id);
const btnDel = document.createElement('button'); btnDel.className = 'danger'; btnDel.textContent = 'Löschen'; btnDel.onclick = () => openDeleteModal(u.id, u.username);
tdActions.append(btnPw, btnAssign, btnNew, btnDel);
tr.append(tdName, tdDate, tdAdmin, tdInv, tdActions);
tbody.appendChild(tr);
});
}
function renderInventories(inventories) {
const container = document.getElementById('inventories-body');
container.innerHTML = '';
if (!inventories.length) {
container.textContent = 'Keine Inventare vorhanden.';
return;
}
inventories.forEach(inv => {
const div = document.createElement('div'); div.className = 'inv-group';
const h3 = document.createElement('h3');
h3.textContent = 'Inventar ' + inv.inventoryId.slice(0, 8) + '… (' + inv.users.length + ' ' + (inv.users.length === 1 ? 'Benutzer' : 'Benutzer') + ')';
const ul = document.createElement('ul');
if (inv.users.length === 0) {
const li = document.createElement('li'); li.textContent = '(keine Benutzer)'; ul.appendChild(li);
} else {
inv.users.forEach(u => {
const li = document.createElement('li'); li.textContent = u.username + (u.isAdmin ? ' [Admin]' : ''); ul.appendChild(li);
});
}
div.append(h3, ul);
container.appendChild(div);
});
}
function toggleAddForm() {
document.getElementById('add-user-form').classList.toggle('open');
}
async function createUser() {
const username = document.getElementById('new-username').value;
const password = document.getElementById('new-password').value;
const err = document.getElementById('add-user-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ username, password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
toggleAddForm();
loadAll();
}
function openPasswordModal(userId) {
pendingUserId = userId;
document.getElementById('pw-input').value = '';
document.getElementById('pw-error').style.display = 'none';
document.getElementById('pw-modal').classList.add('open');
}
async function confirmPasswordChange() {
const password = document.getElementById('pw-input').value;
const err = document.getElementById('pw-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ password })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
}
function openDeleteModal(userId, username) {
pendingUserId = userId;
document.getElementById('del-confirm-text').textContent = `Benutzer "${username}" wirklich löschen?`;
document.getElementById('del-modal').classList.add('open');
}
async function confirmDelete() {
await fetch('/api/admin/users/' + pendingUserId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
closeModal();
loadAll();
}
function openAssignModal(userId) {
pendingUserId = userId;
const select = document.getElementById('assign-inventory-select');
select.innerHTML = '';
allInventories.forEach(inv => {
const opt = document.createElement('option');
opt.value = inv.inventoryId;
const names = inv.users.map(u => u.username).join(', ') || '(leer)';
opt.textContent = inv.inventoryId.slice(0, 8) + '… ' + names;
select.appendChild(opt);
});
document.getElementById('assign-error').style.display = 'none';
document.getElementById('assign-modal').classList.add('open');
}
async function confirmAssign() {
const inventoryId = document.getElementById('assign-inventory-select').value;
const err = document.getElementById('assign-error');
err.style.display = 'none';
const res = await fetch('/api/admin/users/' + pendingUserId + '/inventory', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + accessToken },
body: JSON.stringify({ inventoryId })
});
if (!res.ok) {
err.textContent = (await res.json()).message || 'Fehler';
err.style.display = 'block';
return;
}
closeModal();
loadAll();
}
async function createNewInventory(userId) {
if (!confirm('Für diesen Benutzer ein neues, leeres Inventar erstellen?')) return;
await fetch('/api/admin/users/' + userId + '/inventory/new', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + accessToken }
});
loadAll();
}
function closeModal() {
document.querySelectorAll('.modal-overlay').forEach(m => m.classList.remove('open'));
pendingUserId = null;
}
function logout() {
accessToken = '';
sessionStorage.removeItem('accessToken');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-section').style.display = 'block';
document.getElementById('admin-section').style.display = 'none';
document.getElementById('logout-btn').style.display = 'none';
}
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
</script>
<footer>Krisenvorrat Server v0.2 &middot; &copy; 2026 faenocasul</footer>
</body>
</html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

View file

@ -0,0 +1,295 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
import de.krisenvorrat.server.model.AssignInventoryRequest
import de.krisenvorrat.server.model.CreateUserRequest
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.ItemDto
import de.krisenvorrat.shared.model.LocationDto
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.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class InventorySharingTest {
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:sharing_test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
// --- Helper: create a user and return their JWT token + userId ---
private suspend fun ApplicationTestBuilder.createUserAndGetToken(
username: String, password: String = "pass123"
): Pair<String, String> {
// Create via admin API
client.post("/api/admin/users") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(CreateUserRequest(username, password)))
}
// Login to get token
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 token = loginBody.let {
(it as kotlinx.serialization.json.JsonObject)["accessToken"]!!.let { v ->
(v as kotlinx.serialization.json.JsonPrimitive).content
}
}
val userId = loginBody.let {
(it as kotlinx.serialization.json.JsonObject)["userId"]!!.let { v ->
(v as kotlinx.serialization.json.JsonPrimitive).content
}
}
return Pair(token, userId)
}
// --- Tests ---
@Test
fun test_newUser_getsOwnInventory() = testApp {
val (tokenA, _) = createUserAndGetToken("alice")
val (tokenB, _) = createUserAndGetToken("bob")
// Alice stores an item
val inventoryA = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(makeItem("item-a1", "Dosenbrot")),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(tokenA)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventoryA))
}
// Bob sees empty inventory (separate inventory by default)
val res = client.get("/api/inventory") { bearerAuth(tokenB) }
assertEquals(HttpStatusCode.OK, res.status)
val bobInventory = json.decodeFromString<InventoryDto>(res.bodyAsText())
assertTrue("Bob must not see Alice's items", bobInventory.items.isEmpty())
}
@Test
fun test_assignSharedInventory_bothUsersSeesSameData() = testApp {
val (tokenA, userIdA) = createUserAndGetToken("alice2")
val (tokenB, userIdB) = createUserAndGetToken("bob2")
// Alice stores an item in her inventory
val inventoryA = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(makeItem("item-a1", "Dosenbrot")),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(tokenA)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), inventoryA))
}
// Get Alice's inventoryId from admin user list
val usersRes = client.get("/api/admin/users") { bearerAuth(adminToken) }
val users = json.decodeFromString<List<UserDto>>(usersRes.bodyAsText())
val aliceDto = users.find { it.username == "alice2" }
assertNotNull("Alice must exist", aliceDto)
val aliceInventoryId = aliceDto!!.inventoryId
assertNotNull("Alice must have an inventoryId", aliceInventoryId)
// Assign Bob to Alice's inventory
val assignRes = client.put("/api/admin/users/$userIdB/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(aliceInventoryId!!)))
}
assertEquals(HttpStatusCode.OK, assignRes.status)
// Bob now sees Alice's inventory
val bobRes = client.get("/api/inventory") { bearerAuth(tokenB) }
assertEquals(HttpStatusCode.OK, bobRes.status)
val bobInventory = json.decodeFromString<InventoryDto>(bobRes.bodyAsText())
assertEquals(1, bobInventory.items.size)
assertEquals("Dosenbrot", bobInventory.items[0].name)
}
@Test
fun test_sharedInventory_bobCanWrite_aliceSeesChange() = testApp {
val (tokenA, userIdA) = createUserAndGetToken("alice3")
val (tokenB, userIdB) = createUserAndGetToken("bob3")
// Get Alice's inventoryId
val usersRes = client.get("/api/admin/users") { bearerAuth(adminToken) }
val users = json.decodeFromString<List<UserDto>>(usersRes.bodyAsText())
val aliceInventoryId = users.find { it.username == "alice3" }!!.inventoryId!!
// Assign Bob to Alice's inventory
client.put("/api/admin/users/$userIdB/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(aliceInventoryId)))
}
// Bob writes to the shared inventory
val sharedInventory = InventoryDto(
categories = listOf(CategoryDto(id = 2, name = "Getränke")),
locations = listOf(LocationDto(id = 2, name = "Keller")),
items = listOf(makeItem("item-b1", "Wasser")),
settings = emptyList()
)
client.put("/api/inventory") {
bearerAuth(tokenB)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), sharedInventory))
}
// Alice sees Bob's changes
val aliceRes = client.get("/api/inventory") { bearerAuth(tokenA) }
assertEquals(HttpStatusCode.OK, aliceRes.status)
val aliceInventory = json.decodeFromString<InventoryDto>(aliceRes.bodyAsText())
assertEquals(1, aliceInventory.items.size)
assertEquals("Wasser", aliceInventory.items[0].name)
}
@Test
fun test_createNewInventory_disconnectsFromShared() = testApp {
val (_, userIdA) = createUserAndGetToken("alice4")
val (tokenB, userIdB) = createUserAndGetToken("bob4")
// Get Alice's inventoryId and assign Bob to it
val users = json.decodeFromString<List<UserDto>>(
client.get("/api/admin/users") { bearerAuth(adminToken) }.bodyAsText()
)
val aliceInventoryId = users.find { it.username == "alice4" }!!.inventoryId!!
client.put("/api/admin/users/$userIdB/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(aliceInventoryId)))
}
// Create a new inventory for Bob (disconnect)
val newInvRes = client.post("/api/admin/users/$userIdB/inventory/new") {
bearerAuth(adminToken)
}
assertEquals(HttpStatusCode.Created, newInvRes.status)
// Bob should no longer be in Alice's inventory group
val inventoriesRes = client.get("/api/admin/inventories") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.OK, inventoriesRes.status)
val inventories = json.decodeFromString<List<InventoryWithUsersDto>>(inventoriesRes.bodyAsText())
val aliceGroup = inventories.find { it.inventoryId == aliceInventoryId }
assertNotNull(aliceGroup)
assertTrue("Bob must not be in Alice's group", aliceGroup!!.users.none { it.username == "bob4" })
}
@Test
fun test_deleteUser_orphanedEmptyInventoryIsRemoved() = testApp {
val (_, userIdC) = createUserAndGetToken("charlie")
// Get Charlie's inventoryId
val users = json.decodeFromString<List<UserDto>>(
client.get("/api/admin/users") { bearerAuth(adminToken) }.bodyAsText()
)
val charlieInventoryId = users.find { it.username == "charlie" }!!.inventoryId!!
// Delete Charlie
val deleteRes = client.delete("/api/admin/users/$userIdC") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.NoContent, deleteRes.status)
// Charlie's empty inventory should be cleaned up
val inventories = json.decodeFromString<List<InventoryWithUsersDto>>(
client.get("/api/admin/inventories") { bearerAuth(adminToken) }.bodyAsText()
)
assertTrue(
"Charlie's orphaned inventory must be deleted",
inventories.none { it.inventoryId == charlieInventoryId }
)
}
@Test
fun test_listInventories_asAdmin_returnsAllInventories() = testApp {
createUserAndGetToken("diana")
createUserAndGetToken("eve")
val res = client.get("/api/admin/inventories") { bearerAuth(adminToken) }
assertEquals(HttpStatusCode.OK, res.status)
val inventories = json.decodeFromString<List<InventoryWithUsersDto>>(res.bodyAsText())
// admin + diana + eve → at least 3 inventories
assertTrue(inventories.size >= 3)
}
@Test
fun test_assignInventory_nonAdmin_returns403() = testApp {
val (tokenA, userIdA) = createUserAndGetToken("frank")
val users = json.decodeFromString<List<UserDto>>(
client.get("/api/admin/users") { bearerAuth(adminToken) }.bodyAsText()
)
val frankInventoryId = users.find { it.username == "frank" }!!.inventoryId!!
val res = client.put("/api/admin/users/$userIdA/inventory") {
bearerAuth(tokenA) // non-admin token
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest(frankInventoryId)))
}
assertEquals(HttpStatusCode.Forbidden, res.status)
}
@Test
fun test_assignInventory_nonExistentInventory_returns404() = testApp {
val (_, userIdA) = createUserAndGetToken("grace")
val res = client.put("/api/admin/users/$userIdA/inventory") {
bearerAuth(adminToken)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(AssignInventoryRequest("non-existent-inventory-id")))
}
assertEquals(HttpStatusCode.NotFound, res.status)
}
private fun makeItem(id: String, name: String) = ItemDto(
id = id,
name = name,
categoryId = 1,
quantity = 1.0,
unit = "Stück",
unitPrice = 1.99,
kcalPerKg = null,
expiryDate = null,
locationId = 1,
notes = "",
lastUpdated = System.currentTimeMillis()
)
}