chore(MP-23): network DI client, frontend architecture guards, detekt & ktlint setup, docs, ping DI factory (#21)
* chore(MP-21): snapshot pre-refactor state (Epic 1) * chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template * MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert * MP-23 Epic 3: Gradle/Build Governance zentralisieren
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// Optimized Spring Boot ping service for testing microservice architecture
|
||||
// This service demonstrates circuit breaker patterns, service discovery, and monitoring
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinJvm)
|
||||
alias(libs.plugins.kotlinSpring)
|
||||
alias(libs.plugins.kotlinJpa)
|
||||
alias(libs.plugins.spring.boot)
|
||||
alias(libs.plugins.spring.dependencyManagement)
|
||||
}
|
||||
|
||||
// Configure the main class for the executable JAR
|
||||
springBoot {
|
||||
mainClass.set("at.mocode.ping.service.PingServiceApplicationKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Platform BOM für zentrale Versionsverwaltung
|
||||
implementation(platform(projects.platform.platformBom))
|
||||
|
||||
// Platform und Core Dependencies
|
||||
implementation(projects.platform.platformDependencies)
|
||||
implementation(project(":backend:services:ping:ping-api"))
|
||||
implementation(projects.infrastructure.monitoring.monitoringClient)
|
||||
|
||||
// Spring Boot Service Complete Bundle
|
||||
// Provides: web, validation, actuator, security, oauth2-client, oauth2-resource-server,
|
||||
// data-jpa, data-redis, micrometer-prometheus, tracing, zipkin
|
||||
implementation(libs.bundles.spring.boot.service.complete)
|
||||
|
||||
// Datenbank (PostgresQL) Driver
|
||||
implementation(libs.postgresql.driver)
|
||||
|
||||
// Web-Server (Tomcat) explizit hinzufügen!
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
|
||||
// Jackson Kotlin Support Bundle
|
||||
implementation(libs.bundles.jackson.kotlin)
|
||||
|
||||
// Kotlin Reflection (now from version catalog)
|
||||
implementation(libs.kotlin.reflect)
|
||||
|
||||
// Service Discovery
|
||||
implementation(libs.spring.cloud.starter.consul.discovery)
|
||||
|
||||
// Caching (Caffeine for Spring Cloud LoadBalancer)
|
||||
implementation(libs.caffeine)
|
||||
implementation(libs.spring.web) // Provides spring-context-support
|
||||
|
||||
// Resilience4j Bundle (Circuit Breaker, Reactor, AOP)
|
||||
implementation(libs.bundles.resilience)
|
||||
|
||||
// OpenAPI Documentation
|
||||
implementation(libs.springdoc.openapi.starter.webmvc.ui)
|
||||
|
||||
// Test Dependencies
|
||||
testImplementation(projects.platform.platformTesting)
|
||||
testImplementation(libs.bundles.testing.jvm)
|
||||
testImplementation(libs.spring.boot.starter.test)
|
||||
testImplementation(libs.spring.boot.starter.web)
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package at.mocode.ping.service
|
||||
|
||||
import at.mocode.ping.api.EnhancedPingResponse
|
||||
import at.mocode.ping.api.HealthResponse
|
||||
import at.mocode.ping.api.PingApi
|
||||
import at.mocode.ping.api.PingResponse
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@RestController
|
||||
@CrossOrigin(
|
||||
origins = ["http://localhost:8080", "http://localhost:8083", "http://localhost:4000"],
|
||||
methods = [RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS],
|
||||
allowedHeaders = ["*"],
|
||||
allowCredentials = "true"
|
||||
)
|
||||
class PingController(
|
||||
private val pingService: PingServiceCircuitBreaker
|
||||
) : PingApi {
|
||||
|
||||
// Contract endpoints
|
||||
@GetMapping("/ping/simple")
|
||||
override suspend fun simplePing(): PingResponse {
|
||||
val now = OffsetDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
|
||||
return PingResponse(
|
||||
status = "pong",
|
||||
timestamp = now,
|
||||
service = "ping-service"
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/ping/enhanced")
|
||||
override suspend fun enhancedPing(
|
||||
@RequestParam(required = false, defaultValue = "false") simulate: Boolean
|
||||
): EnhancedPingResponse = pingService.ping(simulate)
|
||||
|
||||
@GetMapping("/ping/health")
|
||||
override suspend fun healthCheck(): HealthResponse = pingService.healthCheck()
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package at.mocode.ping.service
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.EnableAspectJAutoProxy
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAspectJAutoProxy
|
||||
class PingServiceApplication {
|
||||
|
||||
@Bean
|
||||
fun corsConfigurer(): WebMvcConfigurer {
|
||||
return object : WebMvcConfigurer {
|
||||
override fun addCorsMappings(registry: CorsRegistry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("http://localhost:*")
|
||||
.allowedOrigins("http://localhost:8080",
|
||||
"http://localhost:8083",
|
||||
"http://localhost:4000"
|
||||
)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<PingServiceApplication>(*args)
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package at.mocode.ping.service
|
||||
|
||||
import at.mocode.ping.api.EnhancedPingResponse
|
||||
import at.mocode.ping.api.HealthResponse
|
||||
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Service demonstrating a Circuit Breaker pattern with Resilience
|
||||
*
|
||||
* This service simulates potential failures and uses circuit breaker
|
||||
* to handle service degradation gracefully with fallback responses.
|
||||
*/
|
||||
@Service
|
||||
class PingServiceCircuitBreaker {
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreaker::class.java)
|
||||
|
||||
companion object {
|
||||
const val PING_CIRCUIT_BREAKER = "pingCircuitBreaker"
|
||||
private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME //.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary ping method with circuit breaker protection returning DTO directly
|
||||
*
|
||||
* @param simulateFailure - if true, randomly throws exceptions to test circuit breaker
|
||||
*/
|
||||
@CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackPing")
|
||||
fun ping(simulateFailure: Boolean = false): EnhancedPingResponse {
|
||||
val start = System.nanoTime()
|
||||
logger.info("Executing ping service call...")
|
||||
|
||||
if (simulateFailure && Random.nextDouble() < 0.6) {
|
||||
logger.warn("Simulating service failure for circuit breaker testing")
|
||||
throw RuntimeException("Simulated service failure")
|
||||
}
|
||||
|
||||
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
|
||||
val elapsedMs = (System.nanoTime() - start) / 1_000_000
|
||||
logger.info("Ping service call successful")
|
||||
|
||||
return EnhancedPingResponse(
|
||||
status = "pong",
|
||||
timestamp = currentTime,
|
||||
service = "ping-service",
|
||||
circuitBreakerState = "CLOSED",
|
||||
responseTime = elapsedMs
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback method called when circuit breaker is OPEN
|
||||
*
|
||||
* @param simulateFailure - original parameter (ignored in fallback)
|
||||
* @param exception - the exception that triggered the fallback
|
||||
*/
|
||||
fun fallbackPing(simulateFailure: Boolean = false, exception: Exception): EnhancedPingResponse {
|
||||
val start = System.nanoTime()
|
||||
// Die volle Exception nur loggen, nicht an den Client weitergeben.
|
||||
logger.warn("Circuit breaker fallback triggered due to: {}", exception.toString())
|
||||
|
||||
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
|
||||
val elapsedMs = (System.nanoTime() - start) / 1_000_000
|
||||
|
||||
return EnhancedPingResponse(
|
||||
status = "fallback",
|
||||
timestamp = currentTime,
|
||||
service = "ping-service-fallback",
|
||||
circuitBreakerState = "OPEN",
|
||||
responseTime = elapsedMs
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check method with circuit breaker protection returning DTO directly
|
||||
*/
|
||||
@CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackHealth")
|
||||
fun healthCheck(): HealthResponse {
|
||||
logger.info("Executing health check...")
|
||||
|
||||
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
|
||||
return HealthResponse(
|
||||
status = "pong",
|
||||
timestamp = currentTime,
|
||||
service = "ping-service",
|
||||
healthy = true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for health check returning DTO
|
||||
*/
|
||||
fun fallbackHealth(exception: Exception): HealthResponse {
|
||||
logger.warn("Health check fallback triggered: {}", exception.message)
|
||||
|
||||
val currentTime = LocalDateTime.now().atOffset(java.time.ZoneOffset.UTC).format(formatter)
|
||||
return HealthResponse(
|
||||
status = "down",
|
||||
timestamp = currentTime,
|
||||
service = "ping-service",
|
||||
healthy = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package at.mocode.ping.service.config
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.config.http.SessionCreationPolicy
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
/**
|
||||
* Security configuration for the Ping Service.
|
||||
* Enables method-level security for fine-grained authorization control.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity(prePostEnabled = true)
|
||||
class SecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
return http
|
||||
.csrf { it.disable() }
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth
|
||||
// Allow health check endpoints
|
||||
.requestMatchers("/actuator/**", "/health/**").permitAll()
|
||||
// Allow ping endpoints for monitoring (these are typically public)
|
||||
.requestMatchers("/ping/**").permitAll()
|
||||
// All other endpoints require authentication (handled by method-level security)
|
||||
.anyRequest().authenticated()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
spring:
|
||||
application:
|
||||
name: ping-service
|
||||
cloud:
|
||||
consul:
|
||||
host: ${CONSUL_HOST:localhost}
|
||||
port: ${CONSUL_PORT:8500}
|
||||
discovery:
|
||||
enabled: true
|
||||
register: true
|
||||
health-check-path: /actuator/health
|
||||
health-check-interval: 10s
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:${PING_SERVICE_PORT:8082}}
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,metrics,prometheus,circuitbreakers
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
probes:
|
||||
enabled: true
|
||||
tracing:
|
||||
# Disable tracing by default to avoid Zipkin connection errors
|
||||
enabled: ${TRACING_ENABLED:false}
|
||||
sampling:
|
||||
probability: ${TRACING_SAMPLING_PROBABILITY:0.1}
|
||||
zipkin:
|
||||
tracing:
|
||||
# Only configure endpoint if tracing is explicitly enabled
|
||||
endpoint: ${ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
|
||||
# Configure timeout and connection settings to handle missing Zipkin gracefully
|
||||
connect-timeout: 1s
|
||||
read-timeout: 5s
|
||||
|
||||
# Resilience4j Circuit Breaker Configuration
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
# Circuit breaker opens when the failure rate exceeds 50%
|
||||
failure-rate-threshold: 50
|
||||
# Minimum number of calls to calculate the failure rate
|
||||
minimum-number-of-calls: 5
|
||||
# Time to wait before transitioning from OPEN to HALF_OPEN
|
||||
wait-duration-in-open-state: 10s
|
||||
# Number of calls in HALF_OPEN state before deciding to close/open
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
# Sliding window size for calculating failure rate
|
||||
sliding-window-size: 10
|
||||
# Type of sliding window (COUNT_BASED or TIME_BASED)
|
||||
sliding-window-type: COUNT_BASED
|
||||
# Record exceptions that should be considered as failures
|
||||
record-exceptions:
|
||||
- java.lang.Exception
|
||||
# Ignore certain exceptions (don't count as failures)
|
||||
ignore-exceptions:
|
||||
- java.lang.IllegalArgumentException
|
||||
instances:
|
||||
pingCircuitBreaker:
|
||||
# Use default configuration
|
||||
base-config: default
|
||||
# Override specific settings for this instance if needed
|
||||
failure-rate-threshold: 60
|
||||
minimum-number-of-calls: 4
|
||||
wait-duration-in-open-state: 5s
|
||||
|
||||
# Metrics configuration removed to avoid property resolution warnings
|
||||
# Use micrometer and actuator endpoints for metrics instead
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<property name="LOG_PATTERN" value="%d{ISO8601} %-5level [%X{traceId:-}:%X{spanId:-}] %logger{36} - %msg%n"/>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>${LOG_PATTERN}</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="org.springframework" level="INFO"/>
|
||||
<logger name="org.springframework.web" level="INFO"/>
|
||||
<logger name="org.springframework.boot.actuate" level="INFO"/>
|
||||
<logger name="reactor.netty" level="WARN"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</configuration>
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package at.mocode.ping.service
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate
|
||||
import org.springframework.boot.test.web.server.LocalServerPort
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.test.context.ActiveProfiles
|
||||
|
||||
/**
|
||||
* Integration tests for PingController
|
||||
* Tests REST endpoints with circuit breaker functionality using TestRestTemplate
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("test")
|
||||
class PingControllerIntegrationTest {
|
||||
|
||||
@LocalServerPort
|
||||
private var port: Int = 0
|
||||
|
||||
@Autowired
|
||||
private lateinit var restTemplate: TestRestTemplate
|
||||
|
||||
@Autowired
|
||||
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PingControllerIntegrationTest::class.java)
|
||||
|
||||
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
|
||||
fun `should return enhanced ping response when circuit breaker is closed`() {
|
||||
// Given
|
||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
||||
|
||||
// When
|
||||
val response = restTemplate.getForEntity(getUrl("/ping/enhanced"), 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")
|
||||
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 ->
|
||||
val response = restTemplate.getForEntity(getUrl(endpoint), Map::class.java)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package at.mocode.ping.service
|
||||
|
||||
import at.mocode.ping.api.EnhancedPingResponse
|
||||
import at.mocode.ping.api.HealthResponse
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.test.web.servlet.MockMvc
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
|
||||
|
||||
/**
|
||||
* Unit tests for PingController
|
||||
* Tests REST endpoints with mocked dependencies
|
||||
*/
|
||||
@WebMvcTest(
|
||||
controllers = [PingController::class],
|
||||
excludeAutoConfiguration = [
|
||||
org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration::class,
|
||||
org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration::class
|
||||
]
|
||||
)
|
||||
@Import(PingControllerTest.TestConfig::class)
|
||||
class PingControllerTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var mockMvc: MockMvc
|
||||
|
||||
@Autowired
|
||||
private lateinit var pingService: PingServiceCircuitBreaker
|
||||
|
||||
@TestConfiguration
|
||||
class TestConfig {
|
||||
@Bean
|
||||
fun pingServiceCircuitBreaker(): PingServiceCircuitBreaker = mockk(relaxed = true)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
// Reset mocks before each test
|
||||
io.mockk.clearMocks(pingService)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return simple ping response`() {
|
||||
// When & Then
|
||||
mockMvc.perform(get("/ping/simple"))
|
||||
.andExpect(status().isOk)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return enhanced ping response without simulation`() {
|
||||
// Given
|
||||
val expectedResponse = EnhancedPingResponse(
|
||||
status = "pong",
|
||||
timestamp = "2023-10-01T10:00:00Z",
|
||||
service = "ping-service",
|
||||
circuitBreakerState = "CLOSED",
|
||||
responseTime = 10L
|
||||
)
|
||||
every { pingService.ping(false) } returns expectedResponse
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get("/ping/enhanced"))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
// Verify
|
||||
verify { pingService.ping(false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
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
|
||||
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
|
||||
mockMvc.perform(get("/ping/health"))
|
||||
.andExpect(status().isOk)
|
||||
|
||||
// Verify
|
||||
verify { pingService.healthCheck() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should handle missing simulate parameter with default false`() {
|
||||
// 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) }
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package at.mocode.ping.service
|
||||
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreaker
|
||||
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* Comprehensive test suite for PingServiceCircuitBreaker
|
||||
* Updated to assert DTOs instead of Maps.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class PingServiceCircuitBreakerTest {
|
||||
|
||||
@Autowired
|
||||
private lateinit var pingServiceCircuitBreaker: PingServiceCircuitBreaker
|
||||
|
||||
@Autowired
|
||||
private lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
|
||||
|
||||
private val logger = LoggerFactory.getLogger(PingServiceCircuitBreakerTest::class.java)
|
||||
|
||||
private lateinit var circuitBreaker: CircuitBreaker
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
circuitBreaker = circuitBreakerRegistry.circuitBreaker(PingServiceCircuitBreaker.PING_CIRCUIT_BREAKER)
|
||||
// Reset circuit breaker state before each test
|
||||
circuitBreaker.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return successful ping response when circuit breaker is closed`() {
|
||||
// Given
|
||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
||||
|
||||
// When
|
||||
val result = pingServiceCircuitBreaker.ping(simulateFailure = false)
|
||||
|
||||
// Then
|
||||
assertThat(result.status).isEqualTo("pong")
|
||||
assertThat(result.service).isEqualTo("ping-service")
|
||||
assertThat(result.circuitBreakerState).isEqualTo("CLOSED")
|
||||
assertThat(result.timestamp).isNotBlank()
|
||||
assertThat(result.responseTime).isGreaterThanOrEqualTo(0)
|
||||
assertThat(circuitBreaker.state).isEqualTo(CircuitBreaker.State.CLOSED)
|
||||
}
|
||||
|
||||
@Test
|
||||
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)
|
||||
var result = pingServiceCircuitBreaker.ping(simulateFailure = true)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
spring:
|
||||
application:
|
||||
name: ping-service-test
|
||||
cloud:
|
||||
consul:
|
||||
enabled: false
|
||||
discovery:
|
||||
enabled: false
|
||||
register: false
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,circuitbreakers
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
# Resilience4j Circuit Breaker Configuration for tests
|
||||
resilience4j:
|
||||
circuitbreaker:
|
||||
configs:
|
||||
default:
|
||||
failure-rate-threshold: 50
|
||||
minimum-number-of-calls: 5
|
||||
wait-duration-in-open-state: 10s
|
||||
permitted-number-of-calls-in-half-open-state: 3
|
||||
sliding-window-size: 10
|
||||
sliding-window-type: COUNT_BASED
|
||||
record-exceptions:
|
||||
- java.lang.Exception
|
||||
ignore-exceptions:
|
||||
- java.lang.IllegalArgumentException
|
||||
instances:
|
||||
pingCircuitBreaker:
|
||||
base-config: default
|
||||
failure-rate-threshold: 60
|
||||
minimum-number-of-calls: 4
|
||||
wait-duration-in-open-state: 5s
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
legacy:
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
org.springframework.cloud.consul: ERROR
|
||||
com.ecwid.consul: ERROR
|
||||
Reference in New Issue
Block a user