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
This commit is contained in:
khalil-bot
2026-05-28 15:29:13 +02:00
parent 35a97dd5d8
commit 40fde77833
4 changed files with 226 additions and 13 deletions

View File

@@ -4,3 +4,4 @@ target/
.idea/
*.iml
.DS_Store
.env

View File

@@ -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; }
}

View File

@@ -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<SensorReading> 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();
}
}

View File

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