From 40fde77833e637831426e9ab2ca7c1b028812a09 Mon Sep 17 00:00:00 2001 From: khalil-bot Date: Thu, 28 May 2026 15:29:13 +0200 Subject: [PATCH] test(notification-service): add unit tests and clean up Co2Level Add unit tests for AirQualityService (16 cases) and TelegramNotificationService (5 cases) using MockRestServiceServer. Remove unused color fields from Co2Level enum. Add .env to .gitignore. Assisted-by: Claude:claude-sonnet-4-6 --- notification_service/.gitignore | 1 + .../hesso/pi/notification/model/Co2Level.java | 20 +-- .../service/AirQualityServiceTest.java | 126 ++++++++++++++++++ .../TelegramNotificationServiceTest.java | 92 +++++++++++++ 4 files changed, 226 insertions(+), 13 deletions(-) create mode 100644 notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java create mode 100644 notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java diff --git a/notification_service/.gitignore b/notification_service/.gitignore index 838f4a5..0cc2c46 100644 --- a/notification_service/.gitignore +++ b/notification_service/.gitignore @@ -4,3 +4,4 @@ target/ .idea/ *.iml .DS_Store +.env diff --git a/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java b/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java index 9946859..f65094a 100644 --- a/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java +++ b/notification_service/src/main/java/ch/hesso/pi/notification/model/Co2Level.java @@ -2,23 +2,17 @@ package ch.hesso.pi.notification.model; public enum Co2Level { - EXCELLENT("✅ Excellent", "#4caf50"), - GOOD ("🟢 Good", "#8bc34a"), - MODERATE ("🟡 Moderate", "#ffc107"), - POOR ("🟠 Poor", "#ff9800"), - CRITICAL ("🔴 Critical", "#f44336"); + EXCELLENT("✅ Excellent"), + GOOD ("🟢 Good"), + MODERATE ("🟡 Moderate"), + POOR ("🟠 Poor"), + CRITICAL ("🔴 Critical"); private final String label; - private final String themeColor; - Co2Level(String label, String themeColor) { + Co2Level(String label) { this.label = label; - this.themeColor = themeColor; } - public String getLabel() { return label; } - public String getThemeColor() { return themeColor; } - - /** Returns the hex color without the leading '#' (Teams themeColor format). */ - public String getThemeColorHex() { return themeColor.substring(1); } + public String getLabel() { return label; } } diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java b/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java new file mode 100644 index 0000000..6472fac --- /dev/null +++ b/notification_service/src/test/java/ch/hesso/pi/notification/service/AirQualityServiceTest.java @@ -0,0 +1,126 @@ +package ch.hesso.pi.notification.service; + +import ch.hesso.pi.notification.config.NotificationProperties; +import ch.hesso.pi.notification.model.Co2Level; +import ch.hesso.pi.notification.model.SensorReading; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.Instant; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AirQualityServiceTest { + + @Mock private RestClient restClient; + @Mock private RestClient.RequestHeadersUriSpec uriSpec; + @Mock private RestClient.ResponseSpec responseSpec; + + private AirQualityService service; + private NotificationProperties props; + + @BeforeEach + void setUp() { + props = new NotificationProperties(); + props.getAirQuality().setApiUrl("http://test-api"); + service = new AirQualityService(restClient, props); + } + + // ── resolveLevel ───────────────────────────────────────────────────────── + + @ParameterizedTest(name = "{0} ppm -> {1}") + @CsvSource({ + "0, EXCELLENT", + "799, EXCELLENT", + "800, GOOD", + "999, GOOD", + "1000, MODERATE", + "1399, MODERATE", + "1400, POOR", + "1999, POOR", + "2000, CRITICAL", + "3000, CRITICAL" + }) + void resolveLevel_mapsCorrectly(int ppm, Co2Level expected) { + assertThat(service.resolveLevel(ppm)).isEqualTo(expected); + } + + // ── isAlertable ─────────────────────────────────────────────────────────── + + @Test + void isAlertable_defaultThresholdIsPoor() { + assertThat(service.isAlertable(Co2Level.EXCELLENT)).isFalse(); + assertThat(service.isAlertable(Co2Level.GOOD)).isFalse(); + assertThat(service.isAlertable(Co2Level.MODERATE)).isFalse(); + assertThat(service.isAlertable(Co2Level.POOR)).isTrue(); + assertThat(service.isAlertable(Co2Level.CRITICAL)).isTrue(); + } + + @Test + void isAlertable_respectsConfiguredLevel() { + props.getAirQuality().setAlertFromLevel("moderate"); + + assertThat(service.isAlertable(Co2Level.GOOD)).isFalse(); + assertThat(service.isAlertable(Co2Level.MODERATE)).isTrue(); + assertThat(service.isAlertable(Co2Level.POOR)).isTrue(); + } + + @Test + void isAlertable_unknownLevelDefaultsToPoor() { + props.getAirQuality().setAlertFromLevel("invalid"); + + assertThat(service.isAlertable(Co2Level.MODERATE)).isFalse(); + assertThat(service.isAlertable(Co2Level.POOR)).isTrue(); + } + + // ── fetchLatestReadings ─────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + @Test + void fetchLatestReadings_returnsReadingsOnSuccess() { + List expected = List.of( + new SensorReading("A1", "Salle A1", 1500, 22.0, 50, "closed", Instant.now()) + ); + + doReturn(uriSpec).when(restClient).get(); + doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest"); + doReturn(responseSpec).when(uriSpec).retrieve(); + doReturn(expected).when(responseSpec).body(any(ParameterizedTypeReference.class)); + + assertThat(service.fetchLatestReadings()).isEqualTo(expected); + } + + @SuppressWarnings("unchecked") + @Test + void fetchLatestReadings_returnsEmptyListOnApiError() { + doReturn(uriSpec).when(restClient).get(); + doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest"); + doReturn(responseSpec).when(uriSpec).retrieve(); + doThrow(new RestClientException("connection refused")).when(responseSpec).body(any(ParameterizedTypeReference.class)); + + assertThat(service.fetchLatestReadings()).isEmpty(); + } + + @SuppressWarnings("unchecked") + @Test + void fetchLatestReadings_returnsEmptyListWhenApiReturnsNull() { + doReturn(uriSpec).when(restClient).get(); + doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest"); + doReturn(responseSpec).when(uriSpec).retrieve(); + doReturn(null).when(responseSpec).body(any(ParameterizedTypeReference.class)); + + assertThat(service.fetchLatestReadings()).isEmpty(); + } +} diff --git a/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java b/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java new file mode 100644 index 0000000..af90416 --- /dev/null +++ b/notification_service/src/test/java/ch/hesso/pi/notification/service/TelegramNotificationServiceTest.java @@ -0,0 +1,92 @@ +package ch.hesso.pi.notification.service; + +import ch.hesso.pi.notification.config.NotificationProperties; +import ch.hesso.pi.notification.model.Co2Level; +import ch.hesso.pi.notification.model.SensorReading; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.time.Instant; + +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +class TelegramNotificationServiceTest { + + private MockRestServiceServer server; + private TelegramNotificationService service; + private NotificationProperties props; + + private static final SensorReading READING = new SensorReading( + "A1", "Salle A1", 1500, 22.5, 55, "closed", Instant.parse("2026-01-01T10:00:00Z") + ); + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + + props = new NotificationProperties(); + props.getTelegram().setBotToken("test-token"); + props.getTelegram().setChatId("123456789"); + + service = new TelegramNotificationService(builder.build(), props); + } + + @Test + void sendAlert_postsToCorrectTelegramUrl() { + server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage")) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andRespond(withSuccess()); + + service.sendAlert(READING, Co2Level.POOR); + + server.verify(); + } + + @Test + void sendAlert_messageContainsRoomAndCo2() { + server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage")) + .andExpect(jsonPath("$.chat_id").value("123456789")) + .andExpect(jsonPath("$.parse_mode").value("HTML")) + .andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("Salle A1"))) + .andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("1500"))) + .andRespond(withSuccess()); + + service.sendAlert(READING, Co2Level.POOR); + + server.verify(); + } + + @Test + void sendAlert_skipsWhenTokenMissing() { + props.getTelegram().setBotToken(""); + + service.sendAlert(READING, Co2Level.POOR); + + server.verify(); // expects no requests + } + + @Test + void sendAlert_skipsWhenChatIdMissing() { + props.getTelegram().setChatId(""); + + service.sendAlert(READING, Co2Level.POOR); + + server.verify(); // expects no requests + } + + @Test + void sendAlert_doesNotThrowOnApiError() { + server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage")) + .andRespond(withServerError()); + + // must not propagate the exception + service.sendAlert(READING, Co2Level.CRITICAL); + } +}