feat(server): PATCH /api/inventory/items/{id} auf partielles Update umstellen

- Route empfängt JsonObject statt ItemDto, nur übergebene Felder werden aktualisiert
- validatePartialItem() validiert nur vorhandene Felder (name, unit, expiryDate)
- patchItemPartial() im Repository: Guard für leere Felder, kein SQL-Update wenn nichts zu ändern
- Response liefert das aktualisierte Item aus der DB (loadItem) statt des Inputs
- Bestehende Tests in InputValidationTest angepasst (senden nun partielle JSON-Bodys)
- Neue PatchItemTest-Klasse: 10 Tests (Happy Path, 404, Auth, Validierung, Persistenz)
- Alle 554 Tests grün

Closes #56
This commit is contained in:
Jens Reinemann 2026-05-17 02:13:24 +02:00
parent 4b1a5818f2
commit 549e4c916e
4 changed files with 355 additions and 7 deletions

View file

@ -13,6 +13,12 @@ import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere
@ -204,6 +210,58 @@ internal class InventoryRepository {
}
}
fun patchItemPartial(inventoryId: String, itemId: String, fields: JsonObject): Boolean {
val updatableKeys = setOf("name", "categoryId", "quantity", "unit", "unitPrice", "kcalPerKg", "expiryDate", "locationId", "notes", "lastUpdated")
return transaction {
val exists = Items.selectAll()
.where { (Items.id eq itemId) and (Items.inventoryId eq inventoryId) }
.count() > 0
if (!exists) return@transaction false
val hasUpdatableFields = fields.keys.any { it in updatableKeys }
if (!hasUpdatableFields) return@transaction true
Items.update({
(Items.id eq itemId) and (Items.inventoryId eq inventoryId)
}) { stmt ->
if ("name" in fields) stmt[name] = fields["name"]!!.jsonPrimitive.content
if ("categoryId" in fields) stmt[categoryId] = fields["categoryId"]!!.jsonPrimitive.intOrNull ?: 0
if ("quantity" in fields) stmt[quantity] = fields["quantity"]!!.jsonPrimitive.doubleOrNull ?: 0.0
if ("unit" in fields) stmt[unit] = fields["unit"]!!.jsonPrimitive.content
if ("unitPrice" in fields) stmt[unitPrice] = fields["unitPrice"]!!.jsonPrimitive.doubleOrNull ?: 0.0
if ("kcalPerKg" in fields) stmt[kcalPerKg] = fields["kcalPerKg"]!!.jsonPrimitive.intOrNull
if ("expiryDate" in fields) stmt[expiryDate] = fields["expiryDate"]!!.jsonPrimitive.contentOrNull
if ("locationId" in fields) stmt[locationId] = fields["locationId"]!!.jsonPrimitive.intOrNull ?: 0
if ("notes" in fields) stmt[notes] = fields["notes"]!!.jsonPrimitive.content
if ("lastUpdated" in fields) stmt[lastUpdated] = fields["lastUpdated"]!!.jsonPrimitive.longOrNull ?: System.currentTimeMillis()
}
true
}
}
fun loadItem(inventoryId: String, itemId: String): ItemDto? {
return transaction {
Items.selectAll()
.where { (Items.id eq itemId) and (Items.inventoryId eq inventoryId) }
.map {
ItemDto(
id = it[Items.id],
name = it[Items.name],
categoryId = it[Items.categoryId],
quantity = it[Items.quantity],
unit = it[Items.unit],
unitPrice = it[Items.unitPrice],
kcalPerKg = it[Items.kcalPerKg],
expiryDate = it[Items.expiryDate],
locationId = it[Items.locationId],
notes = it[Items.notes],
lastUpdated = it[Items.lastUpdated]
)
}
.singleOrNull()
}
}
fun deleteItem(inventoryId: String, itemId: String): Boolean {
return transaction {
val deleted = Items.deleteWhere {

View file

@ -11,6 +11,11 @@ import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
private val EXPIRY_DATE_REGEX = Regex("""^\d{4}-\d{2}-\d{2}$""")
@ -28,6 +33,19 @@ private fun validateItem(item: ItemDto): String? {
return null
}
private fun validatePartialItem(fields: JsonObject): String? {
fields["name"]?.jsonPrimitive?.content?.let { name ->
if (name.length > 255) return "Item name too long (max 255 characters)"
}
fields["unit"]?.jsonPrimitive?.content?.let { unit ->
if (unit.length > 50) return "Item unit too long (max 50 characters)"
}
fields["expiryDate"]?.jsonPrimitive?.contentOrNull?.let { date ->
if (!date.matches(EXPIRY_DATE_REGEX)) return "Invalid expiryDate format '$date' expected YYYY-MM-DD"
}
return null
}
internal fun Route.inventoryRoutes(
repository: InventoryRepository,
wsManager: WebSocketManager
@ -100,14 +118,16 @@ internal fun Route.inventoryRoutes(
if (itemId.length > 36) return@patch call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Item id too long (max 36 characters)")
)
val item = call.receive<ItemDto>()
validateItem(item)?.let { error ->
val body = call.receiveText()
val fields = Json.parseToJsonElement(body).jsonObject
validatePartialItem(fields)?.let { error ->
return@patch call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = error))
}
val updated = repository.patchItem(inventoryId, itemId, item)
val updated = repository.patchItemPartial(inventoryId, itemId, fields)
if (!updated) {
call.respond(HttpStatusCode.NotFound, ErrorResponse(status = 404, message = "Item not found"))
} else {
val item = repository.loadItem(inventoryId, itemId)!!
wsManager.notifyInventoryUpdated(userId, itemId)
call.respond(HttpStatusCode.OK, item)
}

View file

@ -265,11 +265,10 @@ class InputValidationTest {
setBody(setupBody)
}
val badItem = validItem().copy(expiryDate = "31.12.2027")
val response = client.patch("/api/inventory/items/${validItem().id}") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(badItem))
setBody("""{"expiryDate":"31.12.2027"}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)
@ -284,11 +283,10 @@ class InputValidationTest {
setBody(setupBody)
}
val badItem = validItem().copy(name = "N".repeat(256))
val response = client.patch("/api/inventory/items/${validItem().id}") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(badItem))
setBody("""{"name":"${"N".repeat(256)}"}""")
}
assertEquals(HttpStatusCode.BadRequest, response.status)

View file

@ -0,0 +1,272 @@
package de.krisenvorrat.server
import de.krisenvorrat.server.db.DatabaseFactory
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.json.Json
import org.junit.Assert.assertEquals
import org.junit.Test
class PatchItemTest {
private val json = Json { ignoreUnknownKeys = true }
private val token = createTestAccessToken()
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
environment {
config = MapApplicationConfig(*testMapConfig().toTypedArray())
}
application {
DatabaseFactory.init(
jdbcUrl = "jdbc:h2:mem:patch_${System.nanoTime()};DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver",
adminPassword = "test-admin-pw"
)
configurePlugins()
}
block()
}
private fun seedInventory(): String {
val item = ItemDto(
id = "item-patch-1",
name = "Dosenbrot",
categoryId = 1,
quantity = 5.0,
unit = "Stück",
unitPrice = 3.99,
kcalPerKg = 250,
expiryDate = "2027-06-15",
locationId = 1,
notes = "Vollkornbrot",
lastUpdated = 1715000000L
)
val inventory = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(item),
settings = emptyList()
)
return json.encodeToString(InventoryDto.serializer(), inventory)
}
// ── Happy Path ───────────────────────────────────────────────────────────
@Test
fun test_patchItem_partialUpdate_onlyUpdatesProvidedFields() = testApp {
// Given
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(seedInventory())
}
// When only update name and quantity
val response = client.patch("/api/inventory/items/item-patch-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"name":"Pumpernickel","quantity":10.0}""")
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<ItemDto>(response.bodyAsText())
assertEquals("Pumpernickel", result.name)
assertEquals(10.0, result.quantity, 0.001)
// Unchanged fields remain
assertEquals("Stück", result.unit)
assertEquals(3.99, result.unitPrice, 0.001)
assertEquals(250, result.kcalPerKg)
assertEquals("2027-06-15", result.expiryDate)
assertEquals(1, result.locationId)
assertEquals("Vollkornbrot", result.notes)
}
@Test
fun test_patchItem_updateSingleField_returnsFullItem() = testApp {
// Given
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(seedInventory())
}
// When only update kcalPerKg
val response = client.patch("/api/inventory/items/item-patch-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"kcalPerKg":3200}""")
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<ItemDto>(response.bodyAsText())
assertEquals(3200, result.kcalPerKg)
assertEquals("Dosenbrot", result.name)
assertEquals("item-patch-1", result.id)
}
@Test
fun test_patchItem_updateNullableFieldToNull_setsNull() = testApp {
// Given
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(seedInventory())
}
// When set expiryDate to null
val response = client.patch("/api/inventory/items/item-patch-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"expiryDate":null}""")
}
// Then
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<ItemDto>(response.bodyAsText())
assertEquals(null, result.expiryDate)
}
@Test
fun test_patchItem_persistsChanges() = testApp {
// Given
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(seedInventory())
}
// When patch then reload full inventory
client.patch("/api/inventory/items/item-patch-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"name":"Geänderter Name"}""")
}
val getResponse = client.get("/api/inventory") {
bearerAuth(token)
}
// Then
val inventory = json.decodeFromString<InventoryDto>(getResponse.bodyAsText())
assertEquals("Geänderter Name", inventory.items[0].name)
}
// ── 404: Item not found ──────────────────────────────────────────────────
@Test
fun test_patchItem_nonExistentItem_returns404() = testApp {
// Given empty inventory
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(json.encodeToString(InventoryDto.serializer(), InventoryDto(
categories = emptyList(), locations = emptyList(), items = emptyList(), settings = emptyList()
)))
}
// When
val response = client.patch("/api/inventory/items/nonexistent-id") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"name":"Test"}""")
}
// Then
assertEquals(HttpStatusCode.NotFound, response.status)
}
// ── Authentication ───────────────────────────────────────────────────────
@Test
fun test_patchItem_noToken_returns401() = testApp {
// When no bearer token
val response = client.patch("/api/inventory/items/item-patch-1") {
contentType(ContentType.Application.Json)
setBody("""{"name":"Test"}""")
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
@Test
fun test_patchItem_invalidToken_returns401() = testApp {
// When invalid token
val response = client.patch("/api/inventory/items/item-patch-1") {
bearerAuth("invalid-token-value")
contentType(ContentType.Application.Json)
setBody("""{"name":"Test"}""")
}
// Then
assertEquals(HttpStatusCode.Unauthorized, response.status)
}
// ── Validation ───────────────────────────────────────────────────────────
@Test
fun test_patchItem_itemIdTooLong_returns400() = testApp {
// When
val longId = "X".repeat(37)
val response = client.patch("/api/inventory/items/$longId") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"name":"Test"}""")
}
// Then
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_patchItem_unitTooLong_returns400() = testApp {
// Given
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(seedInventory())
}
// When
val response = client.patch("/api/inventory/items/item-patch-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{"unit":"${"X".repeat(51)}"}""")
}
// Then
assertEquals(HttpStatusCode.BadRequest, response.status)
}
@Test
fun test_patchItem_emptyBody_updatesNothing() = testApp {
// Given
client.put("/api/inventory") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody(seedInventory())
}
// When empty JSON object
val response = client.patch("/api/inventory/items/item-patch-1") {
bearerAuth(token)
contentType(ContentType.Application.Json)
setBody("""{}""")
}
// Then item unchanged
assertEquals(HttpStatusCode.OK, response.status)
val result = json.decodeFromString<ItemDto>(response.bodyAsText())
assertEquals("Dosenbrot", result.name)
assertEquals(5.0, result.quantity, 0.001)
}
}