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.ItemDto
import de.krisenvorrat.shared.model.LocationDto import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto import de.krisenvorrat.shared.model.SettingDto
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.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.deleteWhere 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 { fun deleteItem(inventoryId: String, itemId: String): Boolean {
return transaction { return transaction {
val deleted = Items.deleteWhere { val deleted = Items.deleteWhere {

View file

@ -11,6 +11,11 @@ import io.ktor.server.auth.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* 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}$""") private val EXPIRY_DATE_REGEX = Regex("""^\d{4}-\d{2}-\d{2}$""")
@ -28,6 +33,19 @@ private fun validateItem(item: ItemDto): String? {
return null 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( internal fun Route.inventoryRoutes(
repository: InventoryRepository, repository: InventoryRepository,
wsManager: WebSocketManager wsManager: WebSocketManager
@ -100,14 +118,16 @@ internal fun Route.inventoryRoutes(
if (itemId.length > 36) return@patch call.respond( if (itemId.length > 36) return@patch call.respond(
HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Item id too long (max 36 characters)") HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = "Item id too long (max 36 characters)")
) )
val item = call.receive<ItemDto>() val body = call.receiveText()
validateItem(item)?.let { error -> val fields = Json.parseToJsonElement(body).jsonObject
validatePartialItem(fields)?.let { error ->
return@patch call.respond(HttpStatusCode.BadRequest, ErrorResponse(status = 400, message = 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) { 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 {
val item = repository.loadItem(inventoryId, itemId)!!
wsManager.notifyInventoryUpdated(userId, itemId) wsManager.notifyInventoryUpdated(userId, itemId)
call.respond(HttpStatusCode.OK, item) call.respond(HttpStatusCode.OK, item)
} }

View file

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