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:
parent
4b1a5818f2
commit
549e4c916e
4 changed files with 355 additions and 7 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
272
server/src/test/kotlin/de/krisenvorrat/server/PatchItemTest.kt
Normal file
272
server/src/test/kotlin/de/krisenvorrat/server/PatchItemTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue