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); + } +}