refactor(build, dependencies): introduce SQLDelight WebWorker setup, update Spring Boot dependencies, and align versions
Configured `sqlite.worker.js` for OPFS-backed SQLite WASM operations in the frontend build pipeline. Added new Spring Boot dependency bundles including secure service configurations. Integrated updated database utilities with enhanced error handling. Removed outdated circuit breaker tests and replaced them with modern unit and integration test setups.
This commit is contained in:
parent
2f8529156a
commit
32e43b8fb0
|
|
@ -22,6 +22,18 @@ dependencies {
|
||||||
implementation(platform(projects.platform.platformBom))
|
implementation(platform(projects.platform.platformBom))
|
||||||
// Stellt gemeinsame Abhängigkeiten bereit.
|
// Stellt gemeinsame Abhängigkeiten bereit.
|
||||||
implementation(projects.platform.platformDependencies)
|
implementation(projects.platform.platformDependencies)
|
||||||
|
|
||||||
|
// Spring Boot / Spring Framework APIs used directly in this module
|
||||||
|
// (e.g. ConditionalOnMissingBean)
|
||||||
|
implementation("org.springframework.boot:spring-boot-autoconfigure")
|
||||||
|
implementation("org.springframework.boot:spring-boot")
|
||||||
|
|
||||||
|
// Spring Kafka (ReactiveKafkaProducerTemplate, etc.)
|
||||||
|
implementation(libs.spring.kafka)
|
||||||
|
|
||||||
|
// Jakarta annotations used by Spring / configuration classes
|
||||||
|
implementation(libs.jakarta.annotation.api)
|
||||||
|
|
||||||
// Baut auf der zentralen Kafka-Konfiguration auf und erbt deren Abhängigkeiten.
|
// Baut auf der zentralen Kafka-Konfiguration auf und erbt deren Abhängigkeiten.
|
||||||
implementation(projects.backend.infrastructure.messaging.messagingConfig)
|
implementation(projects.backend.infrastructure.messaging.messagingConfig)
|
||||||
// Fügt die reaktive Kafka-Implementierung hinzu (Project Reactor).
|
// Fügt die reaktive Kafka-Implementierung hinzu (Project Reactor).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinJvm)
|
alias(libs.plugins.kotlinJvm)
|
||||||
alias(libs.plugins.kotlinJpa)
|
alias(libs.plugins.kotlinJpa)
|
||||||
|
alias(libs.plugins.spring.boot) // Spring Boot Plugin hinzufügen
|
||||||
|
alias(libs.plugins.spring.dependencyManagement) // Dependency Management für Spring
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library module: do not create an executable Spring Boot jar here.
|
||||||
|
tasks.bootJar {
|
||||||
|
enabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure a regular jar is produced instead.
|
||||||
|
tasks.jar {
|
||||||
|
enabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
@ -8,6 +20,9 @@ dependencies {
|
||||||
implementation(projects.core.coreDomain)
|
implementation(projects.core.coreDomain)
|
||||||
implementation(projects.platform.platformDependencies)
|
implementation(projects.platform.platformDependencies)
|
||||||
|
|
||||||
|
// Spring Boot Database dependencies
|
||||||
|
implementation(libs.bundles.database.complete)
|
||||||
|
|
||||||
// Exposed
|
// Exposed
|
||||||
implementation(libs.exposed.core)
|
implementation(libs.exposed.core)
|
||||||
implementation(libs.exposed.jdbc)
|
implementation(libs.exposed.jdbc)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,16 @@ package at.mocode.backend.infrastructure.persistence
|
||||||
import at.mocode.core.domain.model.ErrorCodes
|
import at.mocode.core.domain.model.ErrorCodes
|
||||||
import at.mocode.core.domain.model.ErrorDto
|
import at.mocode.core.domain.model.ErrorDto
|
||||||
import at.mocode.core.domain.model.PagedResponse
|
import at.mocode.core.domain.model.PagedResponse
|
||||||
import org.jetbrains.exposed.sql.*
|
import at.mocode.core.utils.Result
|
||||||
import org.jetbrains.exposed.sql.statements.BatchInsertStatement
|
import org.jetbrains.exposed.v1.core.ResultRow
|
||||||
import org.jetbrains.exposed.sql.transactions.transaction
|
import org.jetbrains.exposed.v1.core.Table
|
||||||
|
import org.jetbrains.exposed.v1.core.Column
|
||||||
|
import org.jetbrains.exposed.v1.core.statements.BatchInsertStatement
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.Database
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.JdbcTransaction
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.Query
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.batchInsert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import java.sql.SQLException
|
import java.sql.SQLException
|
||||||
import java.sql.SQLTimeoutException
|
import java.sql.SQLTimeoutException
|
||||||
|
|
||||||
|
|
@ -18,7 +25,7 @@ import java.sql.SQLTimeoutException
|
||||||
|
|
||||||
inline fun <T> transactionResult(
|
inline fun <T> transactionResult(
|
||||||
database: Database? = null,
|
database: Database? = null,
|
||||||
crossinline block: Transaction.() -> T
|
crossinline block: JdbcTransaction.() -> T
|
||||||
): Result<T> {
|
): Result<T> {
|
||||||
return try {
|
return try {
|
||||||
val result = transaction(database) { block() }
|
val result = transaction(database) { block() }
|
||||||
|
|
@ -60,12 +67,12 @@ inline fun <T> transactionResult(
|
||||||
|
|
||||||
inline fun <T> writeTransaction(
|
inline fun <T> writeTransaction(
|
||||||
database: Database? = null,
|
database: Database? = null,
|
||||||
crossinline block: Transaction.() -> T
|
crossinline block: JdbcTransaction.() -> T
|
||||||
): Result<T> = transactionResult(database, block)
|
): Result<T> = transactionResult(database, block)
|
||||||
|
|
||||||
inline fun <T> readTransaction(
|
inline fun <T> readTransaction(
|
||||||
database: Database? = null,
|
database: Database? = null,
|
||||||
crossinline block: Transaction.() -> T
|
crossinline block: JdbcTransaction.() -> T
|
||||||
): Result<T> = transactionResult(database, block)
|
): Result<T> = transactionResult(database, block)
|
||||||
|
|
||||||
fun Query.paginate(page: Int, size: Int): Query {
|
fun Query.paginate(page: Int, size: Int): Query {
|
||||||
|
|
@ -123,18 +130,17 @@ fun <T> Query.toPagedResponse(
|
||||||
object DatabaseUtils {
|
object DatabaseUtils {
|
||||||
|
|
||||||
fun tableExists(tableName: String, database: Database? = null): Boolean {
|
fun tableExists(tableName: String, database: Database? = null): Boolean {
|
||||||
return try {
|
return transactionResult(database) {
|
||||||
transaction(database) {
|
// Postgres-spezifischer, robuster Ansatz über to_regclass
|
||||||
// Postgres-spezifischer, robuster Ansatz über to_regclass
|
val valid = tableName.trim()
|
||||||
val valid = tableName.trim()
|
if (!valid.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) return@transactionResult false
|
||||||
if (!valid.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) return@transaction false
|
exec("SELECT to_regclass('$valid')") { rs ->
|
||||||
exec("SELECT to_regclass('$valid')") { rs ->
|
if (rs.next()) rs.getString(1) else null
|
||||||
if (rs.next()) rs.getString(1) else null
|
} != null
|
||||||
} != null
|
}.fold(
|
||||||
}
|
onSuccess = { it },
|
||||||
} catch (e: Exception) {
|
onFailure = { false }
|
||||||
false
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmName("createIndexIfNotExistsArray")
|
@JvmName("createIndexIfNotExistsArray")
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,18 @@ dependencies {
|
||||||
implementation(projects.backend.services.entries.entriesApi)
|
implementation(projects.backend.services.entries.entriesApi)
|
||||||
implementation(projects.backend.infrastructure.monitoring.monitoringClient)
|
implementation(projects.backend.infrastructure.monitoring.monitoringClient)
|
||||||
|
|
||||||
implementation(libs.bundles.spring.boot.service.complete)
|
// Standard dependencies for a secure microservice (centralized bundle)
|
||||||
|
implementation(libs.bundles.spring.boot.secure.service)
|
||||||
|
// Common service extras
|
||||||
|
implementation(libs.spring.boot.starter.validation)
|
||||||
|
implementation(libs.spring.boot.starter.json)
|
||||||
implementation(libs.postgresql.driver)
|
implementation(libs.postgresql.driver)
|
||||||
implementation(libs.spring.boot.starter.web)
|
|
||||||
|
|
||||||
// KORREKTUR: Jackson Bundle aufgelöst, da Accessor fehlschlägt
|
// KORREKTUR: Jackson Bundle aufgelöst, da Accessor fehlschlägt
|
||||||
implementation(libs.jackson.module.kotlin)
|
implementation(libs.jackson.module.kotlin)
|
||||||
implementation(libs.jackson.datatype.jsr310)
|
implementation(libs.jackson.datatype.jsr310)
|
||||||
|
|
||||||
implementation(libs.kotlin.reflect)
|
implementation(libs.kotlin.reflect)
|
||||||
implementation(libs.spring.cloud.starter.consul.discovery)
|
|
||||||
implementation(libs.caffeine)
|
implementation(libs.caffeine)
|
||||||
implementation(libs.spring.web)
|
implementation(libs.spring.web)
|
||||||
|
|
||||||
|
|
@ -39,5 +41,4 @@ dependencies {
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
testImplementation(libs.bundles.testing.jvm)
|
testImplementation(libs.bundles.testing.jvm)
|
||||||
testImplementation(libs.spring.boot.starter.test)
|
testImplementation(libs.spring.boot.starter.test)
|
||||||
testImplementation(libs.spring.boot.starter.web)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,19 @@ kotlin {
|
||||||
dependencies {
|
dependencies {
|
||||||
// === Project Dependencies ===
|
// === Project Dependencies ===
|
||||||
implementation(projects.contracts.pingApi)
|
implementation(projects.contracts.pingApi)
|
||||||
|
|
||||||
|
// Our central BOM for consistent versions
|
||||||
|
implementation(platform(projects.platform.platformBom))
|
||||||
implementation(projects.platform.platformDependencies)
|
implementation(projects.platform.platformDependencies)
|
||||||
// NEU: Zugriff auf die verschobenen DatabaseUtils
|
// NEU: Zugriff auf die verschobenen DatabaseUtils
|
||||||
implementation(projects.backend.infrastructure.persistence)
|
implementation(projects.backend.infrastructure.persistence)
|
||||||
|
|
||||||
// === Spring Boot & Cloud ===
|
// === Spring Boot & Cloud ===
|
||||||
implementation(libs.bundles.spring.boot.service.complete)
|
// Standard dependencies for a secure microservice
|
||||||
// WICHTIG: Da wir JPA (blockierend) nutzen, brauchen wir Spring MVC (nicht WebFlux)
|
implementation(libs.bundles.spring.boot.secure.service)
|
||||||
implementation(libs.spring.boot.starter.web)
|
// Common service extras
|
||||||
|
implementation(libs.spring.boot.starter.validation)
|
||||||
// Service Discovery
|
implementation(libs.spring.boot.starter.json)
|
||||||
implementation(libs.spring.cloud.starter.consul.discovery)
|
|
||||||
|
|
||||||
// === Database & Persistence ===
|
// === Database & Persistence ===
|
||||||
implementation(libs.bundles.database.complete)
|
implementation(libs.bundles.database.complete)
|
||||||
|
|
@ -37,8 +39,13 @@ dependencies {
|
||||||
|
|
||||||
// === Testing ===
|
// === Testing ===
|
||||||
testImplementation(libs.bundles.testing.jvm)
|
testImplementation(libs.bundles.testing.jvm)
|
||||||
|
testImplementation(libs.spring.boot.starter.test)
|
||||||
|
testImplementation(libs.spring.security.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.test {
|
tasks.test {
|
||||||
useJUnitPlatform()
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
showStandardStreams = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,8 @@ import kotlin.random.Random
|
||||||
* Nutzt den Application Port (PingUseCase).
|
* Nutzt den Application Port (PingUseCase).
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@CrossOrigin(allowedHeaders = ["*"], allowCredentials = "true")
|
// Spring requires using `originPatterns` (not wildcard `origins`) when credentials are enabled.
|
||||||
|
@CrossOrigin(allowedHeaders = ["*"], allowCredentials = "true", originPatterns = ["*"])
|
||||||
class PingController(
|
class PingController(
|
||||||
private val pingUseCase: PingUseCase
|
private val pingUseCase: PingUseCase
|
||||||
) : PingApi {
|
) : PingApi {
|
||||||
|
|
|
||||||
|
|
@ -1,246 +1,59 @@
|
||||||
package at.mocode.ping.service
|
package at.mocode.ping.service
|
||||||
|
|
||||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker
|
import at.mocode.ping.application.PingUseCase
|
||||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
|
import at.mocode.ping.domain.Ping
|
||||||
|
import at.mocode.ping.infrastructure.web.PingController
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.slf4j.LoggerFactory
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.context.ActiveProfiles
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Integration tests for PingController
|
* Lightweight Spring MVC integration test (no full application context / datasource).
|
||||||
* Tests REST endpoints with circuit breaker functionality using TestRestTemplate
|
|
||||||
*/
|
*/
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
@WebMvcTest(
|
||||||
@ActiveProfiles("test")
|
controllers = [PingController::class],
|
||||||
|
excludeAutoConfiguration = [
|
||||||
|
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class,
|
||||||
|
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class
|
||||||
|
]
|
||||||
|
)
|
||||||
|
@Import(PingControllerIntegrationTest.TestConfig::class)
|
||||||
class PingControllerIntegrationTest {
|
class PingControllerIntegrationTest {
|
||||||
|
|
||||||
@LocalServerPort
|
|
||||||
private var port: Int = 0
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var restTemplate: TestRestTemplate
|
private lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
@Autowired
|
@TestConfiguration
|
||||||
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
|
class TestConfig {
|
||||||
|
@Bean
|
||||||
private val logger = LoggerFactory.getLogger(PingControllerIntegrationTest::class.java)
|
fun pingUseCase(): PingUseCase = mockk(relaxed = true)
|
||||||
|
|
||||||
private lateinit var circuitBreaker: CircuitBreaker
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
fun setUp() {
|
|
||||||
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
|
|
||||||
// Reset circuit breaker state before each test
|
|
||||||
circuitBreaker.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getUrl(endpoint: String) = "http://localhost:$port$endpoint"
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return basic ping response from standard endpoint`() {
|
|
||||||
// When
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/simple"), Map::class.java)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
assertThat(response.body!!["status"]).isEqualTo("pong")
|
|
||||||
|
|
||||||
logger.info("Standard ping endpoint response: {}", response.body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should return enhanced ping response when circuit breaker is closed`() {
|
fun `should start MVC slice and serve endpoints`() {
|
||||||
// Given
|
// Just verify the MVC wiring starts and endpoints respond 200
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
mockMvc.perform(get("/ping/health")).andExpect(status().isOk)
|
||||||
|
|
||||||
// When
|
// For endpoints that require the use-case, the relaxed mock is sufficient,
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
|
// but we still provide deterministic ping data.
|
||||||
|
val useCase = TestConfig().pingUseCase()
|
||||||
// Then
|
every { useCase.executePing(any()) } returns Ping(
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
message = "Simple Ping",
|
||||||
assertThat(response.body).isNotNull
|
timestamp = Instant.parse("2023-10-01T10:00:00Z")
|
||||||
|
|
||||||
val body = response.body!!
|
|
||||||
assertThat(body["status"]).isEqualTo("pong")
|
|
||||||
assertThat(body["service"]).isEqualTo("ping-service")
|
|
||||||
assertThat(body["circuitBreakerState"]).isEqualTo("CLOSED")
|
|
||||||
assertThat(body["timestamp"]).isNotNull()
|
|
||||||
|
|
||||||
logger.info("Enhanced ping response: {}", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return enhanced ping response without simulation`() {
|
|
||||||
// When
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/enhanced?simulate=false"), Map::class.java)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
|
|
||||||
val body = response.body!!
|
|
||||||
assertThat(body["status"]).isEqualTo("pong")
|
|
||||||
assertThat(body["service"]).isEqualTo("ping-service")
|
|
||||||
assertThat(body["circuitBreakerState"]).isEqualTo("CLOSED")
|
|
||||||
|
|
||||||
logger.info("Enhanced ping without simulation: {}", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should handle failure simulation in enhanced ping endpoint`() {
|
|
||||||
// Multiple calls to potentially trigger failures due to random simulation
|
|
||||||
repeat(3) { i ->
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/enhanced?simulate=true"), Map::class.java)
|
|
||||||
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
|
|
||||||
val body = response.body!!
|
|
||||||
logger.info("Attempt {}: Response status = {}, Circuit breaker state = {}",
|
|
||||||
i + 1, body["status"], circuitBreaker.state)
|
|
||||||
|
|
||||||
// Response should be either success or fallback
|
|
||||||
assertThat(body["status"]).isIn("pong", "fallback")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return health check response when circuit breaker is closed`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/health"), Map::class.java)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
|
|
||||||
val body = response.body!!
|
|
||||||
assertThat(body["status"]).isEqualTo("pong")
|
|
||||||
assertThat(body["service"]).isEqualTo("ping-service")
|
|
||||||
assertThat(body["healthy"]).isEqualTo(true)
|
|
||||||
assertThat(body["timestamp"]).isNotNull()
|
|
||||||
|
|
||||||
logger.info("Health check response: {}", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return fallback health check when circuit breaker is open`() {
|
|
||||||
// Given - manually open circuit breaker
|
|
||||||
circuitBreaker.transitionToOpenState()
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
|
|
||||||
|
|
||||||
// When
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/health"), Map::class.java)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
|
|
||||||
val body = response.body!!
|
|
||||||
assertThat(body["status"]).isEqualTo("down")
|
|
||||||
assertThat(body["service"]).isEqualTo("ping-service")
|
|
||||||
assertThat(body["healthy"]).isEqualTo(false)
|
|
||||||
|
|
||||||
logger.info("Fallback health check response: {}", body)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should handle multiple rapid requests correctly`() {
|
|
||||||
// Execute multiple rapid requests
|
|
||||||
val results = mutableListOf<Map<String, Any>>()
|
|
||||||
|
|
||||||
repeat(5) { i ->
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val body = response.body as Map<String, Any>
|
|
||||||
results.add(body)
|
|
||||||
|
|
||||||
logger.info("Rapid request {}: status = {}", i + 1, body["status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// All should be successful since we're not simulating failures
|
|
||||||
results.forEach { response ->
|
|
||||||
assertThat(response["status"]).isEqualTo("pong")
|
|
||||||
assertThat(response["service"]).isEqualTo("ping-service")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should maintain circuit breaker state across requests`() {
|
|
||||||
// Given - manually open circuit breaker
|
|
||||||
circuitBreaker.transitionToOpenState()
|
|
||||||
|
|
||||||
// When - make multiple requests
|
|
||||||
repeat(3) { i ->
|
|
||||||
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
|
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull
|
|
||||||
|
|
||||||
val body = response.body!!
|
|
||||||
|
|
||||||
// All should return fallback responses while circuit breaker is open
|
|
||||||
assertThat(body["status"]).isEqualTo("fallback")
|
|
||||||
assertThat(body["circuitBreakerState"]).isEqualTo("OPEN")
|
|
||||||
|
|
||||||
logger.info("Request {} with OPEN circuit breaker: {}", i + 1, body["status"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should test all existing endpoints return valid responses`() {
|
|
||||||
val endpoints = listOf(
|
|
||||||
"/ping/simple",
|
|
||||||
"/ping/enhanced",
|
|
||||||
"/ping/health"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
endpoints.forEach { endpoint ->
|
// Note: we don't assert the full JSON here (covered by PingControllerTest)
|
||||||
val response = restTemplate.getForEntity(getUrl(endpoint), Map::class.java)
|
val result = mockMvc.perform(get("/ping/simple")).andReturn()
|
||||||
|
assertThat(result.response.status).isEqualTo(200)
|
||||||
assertThat(response.statusCode).isEqualTo(HttpStatus.OK)
|
|
||||||
assertThat(response.body).isNotNull()
|
|
||||||
assertThat(response.body!!).isNotEmpty()
|
|
||||||
|
|
||||||
logger.info("Endpoint {} returned valid response: {}", endpoint, response.body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should track circuit breaker metrics after calls`() {
|
|
||||||
// Given
|
|
||||||
val initialMetrics = circuitBreaker.metrics
|
|
||||||
logger.info("Initial metrics - Calls: {}, Failures: {}",
|
|
||||||
initialMetrics.numberOfBufferedCalls, initialMetrics.numberOfFailedCalls)
|
|
||||||
|
|
||||||
// When - execute some calls
|
|
||||||
repeat(3) {
|
|
||||||
restTemplate.getForEntity(getUrl("/ping/enhanced"), Map::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val newMetrics = circuitBreaker.metrics
|
|
||||||
assertThat(newMetrics.numberOfBufferedCalls).isGreaterThanOrEqualTo(3)
|
|
||||||
|
|
||||||
logger.info("Updated metrics - Calls: {}, Failure rate: {}%, Successful: {}, Failed: {}",
|
|
||||||
newMetrics.numberOfBufferedCalls,
|
|
||||||
newMetrics.failureRate,
|
|
||||||
newMetrics.numberOfSuccessfulCalls,
|
|
||||||
newMetrics.numberOfFailedCalls)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,28 @@
|
||||||
package at.mocode.ping.service
|
package at.mocode.ping.service
|
||||||
|
|
||||||
import at.mocode.ping.api.EnhancedPingResponse
|
import at.mocode.ping.domain.Ping
|
||||||
import at.mocode.ping.api.HealthResponse
|
import at.mocode.ping.infrastructure.web.PingController
|
||||||
|
import at.mocode.ping.application.PingUseCase
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||||
import org.springframework.boot.test.context.TestConfiguration
|
import org.springframework.boot.test.context.TestConfiguration
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
import org.springframework.test.web.servlet.MockMvc
|
import org.springframework.test.web.servlet.MockMvc
|
||||||
|
import org.springframework.test.web.servlet.MvcResult
|
||||||
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch
|
||||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.request
|
||||||
|
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for PingController
|
* Unit tests for PingController
|
||||||
|
|
@ -28,109 +36,102 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@Import(PingControllerTest.TestConfig::class)
|
@Import(PingControllerTest.TestConfig::class)
|
||||||
|
@AutoConfigureMockMvc
|
||||||
class PingControllerTest {
|
class PingControllerTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var mockMvc: MockMvc
|
private lateinit var mockMvc: MockMvc
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private lateinit var pingService: PingServiceCircuitBreaker
|
private lateinit var pingUseCase: PingUseCase
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private lateinit var objectMapper: ObjectMapper
|
||||||
|
|
||||||
@TestConfiguration
|
@TestConfiguration
|
||||||
class TestConfig {
|
class TestConfig {
|
||||||
@Bean
|
@Bean
|
||||||
fun pingServiceCircuitBreaker(): PingServiceCircuitBreaker = mockk(relaxed = true)
|
fun pingUseCase(): PingUseCase = mockk(relaxed = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
// Reset mocks before each test
|
// Reset mocks before each test
|
||||||
io.mockk.clearMocks(pingService)
|
io.mockk.clearMocks(pingUseCase)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should return simple ping response`() {
|
fun `should return simple ping response`() {
|
||||||
|
// Given
|
||||||
|
every { pingUseCase.executePing(any()) } returns Ping(
|
||||||
|
message = "Simple Ping",
|
||||||
|
timestamp = Instant.parse("2023-10-01T10:00:00Z")
|
||||||
|
)
|
||||||
|
|
||||||
// When & Then
|
// When & Then
|
||||||
mockMvc.perform(get("/ping/simple"))
|
val mvcResult: MvcResult = mockMvc.perform(get("/ping/simple"))
|
||||||
|
.andExpect(request().asyncStarted())
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(asyncDispatch(mvcResult))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
// In some environments the JSONPath matcher fails to parse the response body.
|
||||||
|
// We still validate the serialized output contains the expected fields.
|
||||||
|
val body = result.response.contentAsString
|
||||||
|
System.out.println("[DEBUG_LOG] /ping/simple response status=${result.response.status} contentType=${result.response.contentType} body=$body")
|
||||||
|
val json = objectMapper.readTree(body)
|
||||||
|
assertThat(json.has("status")).isTrue
|
||||||
|
assertThat(json["status"].asText()).isEqualTo("pong")
|
||||||
|
assertThat(json["service"].asText()).isEqualTo("ping-service")
|
||||||
|
|
||||||
|
verify { pingUseCase.executePing("Simple Ping") }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should return enhanced ping response without simulation`() {
|
fun `should return enhanced ping response`() {
|
||||||
// Given
|
// Given
|
||||||
val expectedResponse = EnhancedPingResponse(
|
every { pingUseCase.executePing(any()) } returns Ping(
|
||||||
status = "pong",
|
message = "Enhanced Ping",
|
||||||
timestamp = "2023-10-01T10:00:00Z",
|
timestamp = Instant.parse("2023-10-01T10:00:00Z")
|
||||||
service = "ping-service",
|
|
||||||
circuitBreakerState = "CLOSED",
|
|
||||||
responseTime = 10L
|
|
||||||
)
|
)
|
||||||
every { pingService.ping(false) } returns expectedResponse
|
|
||||||
|
|
||||||
// When & Then
|
// When & Then
|
||||||
mockMvc.perform(get("/ping/enhanced"))
|
val mvcResult: MvcResult = mockMvc.perform(get("/ping/enhanced"))
|
||||||
|
.andExpect(request().asyncStarted())
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(asyncDispatch(mvcResult))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
// Verify
|
val body = result.response.contentAsString
|
||||||
verify { pingService.ping(false) }
|
System.out.println("[DEBUG_LOG] /ping/enhanced response status=${result.response.status} contentType=${result.response.contentType} body=$body")
|
||||||
}
|
val json = objectMapper.readTree(body)
|
||||||
|
assertThat(json.has("status")).isTrue
|
||||||
|
assertThat(json["status"].asText()).isEqualTo("pong")
|
||||||
|
assertThat(json["service"].asText()).isEqualTo("ping-service")
|
||||||
|
|
||||||
@Test
|
verify { pingUseCase.executePing("Enhanced Ping") }
|
||||||
fun `should return enhanced ping response with simulation enabled`() {
|
|
||||||
// Given
|
|
||||||
val expectedResponse = EnhancedPingResponse(
|
|
||||||
status = "fallback",
|
|
||||||
timestamp = "2023-10-01T10:00:00Z",
|
|
||||||
service = "ping-service-fallback",
|
|
||||||
circuitBreakerState = "OPEN",
|
|
||||||
responseTime = 5L
|
|
||||||
)
|
|
||||||
every { pingService.ping(true) } returns expectedResponse
|
|
||||||
|
|
||||||
// When & Then
|
|
||||||
mockMvc.perform(get("/ping/enhanced?simulate=true"))
|
|
||||||
.andExpect(status().isOk)
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
verify { pingService.ping(true) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `should return health check response`() {
|
fun `should return health check response`() {
|
||||||
// Given
|
|
||||||
val expectedResponse = HealthResponse(
|
|
||||||
status = "pong",
|
|
||||||
timestamp = "2023-10-01T10:00:00Z",
|
|
||||||
service = "ping-service",
|
|
||||||
healthy = true
|
|
||||||
)
|
|
||||||
every { pingService.healthCheck() } returns expectedResponse
|
|
||||||
|
|
||||||
// When & Then
|
// When & Then
|
||||||
mockMvc.perform(get("/ping/health"))
|
val mvcResult: MvcResult = mockMvc.perform(get("/ping/health"))
|
||||||
|
.andExpect(request().asyncStarted())
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
|
val result = mockMvc.perform(asyncDispatch(mvcResult))
|
||||||
.andExpect(status().isOk)
|
.andExpect(status().isOk)
|
||||||
|
.andReturn()
|
||||||
|
|
||||||
// Verify
|
val body = result.response.contentAsString
|
||||||
verify { pingService.healthCheck() }
|
System.out.println("[DEBUG_LOG] /ping/health response status=${result.response.status} contentType=${result.response.contentType} body=$body")
|
||||||
}
|
val json = objectMapper.readTree(body)
|
||||||
|
assertThat(json.has("status")).isTrue
|
||||||
@Test
|
assertThat(json["status"].asText()).isEqualTo("up")
|
||||||
fun `should handle missing simulate parameter with default false`() {
|
assertThat(json["service"].asText()).isEqualTo("ping-service")
|
||||||
// Given
|
|
||||||
val expectedResponse = EnhancedPingResponse(
|
|
||||||
status = "pong",
|
|
||||||
timestamp = "2023-10-01T10:00:00Z",
|
|
||||||
service = "ping-service",
|
|
||||||
circuitBreakerState = "CLOSED",
|
|
||||||
responseTime = 8L
|
|
||||||
)
|
|
||||||
every { pingService.ping(false) } returns expectedResponse
|
|
||||||
|
|
||||||
// When & Then
|
|
||||||
mockMvc.perform(get("/ping/enhanced"))
|
|
||||||
.andExpect(status().isOk)
|
|
||||||
|
|
||||||
// Verify default parameter is used
|
|
||||||
verify { pingService.ping(false) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,216 +1,56 @@
|
||||||
package at.mocode.ping.service
|
package at.mocode.ping.service
|
||||||
|
|
||||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker
|
import at.mocode.ping.application.PingService
|
||||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
|
import at.mocode.ping.domain.Ping
|
||||||
|
import at.mocode.ping.domain.PingRepository
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
import org.assertj.core.api.Assertions.assertThat
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
import org.junit.jupiter.api.BeforeEach
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.slf4j.LoggerFactory
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import kotlin.uuid.Uuid
|
||||||
import org.springframework.boot.test.context.SpringBootTest
|
|
||||||
import kotlin.math.ceil
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comprehensive test suite for PingServiceCircuitBreaker
|
* Unit tests for the actual application service (`PingService`).
|
||||||
* Updated to assert DTOs instead of Maps.
|
*
|
||||||
|
* The previous `PingServiceCircuitBreakerTest` referenced an outdated component.
|
||||||
*/
|
*/
|
||||||
@SpringBootTest
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
class PingServiceCircuitBreakerTest {
|
class PingServiceCircuitBreakerTest {
|
||||||
|
|
||||||
@Autowired
|
private val repository: PingRepository = mockk()
|
||||||
private lateinit var pingServiceCircuitBreaker: PingServiceCircuitBreaker
|
private val service = PingService(repository)
|
||||||
|
|
||||||
@Autowired
|
@Test
|
||||||
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
|
fun `executePing should persist and return ping`() {
|
||||||
|
every { repository.save(any()) } answers { firstArg() }
|
||||||
|
|
||||||
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreakerTest::class.java)
|
val result = service.executePing("Hello")
|
||||||
|
|
||||||
private lateinit var circuitBreaker: CircuitBreaker
|
assertThat(result.message).isEqualTo("Hello")
|
||||||
|
verify { repository.save(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@Test
|
||||||
fun setUp() {
|
fun `getPingHistory should delegate to repository`() {
|
||||||
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
|
every { repository.findAll() } returns emptyList()
|
||||||
// Reset circuit breaker state before each test
|
|
||||||
circuitBreaker.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
val result = service.getPingHistory()
|
||||||
fun `should return successful ping response when circuit breaker is closed`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When
|
assertThat(result).isEmpty()
|
||||||
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
|
verify { repository.findAll() }
|
||||||
|
}
|
||||||
|
|
||||||
// Then
|
@Test
|
||||||
assertThat(result.status).isEqualTo("pong")
|
fun `getPing should delegate to repository`() {
|
||||||
assertThat(result.service).isEqualTo("ping-service")
|
val id = Uuid.generateV7()
|
||||||
assertThat(result.circuitBreakerState).isEqualTo("CLOSED")
|
val ping = Ping(id = id, message = "Hi")
|
||||||
assertThat(result.timestamp).isNotBlank()
|
every { repository.findById(id) } returns ping
|
||||||
assertThat(result.responseTime).isGreaterThanOrEqualTo(0)
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
val result = service.getPing(id)
|
||||||
fun `should handle single failure without opening circuit breaker`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When - try until we hit a simulated failure (60% chance)
|
assertThat(result).isEqualTo(ping)
|
||||||
var result = pingServiceCircuitBreaker.ping(simulateFailure = true)
|
verify { repository.findById(id) }
|
||||||
var attempts = 1
|
}
|
||||||
while (result.status == "pong" && attempts < 10) {
|
|
||||||
result = pingServiceCircuitBreaker.ping(simulateFailure = true)
|
|
||||||
attempts++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - should get fallback response eventually
|
|
||||||
assertThat(result.status).isEqualTo("fallback")
|
|
||||||
assertThat(result.service).isEqualTo("ping-service-fallback")
|
|
||||||
assertThat(result.circuitBreakerState).isEqualTo("OPEN")
|
|
||||||
logger.info("Circuit breaker state after single failure (after {} attempts): {}", attempts, circuitBreaker.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should open circuit breaker after multiple failures`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When - trigger multiple failures to reach minimum-number-of-calls (4) and failure threshold (60%)
|
|
||||||
var failureCount = 0
|
|
||||||
var totalCalls = 0
|
|
||||||
val maxAttempts = 20 // Prevent infinite loop
|
|
||||||
|
|
||||||
while (circuitBreaker.state == CircuitBreaker.State.CLOSED && totalCalls < maxAttempts) {
|
|
||||||
val result = pingServiceCircuitBreaker.ping(simulateFailure = true)
|
|
||||||
totalCalls++
|
|
||||||
if (result.status == "fallback") failureCount++
|
|
||||||
logger.info(
|
|
||||||
"Attempt {}: Circuit breaker state = {}, Response status = {}, Failures so far = {}",
|
|
||||||
totalCalls, circuitBreaker.state, result.status, failureCount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then - circuit breaker should be open after sufficient failures
|
|
||||||
logger.info(
|
|
||||||
"Final circuit breaker state: {} after {} total calls with {} failures",
|
|
||||||
circuitBreaker.state, totalCalls, failureCount
|
|
||||||
)
|
|
||||||
if (totalCalls >= 4 && failureCount >= ceil(totalCalls * 0.6)) {
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return fallback response when circuit breaker is manually opened`() {
|
|
||||||
// Given
|
|
||||||
circuitBreaker.transitionToOpenState()
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
|
|
||||||
|
|
||||||
// When
|
|
||||||
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(result.status).isEqualTo("fallback")
|
|
||||||
assertThat(result.service).isEqualTo("ping-service-fallback")
|
|
||||||
assertThat(result.circuitBreakerState).isEqualTo("OPEN")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return successful health check when circuit breaker is closed`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When
|
|
||||||
val result = pingServiceCircuitBreaker.healthCheck()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(result.healthy).isTrue()
|
|
||||||
assertThat(result.status).isEqualTo("pong")
|
|
||||||
assertThat(result.timestamp).isNotBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should return fallback health check when circuit breaker is open`() {
|
|
||||||
// Given
|
|
||||||
circuitBreaker.transitionToOpenState()
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
|
|
||||||
|
|
||||||
// When
|
|
||||||
val result = pingServiceCircuitBreaker.healthCheck()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(result.healthy).isFalse()
|
|
||||||
assertThat(result.status).isEqualTo("down")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should test circuit breaker state transitions`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When - manually transition to open state
|
|
||||||
circuitBreaker.transitionToOpenState()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.OPEN)
|
|
||||||
|
|
||||||
// When - manually transition to half-open state
|
|
||||||
circuitBreaker.transitionToHalfOpenState()
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.HALF_OPEN)
|
|
||||||
|
|
||||||
// When - successful call should close circuit breaker
|
|
||||||
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
assertThat(result.status).isEqualTo("pong")
|
|
||||||
logger.info("Circuit breaker state after successful call in HALF_OPEN: {}", circuitBreaker.state)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should track circuit breaker metrics`() {
|
|
||||||
// Given
|
|
||||||
val metrics = circuitBreaker.metrics
|
|
||||||
val initialNumberOfCalls = metrics.numberOfBufferedCalls
|
|
||||||
|
|
||||||
// When - execute some successful calls
|
|
||||||
repeat(3) {
|
|
||||||
pingServiceCircuitBreaker.ping(simulateFailure = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then
|
|
||||||
val newMetrics = circuitBreaker.metrics
|
|
||||||
assertThat(newMetrics.numberOfBufferedCalls).isGreaterThan(initialNumberOfCalls)
|
|
||||||
logger.info(
|
|
||||||
"Circuit breaker metrics - Calls: {}, Failure rate: {}%, Successful calls: {}, Failed calls: {}",
|
|
||||||
newMetrics.numberOfBufferedCalls,
|
|
||||||
newMetrics.failureRate,
|
|
||||||
newMetrics.numberOfSuccessfulCalls,
|
|
||||||
newMetrics.numberOfFailedCalls
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `should handle concurrent calls correctly`() {
|
|
||||||
// Given
|
|
||||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
|
||||||
|
|
||||||
// When - execute concurrent calls
|
|
||||||
val threads = (1..10).map { index ->
|
|
||||||
Thread {
|
|
||||||
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
|
|
||||||
logger.info("Concurrent call {}: status = {}", index, result.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
threads.forEach { it.start() }
|
|
||||||
threads.forEach { it.join() }
|
|
||||||
|
|
||||||
// Then - verify circuit breaker recorded calls
|
|
||||||
val metrics = circuitBreaker.metrics
|
|
||||||
assertThat(metrics.numberOfBufferedCalls).isGreaterThanOrEqualTo(10)
|
|
||||||
assertThat(metrics.numberOfSuccessfulCalls).isGreaterThanOrEqualTo(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ kotlin {
|
||||||
|
|
||||||
jsMain.dependencies {
|
jsMain.dependencies {
|
||||||
implementation(libs.sqldelight.driver.web)
|
implementation(libs.sqldelight.driver.web)
|
||||||
|
|
||||||
|
// NPM deps used by `sqlite.worker.js` (OPFS-backed SQLite WASM worker)
|
||||||
|
implementation(npm("@cashapp/sqldelight-sqljs-worker", "2.2.1"))
|
||||||
|
// Use a published build tag from the official package.
|
||||||
|
implementation(npm("@sqlite.org/sqlite-wasm", "3.51.1-build2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@ package at.mocode.frontend.core.localdb
|
||||||
import app.cash.sqldelight.db.SqlDriver
|
import app.cash.sqldelight.db.SqlDriver
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
expect class DatabaseDriverFactory {
|
expect class DatabaseDriverFactory() {
|
||||||
suspend fun createDriver(): SqlDriver
|
suspend fun createDriver(): SqlDriver
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package at.mocode.frontend.core.localdb
|
||||||
|
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper around SQLDelight `AppDatabase` creation.
|
||||||
|
*
|
||||||
|
* The platform-specific part is the `DatabaseDriverFactory` (expect/actual),
|
||||||
|
* which provides the appropriate SQLDelight driver (JVM sqlite driver, JS WebWorkerDriver, ...).
|
||||||
|
*/
|
||||||
|
class DatabaseProvider(
|
||||||
|
private val driverFactory: DatabaseDriverFactory
|
||||||
|
) {
|
||||||
|
suspend fun createDatabase(): AppDatabase {
|
||||||
|
val driver = driverFactory.createDriver()
|
||||||
|
return AppDatabase(driver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Koin module to provide the SQLDelight database for all frontend targets.
|
||||||
|
*/
|
||||||
|
val localDbModule = module {
|
||||||
|
single<DatabaseDriverFactory> { DatabaseDriverFactory() }
|
||||||
|
single<DatabaseProvider> { DatabaseProvider(get()) }
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,51 @@
|
||||||
import { runWorker } from '@cashapp/sqldelight-sqljs-worker';
|
|
||||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
|
|
||||||
|
// Minimal worker protocol compatible with SQLDelight's `web-worker-driver`.
|
||||||
|
// Mirrors the message format used by SQLDelight's `sqljs.worker.js` implementation.
|
||||||
|
function runWorker({ driver }) {
|
||||||
|
let db = null;
|
||||||
|
const open = (name) => {
|
||||||
|
db = driver.open(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open once with the default database name expected by SQLDelight.
|
||||||
|
open('app.db');
|
||||||
|
|
||||||
|
self.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
try {
|
||||||
|
switch (data && data.action) {
|
||||||
|
case 'exec': {
|
||||||
|
if (!data.sql) throw new Error('exec: Missing query string');
|
||||||
|
// sqlite-wasm oo1 DB supports `.exec(...)`.
|
||||||
|
// We intentionally return only `values` which is sufficient for SQLDelight.
|
||||||
|
const rows = [];
|
||||||
|
db.exec({
|
||||||
|
sql: data.sql,
|
||||||
|
bind: data.params ?? [],
|
||||||
|
rowMode: 'array',
|
||||||
|
callback: (row) => rows.push(row)
|
||||||
|
});
|
||||||
|
return postMessage({ id: data.id, results: { values: rows } });
|
||||||
|
}
|
||||||
|
case 'begin_transaction':
|
||||||
|
db.exec('BEGIN TRANSACTION;');
|
||||||
|
return postMessage({ id: data.id, results: [] });
|
||||||
|
case 'end_transaction':
|
||||||
|
db.exec('END TRANSACTION;');
|
||||||
|
return postMessage({ id: data.id, results: [] });
|
||||||
|
case 'rollback_transaction':
|
||||||
|
db.exec('ROLLBACK TRANSACTION;');
|
||||||
|
return postMessage({ id: data.id, results: [] });
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported action: ${data && data.action}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return postMessage({ id: data && data.id, error: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
sqlite3InitModule({
|
sqlite3InitModule({
|
||||||
print: console.log,
|
print: console.log,
|
||||||
printErr: console.error,
|
printErr: console.error,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,49 @@
|
||||||
import { runWorker } from '@cashapp/sqldelight-sqljs-worker';
|
|
||||||
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
|
||||||
|
|
||||||
|
// Minimal worker protocol compatible with SQLDelight's `web-worker-driver`.
|
||||||
|
// Mirrors the message format used by SQLDelight's `sqljs.worker.js` implementation.
|
||||||
|
function runWorker({ driver }) {
|
||||||
|
let db = null;
|
||||||
|
const open = (name) => {
|
||||||
|
db = driver.open(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open once with the default database name expected by SQLDelight.
|
||||||
|
open('app.db');
|
||||||
|
|
||||||
|
self.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
try {
|
||||||
|
switch (data && data.action) {
|
||||||
|
case 'exec': {
|
||||||
|
if (!data.sql) throw new Error('exec: Missing query string');
|
||||||
|
const rows = [];
|
||||||
|
db.exec({
|
||||||
|
sql: data.sql,
|
||||||
|
bind: data.params ?? [],
|
||||||
|
rowMode: 'array',
|
||||||
|
callback: (row) => rows.push(row)
|
||||||
|
});
|
||||||
|
return postMessage({ id: data.id, results: { values: rows } });
|
||||||
|
}
|
||||||
|
case 'begin_transaction':
|
||||||
|
db.exec('BEGIN TRANSACTION;');
|
||||||
|
return postMessage({ id: data.id, results: [] });
|
||||||
|
case 'end_transaction':
|
||||||
|
db.exec('END TRANSACTION;');
|
||||||
|
return postMessage({ id: data.id, results: [] });
|
||||||
|
case 'rollback_transaction':
|
||||||
|
db.exec('ROLLBACK TRANSACTION;');
|
||||||
|
return postMessage({ id: data.id, results: [] });
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported action: ${data && data.action}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return postMessage({ id: data && data.id, error: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
sqlite3InitModule({
|
sqlite3InitModule({
|
||||||
print: console.log,
|
print: console.log,
|
||||||
printErr: console.error,
|
printErr: console.error,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,15 @@ kotlin {
|
||||||
implementation(libs.bundles.kmp.common)
|
implementation(libs.bundles.kmp.common)
|
||||||
implementation(libs.bundles.compose.common)
|
implementation(libs.bundles.compose.common)
|
||||||
|
|
||||||
|
// Ktor (used directly in shared/di and shared/network)
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.contentNegotiation)
|
||||||
|
implementation(libs.ktor.client.logging)
|
||||||
|
implementation(libs.ktor.client.serialization.kotlinx.json)
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Compose
|
// Compose
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
package at.mocode.shared.network
|
||||||
|
|
||||||
|
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
||||||
|
|
@ -74,6 +74,7 @@ kotlin {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Shared modules
|
// Shared modules
|
||||||
implementation(projects.frontend.shared)
|
implementation(projects.frontend.shared)
|
||||||
|
implementation(projects.frontend.core.domain)
|
||||||
implementation(projects.frontend.core.designSystem)
|
implementation(projects.frontend.core.designSystem)
|
||||||
implementation(projects.frontend.core.navigation)
|
implementation(projects.frontend.core.navigation)
|
||||||
implementation(projects.frontend.core.network)
|
implementation(projects.frontend.core.network)
|
||||||
|
|
@ -127,6 +128,28 @@ kotlin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SQLDelight WebWorker (OPFS) resource
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// `:frontend:core:local-db` ships `sqlite.worker.js` as a JS resource.
|
||||||
|
// When bundling the final JS app, webpack resolves `new URL("sqlite.worker.js", import.meta.url)`
|
||||||
|
// relative to the Kotlin JS package folder (root build dir). We therefore copy the worker into
|
||||||
|
// that folder before webpack runs.
|
||||||
|
|
||||||
|
val copySqliteWorkerJs by tasks.registering(Copy::class) {
|
||||||
|
val localDb = project(":frontend:core:local-db")
|
||||||
|
dependsOn(localDb.tasks.named("jsProcessResources"))
|
||||||
|
|
||||||
|
from(localDb.layout.buildDirectory.file("processedResources/js/main/sqlite.worker.js"))
|
||||||
|
|
||||||
|
// Root build directory where Kotlin JS packages are assembled.
|
||||||
|
into(rootProject.layout.buildDirectory.dir("js/packages/${rootProject.name}-frontend-shells-meldestelle-portal/kotlin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == "jsBrowserProductionWebpack" }.configureEach {
|
||||||
|
dependsOn(copySqliteWorkerJs)
|
||||||
|
}
|
||||||
|
|
||||||
// KMP Compile-Optionen
|
// KMP Compile-Optionen
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ hikari = "7.0.2"
|
||||||
h2 = "2.4.240"
|
h2 = "2.4.240"
|
||||||
flyway = "11.19.1"
|
flyway = "11.19.1"
|
||||||
redisson = "4.0.0"
|
redisson = "4.0.0"
|
||||||
lettuce = "7.2.1.RELEASE"
|
# Spring Boot 3.5.x manages Lettuce 6.6.x; keep aligned to avoid binary/API mismatches.
|
||||||
|
lettuce = "6.6.0.RELEASE"
|
||||||
|
|
||||||
# Observability
|
# Observability
|
||||||
micrometer = "1.16.1"
|
micrometer = "1.16.1"
|
||||||
|
|
@ -162,6 +163,7 @@ spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-star
|
||||||
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
|
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
|
||||||
spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" }
|
spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server" }
|
||||||
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
||||||
|
spring-security-test = { module = "org.springframework.security:spring-security-test" }
|
||||||
spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux" }
|
spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux" }
|
||||||
spring-boot-starter-json = { module = "org.springframework.boot:spring-boot-starter-json" }
|
spring-boot-starter-json = { module = "org.springframework.boot:spring-boot-starter-json" }
|
||||||
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springBoot" }
|
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop", version.ref = "springBoot" }
|
||||||
|
|
@ -293,6 +295,10 @@ testing-jvm = [
|
||||||
"mockk",
|
"mockk",
|
||||||
"assertj-core"
|
"assertj-core"
|
||||||
]
|
]
|
||||||
|
test-spring = [
|
||||||
|
"spring-boot-starter-test",
|
||||||
|
"spring-security-test"
|
||||||
|
]
|
||||||
spring-boot-service-complete = [
|
spring-boot-service-complete = [
|
||||||
"spring-boot-starter-web",
|
"spring-boot-starter-web",
|
||||||
"spring-boot-starter-validation",
|
"spring-boot-starter-validation",
|
||||||
|
|
@ -306,6 +312,15 @@ spring-boot-service-complete = [
|
||||||
"zipkin-reporter-brave",
|
"zipkin-reporter-brave",
|
||||||
"zipkin-sender-okhttp3"
|
"zipkin-sender-okhttp3"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Standard dependencies for a "secure" Spring Boot microservice (architecture-approved baseline)
|
||||||
|
spring-boot-secure-service = [
|
||||||
|
"spring-boot-starter-web",
|
||||||
|
"spring-boot-starter-actuator",
|
||||||
|
"spring-boot-starter-security",
|
||||||
|
"spring-boot-starter-oauth2-resource-server",
|
||||||
|
"spring-cloud-starter-consul-discovery"
|
||||||
|
]
|
||||||
database-complete = [
|
database-complete = [
|
||||||
"spring-boot-starter-data-jpa",
|
"spring-boot-starter-data-jpa",
|
||||||
"postgresql-driver",
|
"postgresql-driver",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,11 @@
|
||||||
# yarn lockfile v1
|
# yarn lockfile v1
|
||||||
|
|
||||||
|
|
||||||
|
"@cashapp/sqldelight-sqljs-worker@2.2.1":
|
||||||
|
version "2.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@cashapp/sqldelight-sqljs-worker/-/sqldelight-sqljs-worker-2.2.1.tgz#c71776a9dddfc435d4f1e64317a7039d447ea024"
|
||||||
|
integrity sha512-cj/llgS1T94t7rz63fI7pbi+jJx+vQofCT58KyMZb9XVRuoxb4taB5wbbBa4e/iljiuN5XIGGPFx+5PvtVh3LQ==
|
||||||
|
|
||||||
"@colors/colors@1.5.0":
|
"@colors/colors@1.5.0":
|
||||||
version "1.5.0"
|
version "1.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
|
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
|
||||||
|
|
@ -122,6 +127,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
|
||||||
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
|
||||||
|
|
||||||
|
"@sqlite.org/sqlite-wasm@3.51.1-build2":
|
||||||
|
version "3.51.1-build2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.51.1-build2.tgz#822497fdd05cbee3b1e8209aa46ffe1bafc70dbb"
|
||||||
|
integrity sha512-lVPTBlFsEijJ3wuoIbMfC9QMZKfL8huHN8D/lijNKoVxPqUDNvDtXse0wafe7USSmyfKAMb1JZ3ISSr/Vgbn5w==
|
||||||
|
|
||||||
"@types/body-parser@*":
|
"@types/body-parser@*":
|
||||||
version "1.19.6"
|
version "1.19.6"
|
||||||
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474"
|
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.6.tgz#1859bebb8fd7dac9918a45d54c1971ab8b5af474"
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@ pluginManagement {
|
||||||
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/") } // Added snapshots for plugins
|
maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/") } // Added snapshots for plugins
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.foojayResolver)
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user