Refactor Ping service tests and introduce PingProperties configuration for cleaner service name handling

This commit is contained in:
2026-03-15 18:52:10 +01:00
parent ef336feb94
commit f05aabb0d4
8 changed files with 223 additions and 174 deletions
@@ -0,0 +1,11 @@
package at.mocode.ping.infrastructure
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.stereotype.Component
@Component
@ConfigurationProperties(prefix = "app")
data class PingProperties(
var serviceName: String = "ping-service",
var serviceNameFallback: String = "ping-service-fallback"
)
@@ -2,6 +2,7 @@ package at.mocode.ping.infrastructure.web
import at.mocode.ping.api.* import at.mocode.ping.api.*
import at.mocode.ping.application.PingUseCase import at.mocode.ping.application.PingUseCase
import at.mocode.ping.infrastructure.PingProperties
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.access.prepost.PreAuthorize
@@ -18,10 +19,10 @@ import kotlin.uuid.ExperimentalUuidApi
* Nutzt den Application Port (PingUseCase). * Nutzt den Application Port (PingUseCase).
*/ */
@RestController @RestController
// @CrossOrigin Annotation entfernt. CORS wird zentral im API-Gateway gehandhabt.
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
class PingController( class PingController(
private val pingUseCase: PingUseCase private val pingUseCase: PingUseCase,
private val properties: PingProperties
) : PingApi { ) : PingApi {
private val logger = LoggerFactory.getLogger(PingController::class.java) private val logger = LoggerFactory.getLogger(PingController::class.java)
@@ -54,7 +55,7 @@ class PingController(
return EnhancedPingResponse( return EnhancedPingResponse(
status = "pong", status = "pong",
timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter),
service = "ping-service", service = properties.serviceName,
circuitBreakerState = "CLOSED", circuitBreakerState = "CLOSED",
responseTime = elapsedMs responseTime = elapsedMs
) )
@@ -93,7 +94,7 @@ class PingController(
private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse( private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse(
status = status, status = status,
timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter),
service = "ping-service" service = properties.serviceName
) )
// Fallback // Fallback
@@ -103,7 +104,7 @@ class PingController(
return EnhancedPingResponse( return EnhancedPingResponse(
status = "fallback", status = "fallback",
timestamp = java.time.OffsetDateTime.now().format(formatter), timestamp = java.time.OffsetDateTime.now().format(formatter),
service = "ping-service-fallback", service = properties.serviceNameFallback,
circuitBreakerState = "OPEN", circuitBreakerState = "OPEN",
responseTime = 0 responseTime = 0
) )
@@ -114,13 +115,8 @@ class PingController(
return HealthResponse( return HealthResponse(
status = "up", status = "up",
timestamp = java.time.OffsetDateTime.now().format(formatter), timestamp = java.time.OffsetDateTime.now().format(formatter),
service = "ping-service", service = properties.serviceName,
healthy = true healthy = true
) )
} }
@GetMapping("/ping/history")
fun getHistory() = pingUseCase.getPingHistory().map {
mapOf("id" to it.id.toString(), "message" to it.message, "time" to it.timestamp.toString())
}
} }
@@ -80,3 +80,10 @@ resilience4j:
instances: instances:
pingCircuitBreaker: pingCircuitBreaker:
base-config: default base-config: default
# ==========================================================================
# Custom Application Properties
# ==========================================================================
app:
service-name: ${spring.application.name}
service-name-fallback: "${spring.application.name}-fallback"
@@ -0,0 +1,141 @@
package at.mocode.ping.application
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.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Unit-Tests für den PingService (Application Layer).
* Testet alle Use-Case-Methoden isoliert mit MockK.
*/
@OptIn(ExperimentalUuidApi::class)
class PingServiceTest {
private val repository: PingRepository = mockk()
private lateinit var service: PingService
@BeforeEach
fun setUp() {
service = PingService(repository)
}
@Test
fun `executePing should persist and return ping with given message`() {
// Given
every { repository.save(any()) } answers { firstArg() }
// When
val result = service.executePing("Hello")
// Then
assertThat(result.message).isEqualTo("Hello")
verify { repository.save(any()) }
}
@Test
fun `executePing should generate a new UUID for each ping`() {
// Given
every { repository.save(any()) } answers { firstArg() }
// When
val result1 = service.executePing("Ping 1")
val result2 = service.executePing("Ping 2")
// Then
assertThat(result1.id).isNotEqualTo(result2.id)
}
@Test
fun `getPingHistory should delegate to repository and return all pings`() {
// Given
val pings = listOf(
Ping(message = "Ping A"),
Ping(message = "Ping B")
)
every { repository.findAll() } returns pings
// When
val result = service.getPingHistory()
// Then
assertThat(result).hasSize(2)
assertThat(result.map { it.message }).containsExactly("Ping A", "Ping B")
verify { repository.findAll() }
}
@Test
fun `getPingHistory should return empty list when no pings exist`() {
// Given
every { repository.findAll() } returns emptyList()
// When
val result = service.getPingHistory()
// Then
assertThat(result).isEmpty()
}
@Test
fun `getPing should return ping by id`() {
// Given
val id = Uuid.generateV7()
val ping = Ping(id = id, message = "Find me")
every { repository.findById(id) } returns ping
// When
val result = service.getPing(id)
// Then
assertThat(result).isEqualTo(ping)
verify { repository.findById(id) }
}
@Test
fun `getPing should return null when ping not found`() {
// Given
val id = Uuid.generateV7()
every { repository.findById(id) } returns null
// When
val result = service.getPing(id)
// Then
assertThat(result).isNull()
}
@Test
fun `getPingsSince should return pings after given timestamp`() {
// Given
val timestamp = Instant.parse("2024-01-01T00:00:00Z").toEpochMilli()
val ping = Ping(message = "Recent Ping", timestamp = Instant.parse("2024-06-01T00:00:00Z"))
every { repository.findByTimestampAfter(any()) } returns listOf(ping)
// When
val result = service.getPingsSince(timestamp)
// Then
assertThat(result).hasSize(1)
assertThat(result[0].message).isEqualTo("Recent Ping")
verify { repository.findByTimestampAfter(Instant.ofEpochMilli(timestamp)) }
}
@Test
fun `getPingsSince should return empty list when no pings after timestamp`() {
// Given
every { repository.findByTimestampAfter(any()) } returns emptyList()
// When
val result = service.getPingsSince(System.currentTimeMillis())
// Then
assertThat(result).isEmpty()
}
}
@@ -1,11 +1,12 @@
package at.mocode.ping.service package at.mocode.ping.infrastructure.web
import at.mocode.ping.application.PingUseCase import at.mocode.ping.application.PingUseCase
import at.mocode.ping.domain.Ping import at.mocode.ping.domain.Ping
import at.mocode.ping.infrastructure.PingProperties
import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter
import at.mocode.ping.infrastructure.web.PingController
import at.mocode.ping.test.TestPingServiceApplication import at.mocode.ping.test.TestPingServiceApplication
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import io.mockk.clearMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@@ -32,8 +33,8 @@ import java.time.Instant
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
/** /**
* Unit tests for PingController * Unit-Test für den PingController (Web Layer).
* Tests REST endpoints with mocked dependencies * Nutzt @WebMvcTest für einen isolierten MVC-Slice ohne echte Services oder DB.
*/ */
@WebMvcTest( @WebMvcTest(
controllers = [PingController::class], controllers = [PingController::class],
@@ -42,7 +43,7 @@ import kotlin.uuid.ExperimentalUuidApi
@ContextConfiguration(classes = [TestPingServiceApplication::class]) @ContextConfiguration(classes = [TestPingServiceApplication::class])
@ActiveProfiles("test") @ActiveProfiles("test")
@Import(PingControllerTest.PingControllerTestConfig::class) @Import(PingControllerTest.PingControllerTestConfig::class)
@AutoConfigureMockMvc(addFilters = false) // Disable security filters for unit tests @AutoConfigureMockMvc(addFilters = false)
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
class PingControllerTest { class PingControllerTest {
@@ -53,6 +54,9 @@ class PingControllerTest {
@Qualifier("pingUseCaseMock") @Qualifier("pingUseCaseMock")
private lateinit var pingUseCase: PingUseCase private lateinit var pingUseCase: PingUseCase
@Autowired
private lateinit var properties: PingProperties
@Autowired @Autowired
private lateinit var objectMapper: ObjectMapper private lateinit var objectMapper: ObjectMapper
@@ -65,12 +69,17 @@ class PingControllerTest {
@Bean @Bean
@Primary @Primary
fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true) fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true)
@Bean
@Primary
fun pingProperties(): PingProperties = mockk(relaxed = true)
} }
@BeforeEach @BeforeEach
fun setUp() { fun setUp() {
// Reset mocks before each test clearMocks(pingUseCase, properties)
io.mockk.clearMocks(pingUseCase) every { properties.serviceName } returns "ping-service"
every { properties.serviceNameFallback } returns "ping-service-fallback"
} }
@Test @Test
@@ -81,7 +90,7 @@ class PingControllerTest {
timestamp = Instant.parse("2023-10-01T10:00:00Z") timestamp = Instant.parse("2023-10-01T10:00:00Z")
) )
// When & Then // When
val mvcResult: MvcResult = mockMvc.perform(get("/ping/simple")) val mvcResult: MvcResult = mockMvc.perform(get("/ping/simple"))
.andExpect(request().asyncStarted()) .andExpect(request().asyncStarted())
.andReturn() .andReturn()
@@ -90,12 +99,10 @@ class PingControllerTest {
.andExpect(status().isOk) .andExpect(status().isOk)
.andReturn() .andReturn()
val body = result.response.contentAsString // Then
val json = objectMapper.readTree(body) val json = objectMapper.readTree(result.response.contentAsString)
assertThat(json.has("status")).isTrue
assertThat(json["status"].asText()).isEqualTo("pong") assertThat(json["status"].asText()).isEqualTo("pong")
assertThat(json["service"].asText()).isEqualTo("ping-service") assertThat(json["service"].asText()).isEqualTo(properties.serviceName)
verify { pingUseCase.executePing("Simple Ping") } verify { pingUseCase.executePing("Simple Ping") }
} }
@@ -107,7 +114,7 @@ class PingControllerTest {
timestamp = Instant.parse("2023-10-01T10:00:00Z") timestamp = Instant.parse("2023-10-01T10:00:00Z")
) )
// When & Then // When
val mvcResult: MvcResult = mockMvc.perform(get("/ping/enhanced")) val mvcResult: MvcResult = mockMvc.perform(get("/ping/enhanced"))
.andExpect(request().asyncStarted()) .andExpect(request().asyncStarted())
.andReturn() .andReturn()
@@ -116,18 +123,16 @@ class PingControllerTest {
.andExpect(status().isOk) .andExpect(status().isOk)
.andReturn() .andReturn()
val body = result.response.contentAsString // Then
val json = objectMapper.readTree(body) val json = objectMapper.readTree(result.response.contentAsString)
assertThat(json.has("status")).isTrue
assertThat(json["status"].asText()).isEqualTo("pong") assertThat(json["status"].asText()).isEqualTo("pong")
assertThat(json["service"].asText()).isEqualTo("ping-service") assertThat(json["service"].asText()).isEqualTo(properties.serviceName)
verify { pingUseCase.executePing("Enhanced Ping") } verify { pingUseCase.executePing("Enhanced Ping") }
} }
@Test @Test
fun `should return health check response`() { fun `should return health check response with status up`() {
// When & Then // When
val mvcResult: MvcResult = mockMvc.perform(get("/ping/health")) val mvcResult: MvcResult = mockMvc.perform(get("/ping/health"))
.andExpect(request().asyncStarted()) .andExpect(request().asyncStarted())
.andReturn() .andReturn()
@@ -136,17 +141,16 @@ class PingControllerTest {
.andExpect(status().isOk) .andExpect(status().isOk)
.andReturn() .andReturn()
val body = result.response.contentAsString // Then
val json = objectMapper.readTree(body) val json = objectMapper.readTree(result.response.contentAsString)
assertThat(json.has("status")).isTrue
assertThat(json["status"].asText()).isEqualTo("up") assertThat(json["status"].asText()).isEqualTo("up")
assertThat(json["service"].asText()).isEqualTo("ping-service") assertThat(json["service"].asText()).isEqualTo(properties.serviceName)
} }
@Test @Test
fun `should return sync pings`() { fun `should return sync pings as list`() {
// Given // Given
val timestamp = 1696154400000L // 2023-10-01T10:00:00Z val timestamp = 1696154400000L
every { pingUseCase.getPingsSince(timestamp) } returns listOf( every { pingUseCase.getPingsSince(timestamp) } returns listOf(
Ping( Ping(
message = "Sync Ping", message = "Sync Ping",
@@ -154,8 +158,7 @@ class PingControllerTest {
) )
) )
// When & Then // When
// Changed parameter name to 'since' to match the controller update
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString())) val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString()))
.andExpect(request().asyncStarted()) .andExpect(request().asyncStarted())
.andReturn() .andReturn()
@@ -164,13 +167,33 @@ class PingControllerTest {
.andExpect(status().isOk) .andExpect(status().isOk)
.andReturn() .andReturn()
val body = result.response.contentAsString // Then
val json = objectMapper.readTree(body) val json = objectMapper.readTree(result.response.contentAsString)
assertThat(json.isArray).isTrue assertThat(json.isArray).isTrue
assertThat(json.size()).isEqualTo(1) assertThat(json.size()).isEqualTo(1)
assertThat(json[0]["message"].asText()).isEqualTo("Sync Ping") assertThat(json[0]["message"].asText()).isEqualTo("Sync Ping")
assertThat(json[0]["lastModified"].asLong()).isEqualTo(timestamp + 1000) assertThat(json[0]["lastModified"].asLong()).isEqualTo(timestamp + 1000)
verify { pingUseCase.getPingsSince(timestamp) } verify { pingUseCase.getPingsSince(timestamp) }
} }
@Test
fun `should return empty list when no pings since timestamp`() {
// Given
val timestamp = System.currentTimeMillis()
every { pingUseCase.getPingsSince(timestamp) } returns emptyList()
// When
val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString()))
.andExpect(request().asyncStarted())
.andReturn()
val result = mockMvc.perform(asyncDispatch(mvcResult))
.andExpect(status().isOk)
.andReturn()
// Then
val json = objectMapper.readTree(result.response.contentAsString)
assertThat(json.isArray).isTrue
assertThat(json.size()).isEqualTo(0)
}
} }
@@ -1,74 +0,0 @@
package at.mocode.ping.service
import at.mocode.ping.application.PingUseCase
import at.mocode.ping.domain.Ping
import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter
import at.mocode.ping.infrastructure.web.PingController
import at.mocode.ping.test.TestPingServiceApplication
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
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.context.annotation.Primary
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.ContextConfiguration
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
import kotlin.uuid.ExperimentalUuidApi
/**
* Lightweight Spring MVC integration test (no full application context / datasource).
*/
@WebMvcTest(
controllers = [PingController::class],
properties = ["spring.aop.proxy-target-class=true"]
)
@ContextConfiguration(classes = [TestPingServiceApplication::class])
@ActiveProfiles("test")
@Import(PingControllerIntegrationTest.PingControllerIntegrationTestConfig::class)
@OptIn(ExperimentalUuidApi::class)
class PingControllerIntegrationTest {
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
@Qualifier("pingUseCaseIntegrationMock")
private lateinit var pingUseCase: PingUseCase
@TestConfiguration
class PingControllerIntegrationTestConfig {
@Bean("pingUseCaseIntegrationMock")
@Primary
fun pingUseCase(): PingUseCase = mockk(relaxed = true)
@Bean
@Primary
fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true)
}
@Test
fun `should start MVC slice and serve endpoints`() {
// Just verify the MVC wiring starts and endpoints respond 200
mockMvc.perform(get("/ping/health")).andExpect(status().isOk)
// For endpoints that require the use-case, the relaxed mock is sufficient,
// but we still provide deterministic ping data.
every { pingUseCase.executePing(any()) } returns Ping(
message = "Simple Ping",
timestamp = Instant.parse("2023-10-01T10:00:00Z")
)
// Note: we don't assert the full JSON here (covered by PingControllerTest)
val result = mockMvc.perform(get("/ping/simple")).andReturn()
assertThat(result.response.status).isEqualTo(200)
}
}
@@ -1,56 +0,0 @@
package at.mocode.ping.service
import at.mocode.ping.application.PingService
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.junit.jupiter.api.Test
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/**
* Unit tests for the actual application service (`PingService`).
*
* The previous `PingServiceCircuitBreakerTest` referenced an outdated component.
*/
@OptIn(ExperimentalUuidApi::class)
class PingServiceCircuitBreakerTest {
private val repository: PingRepository = mockk()
private val service = PingService(repository)
@Test
fun `executePing should persist and return ping`() {
every { repository.save(any()) } answers { firstArg() }
val result = service.executePing("Hello")
assertThat(result.message).isEqualTo("Hello")
verify { repository.save(any()) }
}
@Test
fun `getPingHistory should delegate to repository`() {
every { repository.findAll() } returns emptyList()
val result = service.getPingHistory()
assertThat(result).isEmpty()
verify { repository.findAll() }
}
@Test
fun `getPing should delegate to repository`() {
val id = Uuid.generateV7()
val ping = Ping(id = id, message = "Hi")
every { repository.findById(id) } returns ping
val result = service.getPing(id)
assertThat(result).isEqualTo(ping)
verify { repository.findById(id) }
}
}
@@ -1,5 +1,6 @@
package at.mocode.ping.test package at.mocode.ping.test
import at.mocode.ping.infrastructure.PingProperties
import at.mocode.ping.infrastructure.web.PingController import at.mocode.ping.infrastructure.web.PingController
import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.ComponentScan
@@ -21,6 +22,6 @@ import org.springframework.context.annotation.Import
@ComponentScan( @ComponentScan(
basePackages = ["at.mocode.infrastructure.security"] basePackages = ["at.mocode.infrastructure.security"]
) )
@Import(PingController::class) @Import(PingController::class, PingProperties::class)
@EnableAspectJAutoProxy(proxyTargetClass = true) // Erzwingt CGLIB Proxies für Controller @EnableAspectJAutoProxy(proxyTargetClass = true) // Erzwingt CGLIB Proxies für Controller
class TestPingServiceApplication class TestPingServiceApplication