feat(server): add REST-API endpoints for inventory sync & CRUD
server/src/main/kotlin/.../routes/InventoryRoutes.kt: - GET /api/inventory: returns full inventory as JSON - PUT /api/inventory: full-sync replaces entire server inventory server/src/main/kotlin/.../plugins/StatusPages.kt: - Structured error handling via Ktor StatusPages plugin - BadRequestException, SerializationException, IllegalArgumentException → 400 - Unhandled exceptions → 500 with logging server/src/main/kotlin/.../plugins/CallLogging.kt: - Request logging via Ktor CallLogging plugin (INFO level) server/src/main/kotlin/.../model/ErrorResponse.kt: - Serializable error response DTO (status + message) server/src/main/kotlin/.../plugins/Routing.kt: - Health endpoint moved from /health to /api/health - Inventory routes mounted under /api/inventory server/src/main/kotlin/.../Application.kt: - Added configurePlugins() for testability (DB init separate) - StatusPages and CallLogging plugins configured server/src/test/.../ApplicationTest.kt: - 8 endpoint tests using Ktor TestApplication with in-memory H2 - Tests: health, 404, empty GET, PUT valid, PUT+GET roundtrip, invalid JSON → 400, data replacement, JSON content type Closes #42
This commit is contained in:
parent
2387c6ee5a
commit
5974144621
9 changed files with 282 additions and 13 deletions
|
|
@ -54,6 +54,8 @@ ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref
|
|||
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-server-config-yaml = { group = "io.ktor", name = "ktor-server-config-yaml", version.ref = "ktor" }
|
||||
ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages", version.ref = "ktor" }
|
||||
ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" }
|
||||
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
|
||||
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
|
||||
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ dependencies {
|
|||
implementation(libs.ktor.server.core)
|
||||
implementation(libs.ktor.server.netty)
|
||||
implementation(libs.ktor.server.content.negotiation)
|
||||
implementation(libs.ktor.server.status.pages)
|
||||
implementation(libs.ktor.server.call.logging)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.logback.classic)
|
||||
implementation(libs.exposed.core)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package de.krisenvorrat.server
|
||||
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.plugins.configureCallLogging
|
||||
import de.krisenvorrat.server.plugins.configureRouting
|
||||
import de.krisenvorrat.server.plugins.configureSerialization
|
||||
import de.krisenvorrat.server.plugins.configureStatusPages
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.netty.*
|
||||
|
||||
|
|
@ -12,6 +14,12 @@ fun main(args: Array<String>) {
|
|||
|
||||
internal fun Application.module() {
|
||||
DatabaseFactory.init()
|
||||
configurePlugins()
|
||||
}
|
||||
|
||||
internal fun Application.configurePlugins() {
|
||||
configureSerialization()
|
||||
configureStatusPages()
|
||||
configureCallLogging()
|
||||
configureRouting()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package de.krisenvorrat.server.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
internal data class ErrorResponse(
|
||||
val status: Int,
|
||||
val message: String
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package de.krisenvorrat.server.plugins
|
||||
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.calllogging.*
|
||||
import org.slf4j.event.Level
|
||||
|
||||
internal fun Application.configureCallLogging() {
|
||||
install(CallLogging) {
|
||||
level = Level.INFO
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
package de.krisenvorrat.server.plugins
|
||||
|
||||
import de.krisenvorrat.server.repository.InventoryRepository
|
||||
import de.krisenvorrat.server.routes.inventoryRoutes
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
internal fun Application.configureRouting() {
|
||||
internal fun Application.configureRouting(repository: InventoryRepository = InventoryRepository()) {
|
||||
routing {
|
||||
get("/health") {
|
||||
get("/api/health") {
|
||||
call.respondText("OK", ContentType.Text.Plain, HttpStatusCode.OK)
|
||||
}
|
||||
inventoryRoutes(repository)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
package de.krisenvorrat.server.plugins
|
||||
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.plugins.*
|
||||
import io.ktor.server.plugins.statuspages.*
|
||||
import io.ktor.server.response.*
|
||||
import kotlinx.serialization.SerializationException
|
||||
|
||||
internal fun Application.configureStatusPages() {
|
||||
install(StatusPages) {
|
||||
exception<BadRequestException> { call, cause ->
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorResponse(
|
||||
status = HttpStatusCode.BadRequest.value,
|
||||
message = "Invalid request body: ${cause.cause?.message ?: cause.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
exception<SerializationException> { call, cause ->
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorResponse(
|
||||
status = HttpStatusCode.BadRequest.value,
|
||||
message = "Invalid request body: ${cause.message}"
|
||||
)
|
||||
)
|
||||
}
|
||||
exception<IllegalArgumentException> { call, cause ->
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorResponse(
|
||||
status = HttpStatusCode.BadRequest.value,
|
||||
message = cause.message ?: "Bad request"
|
||||
)
|
||||
)
|
||||
}
|
||||
exception<Throwable> { call, cause ->
|
||||
call.application.log.error("Unhandled exception", cause)
|
||||
call.respond(
|
||||
HttpStatusCode.InternalServerError,
|
||||
ErrorResponse(
|
||||
status = HttpStatusCode.InternalServerError.value,
|
||||
message = "Internal server error"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package de.krisenvorrat.server.routes
|
||||
|
||||
import de.krisenvorrat.server.repository.InventoryRepository
|
||||
import de.krisenvorrat.shared.model.InventoryDto
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.request.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
|
||||
internal fun Route.inventoryRoutes(repository: InventoryRepository) {
|
||||
route("/api/inventory") {
|
||||
get {
|
||||
val inventory = repository.loadInventory()
|
||||
call.respond(HttpStatusCode.OK, inventory)
|
||||
}
|
||||
|
||||
put {
|
||||
val inventory = call.receive<InventoryDto>()
|
||||
repository.saveInventory(inventory)
|
||||
val saved = repository.loadInventory()
|
||||
call.respond(HttpStatusCode.OK, saved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,42 @@
|
|||
package de.krisenvorrat.server
|
||||
|
||||
import de.krisenvorrat.server.db.DatabaseFactory
|
||||
import de.krisenvorrat.server.model.ErrorResponse
|
||||
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 de.krisenvorrat.shared.model.SettingDto
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.testing.*
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ApplicationTest {
|
||||
|
||||
@Test
|
||||
fun test_healthEndpoint_returnsOk() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
private fun testApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication {
|
||||
application {
|
||||
DatabaseFactory.init(
|
||||
jdbcUrl = "jdbc:h2:mem:test_${System.nanoTime()};DB_CLOSE_DELAY=-1",
|
||||
driver = "org.h2.Driver"
|
||||
)
|
||||
configurePlugins()
|
||||
}
|
||||
block()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_healthEndpoint_returnsOk() = testApp {
|
||||
// When
|
||||
val response = client.get("/health")
|
||||
val response = client.get("/api/health")
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
|
|
@ -24,15 +44,154 @@ class ApplicationTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun test_unknownRoute_returns404() = testApplication {
|
||||
application {
|
||||
module()
|
||||
}
|
||||
|
||||
fun test_unknownRoute_returns404() = testApp {
|
||||
// When
|
||||
val response = client.get("/nonexistent")
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getInventory_emptyDatabase_returnsEmptyInventory() = testApp {
|
||||
// When
|
||||
val response = client.get("/api/inventory")
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val inventory = json.decodeFromString<InventoryDto>(response.bodyAsText())
|
||||
assertTrue(inventory.categories.isEmpty())
|
||||
assertTrue(inventory.locations.isEmpty())
|
||||
assertTrue(inventory.items.isEmpty())
|
||||
assertTrue(inventory.settings.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_putInventory_validData_returnsUpdatedInventory() = testApp {
|
||||
// Given
|
||||
val inventory = createTestInventory()
|
||||
val body = json.encodeToString(InventoryDto.serializer(), inventory)
|
||||
|
||||
// When
|
||||
val response = client.put("/api/inventory") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
|
||||
assertEquals(2, result.categories.size)
|
||||
assertEquals("Konserven", result.categories[0].name)
|
||||
assertEquals(1, result.items.size)
|
||||
assertEquals("Dosenbrot", result.items[0].name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_putThenGet_roundTripsCorrectly() = testApp {
|
||||
// Given
|
||||
val inventory = createTestInventory()
|
||||
val body = json.encodeToString(InventoryDto.serializer(), inventory)
|
||||
|
||||
// When – PUT
|
||||
client.put("/api/inventory") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(body)
|
||||
}
|
||||
|
||||
// When – GET
|
||||
val response = client.get("/api/inventory")
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
|
||||
assertEquals(2, result.categories.size)
|
||||
assertEquals(2, result.locations.size)
|
||||
assertEquals(1, result.items.size)
|
||||
assertEquals(1, result.settings.size)
|
||||
assertEquals("item-1", result.items[0].id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_putInventory_invalidJson_returns400() = testApp {
|
||||
// When
|
||||
val response = client.put("/api/inventory") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody("{invalid json}")
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.BadRequest, response.status)
|
||||
val error = json.decodeFromString<ErrorResponse>(response.bodyAsText())
|
||||
assertEquals(400, error.status)
|
||||
assertTrue(error.message.contains("Invalid request body"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_putInventory_replacesExistingData() = testApp {
|
||||
// Given – first PUT
|
||||
val firstInventory = createTestInventory()
|
||||
client.put("/api/inventory") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(InventoryDto.serializer(), firstInventory))
|
||||
}
|
||||
|
||||
// When – second PUT with different data
|
||||
val updatedInventory = InventoryDto(
|
||||
categories = listOf(CategoryDto(id = 10, name = "Neu")),
|
||||
locations = listOf(LocationDto(id = 10, name = "Garage")),
|
||||
items = emptyList(),
|
||||
settings = listOf(SettingDto(key = "lang", value = "de"))
|
||||
)
|
||||
val response = client.put("/api/inventory") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(json.encodeToString(InventoryDto.serializer(), updatedInventory))
|
||||
}
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
val result = json.decodeFromString<InventoryDto>(response.bodyAsText())
|
||||
assertEquals(1, result.categories.size)
|
||||
assertEquals("Neu", result.categories[0].name)
|
||||
assertEquals(0, result.items.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun test_getInventory_returnsJsonContentType() = testApp {
|
||||
// When
|
||||
val response = client.get("/api/inventory")
|
||||
|
||||
// Then
|
||||
assertEquals(ContentType.Application.Json.withCharset(Charsets.UTF_8), response.contentType())
|
||||
}
|
||||
|
||||
private fun createTestInventory(): InventoryDto = InventoryDto(
|
||||
categories = listOf(
|
||||
CategoryDto(id = 1, name = "Konserven"),
|
||||
CategoryDto(id = 2, name = "Getränke")
|
||||
),
|
||||
locations = listOf(
|
||||
LocationDto(id = 1, name = "Keller"),
|
||||
LocationDto(id = 2, name = "Speisekammer")
|
||||
),
|
||||
items = listOf(
|
||||
ItemDto(
|
||||
id = "item-1",
|
||||
name = "Dosenbrot",
|
||||
categoryId = 1,
|
||||
quantity = 5.0,
|
||||
unit = "Stück",
|
||||
unitPrice = 3.99,
|
||||
kcalPer100g = 250,
|
||||
expiryDate = "2027-06-15",
|
||||
locationId = 1,
|
||||
minStock = 2.0,
|
||||
notes = "Vollkornbrot in der Dose",
|
||||
lastUpdated = 1715000000L
|
||||
)
|
||||
),
|
||||
settings = listOf(
|
||||
SettingDto(key = "theme", value = "dark")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue