Introduce Ktor-based HTTP server for Masterdata context, implement upsert logic for Altersklasse, Bundesland, and Land repositories, enhance IdempotencyPlugin, and add integration tests.
Some checks failed
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:
Stefan Mogeritsch 2026-03-29 00:05:14 +01:00
parent eedce74a85
commit 8c2a82403e
21 changed files with 919 additions and 124 deletions

View File

@ -34,5 +34,6 @@ dependencies {
implementation(libs.ktor.server.authJwt)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests)
// Ktor 3.x Test-Host statt veraltetes tests-Artefakt
testImplementation(libs.ktor.server.testHost)
}

View File

@ -27,5 +27,6 @@ dependencies {
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests)
// Ktor 3.x: Verwende das Test-Host-Artefakt statt des veralteten "ktor-server-tests-jvm"
testImplementation(libs.ktor.server.testHost)
}

View File

@ -0,0 +1,33 @@
package at.mocode.masterdata.api
import at.mocode.masterdata.api.plugins.IdempotencyPlugin
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.application.Application
import io.ktor.server.routing.routing
/**
* Ktor-Modul für den Masterdata-Bounded-Context.
*
* - Installiert das IdempotencyPlugin (Header Idempotency-Key) global.
* - Registriert alle Masterdata-Routen (Country, Bundesland, Altersklasse, Platz).
*/
fun Application.masterdataApiModule(
countryController: CountryController,
bundeslandController: BundeslandController,
altersklasseController: AltersklasseController,
platzController: PlatzController
) {
// Installiere das Idempotency-Plugin global für alle Routen
IdempotencyPlugin.install(this)
// Registriere die REST-Routen der Controller
routing {
with(countryController) { registerRoutes() }
with(bundeslandController) { registerRoutes() }
with(altersklasseController) { registerRoutes() }
with(platzController) { registerRoutes() }
}
}

View File

@ -1,26 +1,182 @@
package at.mocode.masterdata.api.plugins
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.util.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
/**
* Minimaler Ktor-Plugin-Skeleton für Idempotency-Key Verarbeitung.
* Aktuell: Stellt den Header `Idempotency-Key` als Call-Attribut bereit.
* Erweiterung (Cache/Short-Circuit) folgt im nächsten Schritt.
* Ktor-Plugin für Idempotency-Key Verarbeitung mit leichtgewichtigem In-Memory-Cache (TTL, Default 5 Min).
*
* Verhalten:
* - Liest Header "Idempotency-Key" für POST/PUT.
* - Prüft Cache: bei Treffer (nicht abgelaufen) wird die zuvor gespeicherte Response sofort zurückgegeben (Short-Circuit).
* - Nach erfolgreicher Antwort (200/201/204) wird falls möglich der serialisierte Response-Body inkl. Status/Headers im Cache abgelegt.
*
* Hinweise:
* - Es werden ausschließlich Antworten zwischengespeichert, deren Body als Text oder ByteArray vorliegt (typisch für JSON).
* - Kein Persistenz-Layer (lean MVP). Für Multi-Node/HA später Option B (idempotency_records) umsetzen.
*/
object IdempotencyPlugin {
@JvmInline
value class Configuration(val ttl: Duration = Duration.ofMinutes(5))
val IdempotencyKeyAttr: AttributeKey<String> = AttributeKey("IdempotencyKey")
private val CacheAttr: AttributeKey<java.util.concurrent.ConcurrentHashMap<String, CacheEntry>> = AttributeKey("IdempotencyCache")
private val InflightAttr: AttributeKey<java.util.concurrent.ConcurrentHashMap<String, CompletableDeferred<CacheEntry>>> = AttributeKey("IdempotencyInflight")
private val LeaderFlagAttr: AttributeKey<Boolean> = AttributeKey("IdempotencyLeaderFlag")
private data class CacheEntry(
val status: HttpStatusCode,
val contentType: ContentType?,
val body: ByteArray,
val storedAtMillis: Long
)
// Hinweis: Kein globaler Cache mehr! Der Cache ist pro Application-Instance gebunden,
// um Test-Interferenzen und Cross-App-Leaks zu vermeiden.
fun install(application: Application, configuration: Configuration = Configuration()) {
val ttlMillis = configuration.ttl.toMillis()
// Per-Application Cache initialisieren (falls nicht vorhanden)
if (!application.attributes.contains(CacheAttr)) {
application.attributes.put(CacheAttr, ConcurrentHashMap())
}
if (!application.attributes.contains(InflightAttr)) {
application.attributes.put(InflightAttr, ConcurrentHashMap())
}
// Vor der eigentlichen Verarbeitung: Cache prüfen und ggf. Short-Circuit
application.intercept(ApplicationCallPipeline.Plugins) {
val key = call.request.headers["Idempotency-Key"]?.trim()
if (!key.isNullOrBlank()) {
call.attributes.put(IdempotencyKeyAttr, key)
val cache = application.attributes[CacheAttr]
val entry = cache[key]
if (entry != null) {
val now = System.currentTimeMillis()
if (now - entry.storedAtMillis <= ttlMillis) {
// Short-Circuit mit gecachter Antwort
call.response.status(entry.status)
val ct = entry.contentType ?: ContentType.Application.Json
call.respondBytes(bytes = entry.body, contentType = ct)
finish()
return@intercept
} else if (now - entry.storedAtMillis > ttlMillis) {
cache.remove(key)
}
}
// Concurrent duplicate handling: warte auf in-flight Ergebnis
val inflight = application.attributes[InflightAttr]
val parentJob = call.coroutineContext[Job]
val deferred = CompletableDeferred<CacheEntry>(parent = parentJob)
val existing = inflight.putIfAbsent(key, deferred)
if (existing != null) {
// Follower: auf Ergebnis warten und sofort antworten
try {
val result = existing.await()
call.response.status(result.status)
val ct = result.contentType ?: ContentType.Application.Json
call.respondBytes(bytes = result.body, contentType = ct)
finish()
return@intercept
} catch (e: Exception) {
// Leader wird Aufräumen übernehmen (invokeOnCompletion). Normale Verarbeitung zulassen.
}
} else {
// Leader dieses Keys für diese Call-Pipeline markieren
call.attributes.put(LeaderFlagAttr, true)
// Sicherheitsnetz: Wenn der Call endet, aber kein Ergebnis gesetzt wurde,
// verhindere hängende Deferreds und bereinige In-Flight-Eintrag.
parentJob?.invokeOnCompletion { cause ->
val d = inflight.remove(key) ?: return@invokeOnCompletion
if (!d.isCompleted) {
if (cause != null) d.completeExceptionally(cause)
else d.completeExceptionally(IllegalStateException("Idempotency: call finished without completing result"))
}
}
}
}
}
// Nach dem Serialisieren der Antwort: ggf. in Cache legen
application.sendPipeline.intercept(ApplicationSendPipeline.After) { subject ->
val key = call.attributes.getOrNull(IdempotencyKeyAttr) ?: return@intercept
val status = call.response.status() ?: return@intercept
if (status != HttpStatusCode.OK && status != HttpStatusCode.Created && status != HttpStatusCode.NoContent) return@intercept
// Nur ausgewählte Content-Typen/Body-Formen cachen
var bodyBytes: ByteArray? = null
var contentType: ContentType? = null
when (subject) {
is OutgoingContent.ByteArrayContent -> {
bodyBytes = subject.bytes()
contentType = subject.contentType
}
is OutgoingContent.NoContent -> {
bodyBytes = ByteArray(0)
contentType = subject.contentType
}
is OutgoingContent.ReadChannelContent -> {
// Nicht trivial ohne Consumption; überspringen
}
is OutgoingContent.WriteChannelContent -> {
// Nicht trivial; überspringen
}
is TextContent -> {
bodyBytes = subject.text.toByteArray(Charsets.UTF_8)
contentType = subject.contentType
}
}
if (bodyBytes != null) {
val entry = CacheEntry(
status = status,
contentType = contentType,
body = bodyBytes,
storedAtMillis = System.currentTimeMillis()
)
val cache = application.attributes[CacheAttr]
cache[key] = entry
// Wenn dieser Call der Leader war, vervollständige alle wartenden Requests
if (call.attributes.getOrNull(LeaderFlagAttr) == true) {
val inflight = application.attributes[InflightAttr]
val deferred = inflight.remove(key)
deferred?.complete(entry)
}
} else {
// Kein cachebarer Body in-flight ggf. bereinigen, damit Follower nicht ewig warten
if (call.attributes.getOrNull(LeaderFlagAttr) == true) {
val inflight = application.attributes[InflightAttr]
inflight.remove(key)?.completeExceptionally(IllegalStateException("Idempotency: no cacheable body"))
}
}
}
}
/**
* Ermöglicht das Leeren des per-Application-Caches (z.B. für Tests).
*/
fun clear(application: Application) {
if (application.attributes.contains(CacheAttr)) {
application.attributes[CacheAttr].clear()
}
if (application.attributes.contains(InflightAttr)) {
val inflight = application.attributes[InflightAttr]
// Alle offenen Deferreds abbrechen, um Leaks in Tests zu verhindern
inflight.values.forEach { d -> if (!d.isCompleted) d.completeExceptionally(CancellationException("Idempotency: cleared")) }
inflight.clear()
}
}
}

View File

@ -0,0 +1,57 @@
package at.mocode.masterdata.api
import at.mocode.masterdata.api.plugins.IdempotencyPlugin
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
class IdempotencyPluginTest {
@Test
fun `second POST with same Idempotency-Key returns cached response and skips handler`() = testApplication {
val counter = AtomicInteger(0)
application {
// Install plugin directly for focused unit test
this@application.install(ContentNegotiation) { json() }
IdempotencyPlugin.install(this@application)
routing {
post("/echo") {
counter.incrementAndGet()
// Simuliere erfolgreiche Erstellung mit 201 und JSON-Body (als String, um Serializer-Probleme zu vermeiden)
val json = """{"ok":true,"count":${counter.get()}}"""
call.respondText(text = json, contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
}
}
}
val key = "test-key-123"
val payload = """{"name":"Austria"}"""
val r1 = client.post("/echo") {
header("Idempotency-Key", key)
contentType(ContentType.Application.Json)
setBody(payload)
}
val r2 = client.post("/echo") {
header("Idempotency-Key", key)
contentType(ContentType.Application.Json)
setBody(payload)
}
assertThat(r1.status).isEqualTo(HttpStatusCode.Created)
assertThat(r2.status).isEqualTo(HttpStatusCode.Created)
assertThat(r2.bodyAsText()).isEqualTo(r1.bodyAsText())
// Sicherstellen, dass der Handler nur einmal ausgeführt wurde
assertThat(counter.get()).isEqualTo(1)
}
}

View File

@ -121,8 +121,8 @@ class CreateAltersklasseUseCase(
updatedAt = now
)
// Save to repository
val savedAltersklasse = altersklasseRepository.save(altersklasse)
// Upsert anhand des natürlichen Schlüssels (altersklasseCode)
val savedAltersklasse = altersklasseRepository.upsertByCode(altersklasse)
return CreateAltersklasseResponse(
altersklasse = savedAltersklasse,
success = true

View File

@ -117,8 +117,8 @@ class CreateBundeslandUseCase(
updatedAt = now
)
// Save to repository
val savedBundesland = bundeslandRepository.save(bundesland)
// Upsert anhand des natürlichen Schlüssels (landId + kuerzel)
val savedBundesland = bundeslandRepository.upsertByLandIdAndKuerzel(bundesland)
return CreateBundeslandResponse(
bundesland = savedBundesland,
success = true

View File

@ -123,8 +123,8 @@ class CreateCountryUseCase(
updatedAt = now
)
// Save to repository
val savedCountry = landRepository.save(country)
// Upsert anhand des natürlichen Schlüssels (ISO Alpha-3)
val savedCountry = landRepository.upsertByIsoAlpha3(country)
return CreateCountryResponse(
country = savedCountry,
success = true

View File

@ -115,8 +115,8 @@ class CreatePlatzUseCase(
updatedAt = now
)
// Save to repository
val savedPlatz = platzRepository.save(platz)
// Upsert anhand des natürlichen Schlüssels (turnierId + name)
val savedPlatz = platzRepository.upsertByTurnierIdAndName(platz)
return CreatePlatzResponse(
platz = savedPlatz,
success = true

View File

@ -108,6 +108,13 @@ interface AltersklasseRepository {
*/
suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition
/**
* Upsert basierend auf dem natürlichen Schlüssel Altersklassen-Code.
* Existiert bereits ein Datensatz mit gleichem Code, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByCode(altersklasse: AltersklasseDefinition): AltersklasseDefinition
/**
* Deletes an age class by ID.
*

View File

@ -75,6 +75,13 @@ interface BundeslandRepository {
*/
suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition
/**
* Upsert basierend auf dem natürlichen Schlüssel (landId + kuerzel).
* Existiert bereits ein Datensatz mit gleicher Kombination, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition
/**
* Deletes a federal state by ID.
*

View File

@ -77,6 +77,13 @@ interface LandRepository {
*/
suspend fun save(land: LandDefinition): LandDefinition
/**
* Upsert basierend auf dem natürlichen Schlüssel ISO Alpha-3.
* Existiert bereits ein Datensatz mit gleichem ISO Alpha-3 Code, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByIsoAlpha3(land: LandDefinition): LandDefinition
/**
* Deletes a country by ID.
*

View File

@ -103,6 +103,13 @@ interface PlatzRepository {
*/
suspend fun save(platz: Platz): Platz
/**
* Upsert basierend auf dem natürlichen Schlüssel (turnierId + name).
* Existiert bereits ein Datensatz mit gleicher Kombination, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByTurnierIdAndName(platz: Platz): Platz
/**
* Deletes a venue by ID.
*

View File

@ -217,4 +217,58 @@ class AltersklasseRepositoryImpl : AltersklasseRepository {
ageOk && geschlechtOk
}.singleOrNull() ?: false
}
override suspend fun upsertByCode(altersklasse: AltersklasseDefinition): AltersklasseDefinition = DatabaseFactory.dbQuery {
// 1) Update anhand des natürlichen Schlüssels (altersklasse_code)
val updated = AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
it[bezeichnung] = altersklasse.bezeichnung
it[minAlter] = altersklasse.minAlter
it[maxAlter] = altersklasse.maxAlter
it[stichtagRegelText] = altersklasse.stichtagRegelText
it[sparteFilter] = altersklasse.sparteFilter?.name
it[geschlechtFilter] = altersklasse.geschlechtFilter
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
it[istAktiv] = altersklasse.istAktiv
it[updatedAt] = altersklasse.updatedAt
}
if (updated > 0) {
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }
.map(::rowToAltersklasseDefinition)
.single()
} else {
// 2) Insert versuchen
try {
AltersklasseTable.insert {
it[id] = altersklasse.altersklasseId
it[altersklasseCode] = altersklasse.altersklasseCode
it[bezeichnung] = altersklasse.bezeichnung
it[minAlter] = altersklasse.minAlter
it[maxAlter] = altersklasse.maxAlter
it[stichtagRegelText] = altersklasse.stichtagRegelText
it[sparteFilter] = altersklasse.sparteFilter?.name
it[geschlechtFilter] = altersklasse.geschlechtFilter
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
it[istAktiv] = altersklasse.istAktiv
it[createdAt] = altersklasse.createdAt
it[updatedAt] = altersklasse.updatedAt
}
} catch (e: Exception) {
// Race-Fallback bei Unique-Constraint
AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
it[bezeichnung] = altersklasse.bezeichnung
it[minAlter] = altersklasse.minAlter
it[maxAlter] = altersklasse.maxAlter
it[stichtagRegelText] = altersklasse.stichtagRegelText
it[sparteFilter] = altersklasse.sparteFilter?.name
it[geschlechtFilter] = altersklasse.geschlechtFilter
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
it[istAktiv] = altersklasse.istAktiv
it[updatedAt] = altersklasse.updatedAt
}
}
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }
.map(::rowToAltersklasseDefinition)
.single()
}
}
}

View File

@ -133,4 +133,74 @@ class BundeslandRepositoryImpl : BundeslandRepository {
BundeslandTable.selectAll().where { (BundeslandTable.landId eq landId) and (BundeslandTable.istAktiv eq true) }
.count()
}
override suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition = DatabaseFactory.dbQuery {
// 1) Update anhand des natürlichen Schlüssels (landId + kuerzel)
val updated = if (bundesland.kuerzel == null) {
// Ohne Kuerzel ist der natürliche Schlüssel nicht definiert → versuche Update via (landId + name) als Fallback nicht, bleib bei none
0
} else {
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
it[landId] = bundesland.landId
it[oepsCode] = bundesland.oepsCode
it[iso3166_2_Code] = bundesland.iso3166_2_Code
it[name] = bundesland.name
it[kuerzel] = bundesland.kuerzel
it[wappenUrl] = bundesland.wappenUrl
it[istAktiv] = bundesland.istAktiv
it[sortierReihenfolge] = bundesland.sortierReihenfolge
it[updatedAt] = bundesland.updatedAt
}
}
if (updated > 0) {
BundeslandTable.selectAll()
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
.map(::rowToBundeslandDefinition)
.single()
} else {
// 2) Insert versuchen
try {
BundeslandTable.insert {
it[id] = bundesland.bundeslandId
it[landId] = bundesland.landId
it[oepsCode] = bundesland.oepsCode
it[iso3166_2_Code] = bundesland.iso3166_2_Code
it[name] = bundesland.name
it[kuerzel] = bundesland.kuerzel
it[wappenUrl] = bundesland.wappenUrl
it[istAktiv] = bundesland.istAktiv
it[sortierReihenfolge] = bundesland.sortierReihenfolge
it[createdAt] = bundesland.createdAt
it[updatedAt] = bundesland.updatedAt
}
} catch (e: Exception) {
// Race-Condition → erneut Update
if (bundesland.kuerzel != null) {
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
it[landId] = bundesland.landId
it[oepsCode] = bundesland.oepsCode
it[iso3166_2_Code] = bundesland.iso3166_2_Code
it[name] = bundesland.name
it[kuerzel] = bundesland.kuerzel
it[wappenUrl] = bundesland.wappenUrl
it[istAktiv] = bundesland.istAktiv
it[sortierReihenfolge] = bundesland.sortierReihenfolge
it[updatedAt] = bundesland.updatedAt
}
}
}
// Rückgabe des aktuellen Datensatzes: Falls Kuerzel null, greife auf ID zurück
if (bundesland.kuerzel != null) {
BundeslandTable.selectAll()
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
.map(::rowToBundeslandDefinition)
.single()
} else {
BundeslandTable.selectAll().where { BundeslandTable.id eq bundesland.bundeslandId }
.map(::rowToBundeslandDefinition)
.single()
}
}
}
}

View File

@ -4,6 +4,8 @@ package at.mocode.masterdata.infrastructure.persistence
import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository
import at.mocode.core.utils.database.DatabaseFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.or
@ -13,6 +15,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
/**
@ -20,6 +23,11 @@ import kotlin.uuid.Uuid
*/
class LandRepositoryImpl : LandRepository {
companion object {
// PerKey Locks, um konkurrierende Upserts auf denselben natürlichen Schlüssel zu serialisieren
private val upsertLocks = ConcurrentHashMap<String, Any>()
}
private fun rowToLandDefinition(row: ResultRow): LandDefinition {
return LandDefinition(
landId = row[LandTable.id],
@ -138,4 +146,70 @@ class LandRepositoryImpl : LandRepository {
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.istAktiv eq true }.count()
}
}
override suspend fun upsertByIsoAlpha3(land: LandDefinition): LandDefinition {
val naturalKey = land.isoAlpha3Code.uppercase()
val lock = upsertLocks.computeIfAbsent(naturalKey) { Any() }
// Blockierend ausführen, damit kein suspend innerhalb synchronized stattfindet
return withContext(Dispatchers.IO) {
synchronized(lock) {
DatabaseFactory.transaction {
// 1) Versuche Update anhand natürlichem Schlüssel (ISO Alpha-3)
val updated = LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
it[isoAlpha3Code] = naturalKey
it[isoNumerischerCode] = land.isoNumerischerCode
it[nameDeutsch] = land.nameDeutsch
it[nameEnglisch] = land.nameEnglisch
it[wappenUrl] = land.wappenUrl
it[istEuMitglied] = land.istEuMitglied
it[istEwrMitglied] = land.istEwrMitglied
it[istAktiv] = land.istAktiv
it[sortierReihenfolge] = land.sortierReihenfolge
it[updatedAt] = land.updatedAt
}
if (updated == 0) {
// 2) Kein Update → Insert versuchen
try {
LandTable.insert {
it[id] = land.landId
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
it[isoAlpha3Code] = naturalKey
it[isoNumerischerCode] = land.isoNumerischerCode
it[nameDeutsch] = land.nameDeutsch
it[nameEnglisch] = land.nameEnglisch
it[wappenUrl] = land.wappenUrl
it[istEuMitglied] = land.istEuMitglied
it[istEwrMitglied] = land.istEwrMitglied
it[istAktiv] = land.istAktiv
it[sortierReihenfolge] = land.sortierReihenfolge
it[createdAt] = land.createdAt
it[updatedAt] = land.updatedAt
}
} catch (e: Exception) {
// Race-Condition (Unique-Constraint gegriffen) → erneut mit Update abrunden
LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
it[isoAlpha3Code] = naturalKey
it[isoNumerischerCode] = land.isoNumerischerCode
it[nameDeutsch] = land.nameDeutsch
it[nameEnglisch] = land.nameEnglisch
it[wappenUrl] = land.wappenUrl
it[istEuMitglied] = land.istEuMitglied
it[istEwrMitglied] = land.istEwrMitglied
it[istAktiv] = land.istAktiv
it[sortierReihenfolge] = land.sortierReihenfolge
it[updatedAt] = land.updatedAt
}
}
}
// Rückgabe des aktuellen Datensatzes
LandTable.selectAll().where { LandTable.isoAlpha3Code eq naturalKey }
.map(::rowToLandDefinition)
.single()
}
}
}
}
}

View File

@ -22,83 +22,75 @@ import kotlin.uuid.Uuid
*/
class PlatzRepositoryImpl : PlatzRepository {
private fun rowToPlatz(row: ResultRow): Platz {
return Platz(
id = row[PlatzTable.id],
turnierId = row[PlatzTable.turnierId],
name = row[PlatzTable.name],
dimension = row[PlatzTable.dimension],
boden = row[PlatzTable.boden],
typ = PlatzTypE.valueOf(row[PlatzTable.typ]),
istAktiv = row[PlatzTable.istAktiv],
sortierReihenfolge = row[PlatzTable.sortierReihenfolge],
createdAt = row[PlatzTable.createdAt],
updatedAt = row[PlatzTable.updatedAt]
)
}
private fun rowToPlatz(row: ResultRow): Platz {
return Platz(
id = row[PlatzTable.id],
turnierId = row[PlatzTable.turnierId],
name = row[PlatzTable.name],
dimension = row[PlatzTable.dimension],
boden = row[PlatzTable.boden],
typ = PlatzTypE.valueOf(row[PlatzTable.typ]),
istAktiv = row[PlatzTable.istAktiv],
sortierReihenfolge = row[PlatzTable.sortierReihenfolge],
createdAt = row[PlatzTable.createdAt],
updatedAt = row[PlatzTable.updatedAt]
)
}
override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { PlatzTable.id eq id }
.map(::rowToPlatz)
.singleOrNull()
}
override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { PlatzTable.id eq id }
.map(::rowToPlatz)
.singleOrNull()
}
override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId }
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
}
override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery {
val pattern = "%$searchTerm%"
val query = PlatzTable.selectAll().where { PlatzTable.name like pattern }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
query.limit(limit).map(::rowToPlatz)
}
override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery {
val pattern = "%$searchTerm%"
val query = PlatzTable.selectAll().where { PlatzTable.name like pattern }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
query.limit(limit).map(::rowToPlatz)
}
override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.map(::rowToPlatz)
}
override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.map(::rowToPlatz)
}
override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.map(::rowToPlatz)
}
override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.map(::rowToPlatz)
}
override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.map(::rowToPlatz)
}
override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.map(::rowToPlatz)
}
override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true }
if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true }
if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
}
override suspend fun findSuitableForDiscipline(
requiredType: PlatzTypE,
@ -110,23 +102,58 @@ class PlatzRepositoryImpl : PlatzRepository {
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
query.andWhere { PlatzTable.istAktiv eq true }
query.map(::rowToPlatz)
}
}
override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery {
val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any()
if (exists) {
PlatzTable.update({ PlatzTable.id eq platz.id }) {
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[updatedAt] = platz.updatedAt
}
platz
} else {
override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery {
val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any()
if (exists) {
PlatzTable.update({ PlatzTable.id eq platz.id }) {
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[updatedAt] = platz.updatedAt
}
platz
} else {
PlatzTable.insert {
it[id] = platz.id
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[createdAt] = platz.createdAt
it[updatedAt] = platz.updatedAt
}
platz
}
}
override suspend fun upsertByTurnierIdAndName(platz: Platz): Platz = DatabaseFactory.dbQuery {
// 1) Versuch: Update anhand (turnierId + name)
val updated = PlatzTable.update({ (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }) {
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[updatedAt] = platz.updatedAt
}
if (updated > 0) {
PlatzTable.selectAll().where { (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }
.map(::rowToPlatz)
.single()
} else {
// 2) Kein Update → Insert versuchen
try {
PlatzTable.insert {
it[id] = platz.id
it[turnierId] = platz.turnierId
@ -138,34 +165,47 @@ class PlatzRepositoryImpl : PlatzRepository {
it[sortierReihenfolge] = platz.sortierReihenfolge
it[createdAt] = platz.createdAt
it[updatedAt] = platz.updatedAt
}
platz
}
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.deleteWhere { PlatzTable.id eq id } > 0
}
override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any()
}
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count()
}
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
} catch (e: Exception) {
// Race-Condition (Unique-Constraint gegriffen) → erneut via Update abrunden
PlatzTable.update({ (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }) {
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[updatedAt] = platz.updatedAt
}
query.count()
}
PlatzTable.selectAll().where { (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }
.map(::rowToPlatz)
.single()
}
}
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery {
// Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück.
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }
.map(::rowToPlatz)
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.deleteWhere { PlatzTable.id eq id } > 0
}
override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any()
}
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count()
}
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) }
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.count()
}
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery {
// Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück.
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }
.map(::rowToPlatz)
}
}

View File

@ -0,0 +1,118 @@
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.masterdata.domain.model.LandDefinition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LandRepositoryImplTest {
private lateinit var repo: LandRepositoryImpl
@BeforeAll
fun initDb() {
// In-Memory H2 DB
Database.connect("jdbc:h2:mem:landrepo;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
transaction {
SchemaUtils.create(LandTable)
}
repo = LandRepositoryImpl()
}
@Test
fun `upsertByIsoAlpha3 performs update then insert semantics`() {
runBlocking {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val id = Uuid.random()
val base = LandDefinition(
landId = id,
isoAlpha2Code = "ZZ",
isoAlpha3Code = "ZZY",
isoNumerischerCode = null,
nameDeutsch = "Testland",
nameEnglisch = null,
wappenUrl = null,
istEuMitglied = null,
istEwrMitglied = null,
istAktiv = true,
sortierReihenfolge = 1,
createdAt = now,
updatedAt = now
)
// 1) Insert path
val saved1 = repo.upsertByIsoAlpha3(base)
assertThat(saved1.isoAlpha3Code).isEqualTo("ZZY")
// 2) Update path (gleicher natürlicher Schlüssel, geänderte Werte)
val updated = base.copy(nameDeutsch = "Testland Neu", sortierReihenfolge = 2, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
val saved2 = repo.upsertByIsoAlpha3(updated)
assertThat(saved2.nameDeutsch).isEqualTo("Testland Neu")
assertThat(saved2.sortierReihenfolge).isEqualTo(2)
// Stelle sicher, dass nur ein Datensatz existiert
val count = transaction { LandTable.selectAll().count() }
assertThat(count).isEqualTo(1)
}
}
@Test
fun `upsertByIsoAlpha3 handles race with two concurrent upserts on same natural key`() {
runBlocking {
// Sicherstellen, dass der Test mit leerer Tabelle startet
transaction {
SchemaUtils.drop(LandTable)
SchemaUtils.create(LandTable)
}
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val base1 = LandDefinition(
landId = Uuid.random(),
isoAlpha2Code = "ZX",
isoAlpha3Code = "ZXY",
isoNumerischerCode = null,
nameDeutsch = "RaceLand",
nameEnglisch = null,
wappenUrl = null,
istEuMitglied = null,
istEwrMitglied = null,
istAktiv = true,
sortierReihenfolge = 5,
createdAt = now,
updatedAt = now
)
val base2 = base1.copy(landId = Uuid.random(), nameDeutsch = "RaceLand Zwei", sortierReihenfolge = 6, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
// Feuere zwei parallele Upserts
val d1 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base1) }
val d2 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base2) }
val r1 = d1.await()
val r2 = d2.await()
// Beide Aufrufe dürfen nicht fehlschlagen und müssen denselben Datensatz (natürlicher Schlüssel) repräsentieren
assertThat(r1.isoAlpha3Code).isEqualTo("ZXY")
assertThat(r2.isoAlpha3Code).isEqualTo("ZXY")
// Es soll genau 1 Datensatz existieren
val count = transaction { LandTable.selectAll().count() }
assertThat(count).isEqualTo(1)
// Finaler Zustand entspricht einem der beiden Upserts (update hat "gewonnen"); Name ist entweder "RaceLand" oder "RaceLand Zwei"
val finalRow = transaction { LandTable.selectAll().first() }
val finalName = finalRow[LandTable.nameDeutsch]
assertThat(finalName == "RaceLand" || finalName == "RaceLand Zwei").isTrue()
}
}
}

View File

@ -33,6 +33,12 @@ dependencies {
implementation(libs.spring.boot.starter.actuator)
//implementation(libs.springdoc.openapi.starter.webmvc.ui)
// Ktor Server (für SCS: eigener kleiner HTTP-Server pro Kontext)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serialization.kotlinx.json)
// Datenbank-Abhängigkeiten
implementation(libs.exposed.core)
implementation(libs.exposed.dao)

View File

@ -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
}
}

View File

@ -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")
}
}