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

This commit is contained in:
2026-03-29 00:05:14 +01:00
parent eedce74a85
commit 8c2a82403e
21 changed files with 919 additions and 124 deletions
@@ -0,0 +1,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")
}
}