From 8c2a82403e4e5888d67f0609626cd4bc36948a13 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sun, 29 Mar 2026 00:05:14 +0100 Subject: [PATCH] Introduce Ktor-based HTTP server for Masterdata context, implement upsert logic for Altersklasse, Bundesland, and Land repositories, enhance IdempotencyPlugin, and add integration tests. --- .../events/events-api/build.gradle.kts | 3 +- .../masterdata-api/build.gradle.kts | 3 +- .../masterdata/api/MasterdataApiModule.kt | 33 +++ .../api/plugins/IdempotencyPlugin.kt | 162 ++++++++++- .../masterdata/api/IdempotencyPluginTest.kt | 57 ++++ .../usecase/CreateAltersklasseUseCase.kt | 4 +- .../usecase/CreateBundeslandUseCase.kt | 4 +- .../usecase/CreateCountryUseCase.kt | 4 +- .../application/usecase/CreatePlatzUseCase.kt | 4 +- .../repository/AltersklasseRepository.kt | 7 + .../domain/repository/BundeslandRepository.kt | 7 + .../domain/repository/LandRepository.kt | 7 + .../domain/repository/PlatzRepository.kt | 7 + .../persistence/AltersklasseRepositoryImpl.kt | 54 ++++ .../persistence/BundeslandRepositoryImpl.kt | 70 +++++ .../persistence/LandRepositoryImpl.kt | 76 ++++- .../persistence/PlatzRepositoryImpl.kt | 260 ++++++++++-------- .../persistence/LandRepositoryImplTest.kt | 118 ++++++++ .../masterdata-service/build.gradle.kts | 6 + .../service/config/KtorServerConfiguration.kt | 50 ++++ .../api/IdempotencyApiIntegrationTest.kt | 107 +++++++ 21 files changed, 919 insertions(+), 124 deletions(-) create mode 100644 backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt create mode 100644 backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/IdempotencyPluginTest.kt create mode 100644 backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt create mode 100644 backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt create mode 100644 backend/services/masterdata/masterdata-service/src/test/kotlin/at/mocode/masterdata/service/api/IdempotencyApiIntegrationTest.kt diff --git a/backend/services/events/events-api/build.gradle.kts b/backend/services/events/events-api/build.gradle.kts index 80dda1af..e5ec8836 100644 --- a/backend/services/events/events-api/build.gradle.kts +++ b/backend/services/events/events-api/build.gradle.kts @@ -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) } diff --git a/backend/services/masterdata/masterdata-api/build.gradle.kts b/backend/services/masterdata/masterdata-api/build.gradle.kts index 1b9db7ba..567e3a6f 100644 --- a/backend/services/masterdata/masterdata-api/build.gradle.kts +++ b/backend/services/masterdata/masterdata-api/build.gradle.kts @@ -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) } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt new file mode 100644 index 00000000..b6c161d3 --- /dev/null +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt @@ -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() } + } +} diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/plugins/IdempotencyPlugin.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/plugins/IdempotencyPlugin.kt index 578ab8f0..2c6b36f6 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/plugins/IdempotencyPlugin.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/plugins/IdempotencyPlugin.kt @@ -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 = AttributeKey("IdempotencyKey") + private val CacheAttr: AttributeKey> = AttributeKey("IdempotencyCache") + private val InflightAttr: AttributeKey>> = AttributeKey("IdempotencyInflight") + private val LeaderFlagAttr: AttributeKey = 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(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() + } + } } diff --git a/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/IdempotencyPluginTest.kt b/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/IdempotencyPluginTest.kt new file mode 100644 index 00000000..1fa1b52b --- /dev/null +++ b/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/IdempotencyPluginTest.kt @@ -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) + } +} diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt index 6b90d5df..1a797a6d 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt @@ -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 diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt index 43c70d14..b679f2bd 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt @@ -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 diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt index e6e44efc..36770e4a 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt @@ -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 diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt index 895d91fe..393ca9d7 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt @@ -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 diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt index b7dcd1fc..049a2995 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt @@ -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. * diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt index fbb65c84..b8005cf2 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt @@ -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. * diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt index bd20b8f8..120d98ff 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt @@ -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. * diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt index e995d559..ed21ca2f 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt @@ -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. * diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt index 4c1bcffe..b6f61ace 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt @@ -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() + } + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt index 0b588ac0..06e5c686 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt @@ -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() + } + } + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt index cd118248..376270d7 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt @@ -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 { + // Per‑Key Locks, um konkurrierende Upserts auf denselben natürlichen Schlüssel zu serialisieren + private val upsertLocks = ConcurrentHashMap() + } + 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() + } + } + } + } + } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt index fdb46b85..a37e7746 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt @@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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) + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt b/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt new file mode 100644 index 00000000..c1f9c5d5 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt @@ -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() + } + } +} diff --git a/backend/services/masterdata/masterdata-service/build.gradle.kts b/backend/services/masterdata/masterdata-service/build.gradle.kts index 786cf744..e5951767 100644 --- a/backend/services/masterdata/masterdata-service/build.gradle.kts +++ b/backend/services/masterdata/masterdata-service/build.gradle.kts @@ -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) diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt new file mode 100644 index 00000000..27b9c954 --- /dev/null +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt @@ -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 { + 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 + } +} diff --git a/backend/services/masterdata/masterdata-service/src/test/kotlin/at/mocode/masterdata/service/api/IdempotencyApiIntegrationTest.kt b/backend/services/masterdata/masterdata-service/src/test/kotlin/at/mocode/masterdata/service/api/IdempotencyApiIntegrationTest.kt new file mode 100644 index 00000000..5e750f5a --- /dev/null +++ b/backend/services/masterdata/masterdata-service/src/test/kotlin/at/mocode/masterdata/service/api/IdempotencyApiIntegrationTest.kt @@ -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 { + 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") + } +}