From f05aabb0d422624d002aefb693bf7b6ed16d44cd Mon Sep 17 00:00:00 2001 From: stefan Date: Sun, 15 Mar 2026 18:52:10 +0100 Subject: [PATCH] Refactor Ping service tests and introduce PingProperties configuration for cleaner service name handling --- .../ping/infrastructure/PingProperties.kt | 11 ++ .../ping/infrastructure/web/PingController.kt | 18 +-- .../src/main/resources/application.yaml | 7 + .../ping/application/PingServiceTest.kt | 141 ++++++++++++++++++ .../web}/PingControllerTest.kt | 87 +++++++---- .../service/PingControllerIntegrationTest.kt | 74 --------- .../service/PingServiceCircuitBreakerTest.kt | 56 ------- .../ping/test/TestPingServiceApplication.kt | 3 +- 8 files changed, 223 insertions(+), 174 deletions(-) create mode 100644 backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/PingProperties.kt create mode 100644 backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt rename backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/{service => infrastructure/web}/PingControllerTest.kt (69%) delete mode 100644 backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt delete mode 100644 backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingServiceCircuitBreakerTest.kt diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/PingProperties.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/PingProperties.kt new file mode 100644 index 00000000..ede701a8 --- /dev/null +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/PingProperties.kt @@ -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" +) diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt index 9d6bf2e7..371d5912 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt @@ -2,6 +2,7 @@ package at.mocode.ping.infrastructure.web import at.mocode.ping.api.* import at.mocode.ping.application.PingUseCase +import at.mocode.ping.infrastructure.PingProperties import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker import org.slf4j.LoggerFactory import org.springframework.security.access.prepost.PreAuthorize @@ -18,10 +19,10 @@ import kotlin.uuid.ExperimentalUuidApi * Nutzt den Application Port (PingUseCase). */ @RestController -// @CrossOrigin Annotation entfernt. CORS wird zentral im API-Gateway gehandhabt. @OptIn(ExperimentalUuidApi::class) class PingController( - private val pingUseCase: PingUseCase + private val pingUseCase: PingUseCase, + private val properties: PingProperties ) : PingApi { private val logger = LoggerFactory.getLogger(PingController::class.java) @@ -54,7 +55,7 @@ class PingController( return EnhancedPingResponse( status = "pong", timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), - service = "ping-service", + service = properties.serviceName, circuitBreakerState = "CLOSED", responseTime = elapsedMs ) @@ -93,7 +94,7 @@ class PingController( private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse( status = status, timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), - service = "ping-service" + service = properties.serviceName ) // Fallback @@ -103,7 +104,7 @@ class PingController( return EnhancedPingResponse( status = "fallback", timestamp = java.time.OffsetDateTime.now().format(formatter), - service = "ping-service-fallback", + service = properties.serviceNameFallback, circuitBreakerState = "OPEN", responseTime = 0 ) @@ -114,13 +115,8 @@ class PingController( return HealthResponse( status = "up", timestamp = java.time.OffsetDateTime.now().format(formatter), - service = "ping-service", + service = properties.serviceName, 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()) - } } diff --git a/backend/services/ping/ping-service/src/main/resources/application.yaml b/backend/services/ping/ping-service/src/main/resources/application.yaml index a15fe27d..b6cb4cca 100644 --- a/backend/services/ping/ping-service/src/main/resources/application.yaml +++ b/backend/services/ping/ping-service/src/main/resources/application.yaml @@ -80,3 +80,10 @@ resilience4j: instances: pingCircuitBreaker: base-config: default + +# ========================================================================== +# Custom Application Properties +# ========================================================================== +app: + service-name: ${spring.application.name} + service-name-fallback: "${spring.application.name}-fallback" diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt new file mode 100644 index 00000000..ac70f442 --- /dev/null +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt @@ -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() + } +} diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt similarity index 69% rename from backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt rename to backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt index c422d8af..b2b656c2 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerTest.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt @@ -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.domain.Ping +import at.mocode.ping.infrastructure.PingProperties import at.mocode.ping.infrastructure.persistence.PingRepositoryAdapter -import at.mocode.ping.infrastructure.web.PingController import at.mocode.ping.test.TestPingServiceApplication import com.fasterxml.jackson.databind.ObjectMapper +import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -32,8 +33,8 @@ import java.time.Instant import kotlin.uuid.ExperimentalUuidApi /** - * Unit tests for PingController - * Tests REST endpoints with mocked dependencies + * Unit-Test für den PingController (Web Layer). + * Nutzt @WebMvcTest für einen isolierten MVC-Slice ohne echte Services oder DB. */ @WebMvcTest( controllers = [PingController::class], @@ -42,7 +43,7 @@ import kotlin.uuid.ExperimentalUuidApi @ContextConfiguration(classes = [TestPingServiceApplication::class]) @ActiveProfiles("test") @Import(PingControllerTest.PingControllerTestConfig::class) -@AutoConfigureMockMvc(addFilters = false) // Disable security filters for unit tests +@AutoConfigureMockMvc(addFilters = false) @OptIn(ExperimentalUuidApi::class) class PingControllerTest { @@ -53,6 +54,9 @@ class PingControllerTest { @Qualifier("pingUseCaseMock") private lateinit var pingUseCase: PingUseCase + @Autowired + private lateinit var properties: PingProperties + @Autowired private lateinit var objectMapper: ObjectMapper @@ -65,12 +69,17 @@ class PingControllerTest { @Bean @Primary fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true) + + @Bean + @Primary + fun pingProperties(): PingProperties = mockk(relaxed = true) } @BeforeEach fun setUp() { - // Reset mocks before each test - io.mockk.clearMocks(pingUseCase) + clearMocks(pingUseCase, properties) + every { properties.serviceName } returns "ping-service" + every { properties.serviceNameFallback } returns "ping-service-fallback" } @Test @@ -81,7 +90,7 @@ class PingControllerTest { timestamp = Instant.parse("2023-10-01T10:00:00Z") ) - // When & Then + // When val mvcResult: MvcResult = mockMvc.perform(get("/ping/simple")) .andExpect(request().asyncStarted()) .andReturn() @@ -90,12 +99,10 @@ class PingControllerTest { .andExpect(status().isOk) .andReturn() - val body = result.response.contentAsString - val json = objectMapper.readTree(body) - assertThat(json.has("status")).isTrue + // Then + val json = objectMapper.readTree(result.response.contentAsString) 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") } } @@ -107,7 +114,7 @@ class PingControllerTest { timestamp = Instant.parse("2023-10-01T10:00:00Z") ) - // When & Then + // When val mvcResult: MvcResult = mockMvc.perform(get("/ping/enhanced")) .andExpect(request().asyncStarted()) .andReturn() @@ -116,18 +123,16 @@ class PingControllerTest { .andExpect(status().isOk) .andReturn() - val body = result.response.contentAsString - val json = objectMapper.readTree(body) - assertThat(json.has("status")).isTrue + // Then + val json = objectMapper.readTree(result.response.contentAsString) 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") } } @Test - fun `should return health check response`() { - // When & Then + fun `should return health check response with status up`() { + // When val mvcResult: MvcResult = mockMvc.perform(get("/ping/health")) .andExpect(request().asyncStarted()) .andReturn() @@ -136,17 +141,16 @@ class PingControllerTest { .andExpect(status().isOk) .andReturn() - val body = result.response.contentAsString - val json = objectMapper.readTree(body) - assertThat(json.has("status")).isTrue + // Then + val json = objectMapper.readTree(result.response.contentAsString) assertThat(json["status"].asText()).isEqualTo("up") - assertThat(json["service"].asText()).isEqualTo("ping-service") + assertThat(json["service"].asText()).isEqualTo(properties.serviceName) } @Test - fun `should return sync pings`() { + fun `should return sync pings as list`() { // Given - val timestamp = 1696154400000L // 2023-10-01T10:00:00Z + val timestamp = 1696154400000L every { pingUseCase.getPingsSince(timestamp) } returns listOf( Ping( message = "Sync Ping", @@ -154,8 +158,7 @@ class PingControllerTest { ) ) - // When & Then - // Changed parameter name to 'since' to match the controller update + // When val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString())) .andExpect(request().asyncStarted()) .andReturn() @@ -164,13 +167,33 @@ class PingControllerTest { .andExpect(status().isOk) .andReturn() - val body = result.response.contentAsString - val json = objectMapper.readTree(body) + // Then + val json = objectMapper.readTree(result.response.contentAsString) assertThat(json.isArray).isTrue assertThat(json.size()).isEqualTo(1) assertThat(json[0]["message"].asText()).isEqualTo("Sync Ping") assertThat(json[0]["lastModified"].asLong()).isEqualTo(timestamp + 1000) - 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) + } } diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt deleted file mode 100644 index 9986c8e0..00000000 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingControllerIntegrationTest.kt +++ /dev/null @@ -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) - } -} diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingServiceCircuitBreakerTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingServiceCircuitBreakerTest.kt deleted file mode 100644 index b38fb516..00000000 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/service/PingServiceCircuitBreakerTest.kt +++ /dev/null @@ -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) } - } -} diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt index 192252bb..4873e7af 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt @@ -1,5 +1,6 @@ package at.mocode.ping.test +import at.mocode.ping.infrastructure.PingProperties import at.mocode.ping.infrastructure.web.PingController import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.context.annotation.ComponentScan @@ -21,6 +22,6 @@ import org.springframework.context.annotation.Import @ComponentScan( basePackages = ["at.mocode.infrastructure.security"] ) -@Import(PingController::class) +@Import(PingController::class, PingProperties::class) @EnableAspectJAutoProxy(proxyTargetClass = true) // Erzwingt CGLIB Proxies für Controller class TestPingServiceApplication