Introduce Ktor-based HTTP server for Masterdata context, implement upsert logic for Altersklasse, Bundesland, and Land repositories, enhance IdempotencyPlugin, and add integration tests.
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled

This commit is contained in:
2026-03-29 00:05:14 +01:00
parent eedce74a85
commit 8c2a82403e
21 changed files with 919 additions and 124 deletions
@@ -0,0 +1,50 @@
package at.mocode.masterdata.service.config
import at.mocode.masterdata.api.masterdataApiModule
import at.mocode.masterdata.api.rest.AltersklasseController
import at.mocode.masterdata.api.rest.BundeslandController
import at.mocode.masterdata.api.rest.CountryController
import at.mocode.masterdata.api.rest.PlatzController
import io.ktor.server.engine.embeddedServer
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.DisposableBean
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Ktor-Server Bootstrap für den Masterdata-Bounded-Context (SCS-Architektur).
*
* - Startet einen eigenen Ktor Netty Server für diesen Kontext.
* - Hängt das masterdataApiModule mit den via Spring bereitgestellten Controllern ein.
* - Port ist konfigurierbar über SPRING-Config/ENV (Default 8091). Für Tests kann Port 0 genutzt werden.
*/
@Configuration
class KtorServerConfiguration {
private val log = LoggerFactory.getLogger(KtorServerConfiguration::class.java)
@Bean(destroyMethod = "stop")
fun ktorServer(
@Value("\${masterdata.http.port:8091}") port: Int,
countryController: CountryController,
bundeslandController: BundeslandController,
altersklasseController: AltersklasseController,
platzController: PlatzController
): EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
log.info("Starting Masterdata Ktor server on port {}", port)
val engine = embeddedServer(Netty, port = port) {
masterdataApiModule(
countryController = countryController,
bundeslandController = bundeslandController,
altersklasseController = altersklasseController,
platzController = platzController
)
}
engine.start(wait = false)
return engine
}
}
@@ -0,0 +1,107 @@
package at.mocode.masterdata.service.api
import at.mocode.masterdata.service.MasterdataServiceApplication
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
@SpringBootTest(
classes = [MasterdataServiceApplication::class],
properties = [
"spring.main.web-application-type=none",
"masterdata.http.port=18091" // fixed port for tests to simplify port discovery
]
)
@ActiveProfiles("test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IdempotencyApiIntegrationTest {
private lateinit var baseUri: String
@BeforeAll
fun setUp() {
baseUri = "http://localhost:18091"
}
@AfterAll
fun tearDown() {
// Server lifecycle managed by Spring; no explicit stop here.
}
@Test
fun `second POST with same Idempotency-Key returns identical response and does not create duplicate`() {
val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()
val payload = """
{
"isoAlpha2Code": "ZZ",
"isoAlpha3Code": "ZZY",
"nameDeutsch": "Testland Idempotency"
}
""".trimIndent()
val key = "itest-key-001"
val req1 = HttpRequest.newBuilder()
.uri(URI.create("$baseUri/countries"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build()
val res1 = sendWithRetry(client, req1)
val req2 = HttpRequest.newBuilder()
.uri(URI.create("$baseUri/countries"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build()
val res2 = sendWithRetry(client, req2)
// Beide Antworten müssen identisch sein (Status + Body)
assertThat(res1.statusCode()).isIn(200, 201) // Created or OK
assertThat(res2.statusCode()).isEqualTo(res1.statusCode())
assertThat(res2.headers().firstValue("Content-Type").orElse("application/json")).contains("application/json")
assertThat(res2.body()).isEqualTo(res1.body())
// Verifizieren, dass kein Duplikat erzeugt wurde:
// GET /countries?search=Testland%20Idempotency&unpaged=true sollte genau 1 Element enthalten
val resList = sendWithRetry(
client,
HttpRequest.newBuilder()
.uri(URI.create("$baseUri/countries?search=Testland%20Idempotency&unpaged=true"))
.GET()
.build()
)
assertThat(resList.statusCode()).isEqualTo(200)
// sehr leichte Prüfung auf genau ein Ergebnis: zähle Vorkommen der isoAlpha3Code in JSON
val occurrences = Regex("\"isoAlpha3Code\"\\s*:\\s*\"ZZY\"").findAll(resList.body()).count()
assertThat(occurrences).isEqualTo(1)
}
private fun sendWithRetry(client: HttpClient, request: HttpRequest, attempts: Int = 10, delayMillis: Long = 100): HttpResponse<String> {
var lastEx: Exception? = null
repeat(attempts) { idx ->
try {
return client.send(request, HttpResponse.BodyHandlers.ofString())
} catch (e: Exception) {
lastEx = e
if (idx == attempts - 1) throw e
Thread.sleep(delayMillis)
}
}
throw lastEx ?: IllegalStateException("unreachable")
}
}