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:
1
notification_service/.gitignore
vendored
1
notification_service/.gitignore
vendored
@@ -4,3 +4,4 @@ target/
|
||||
.idea/
|
||||
*.iml
|
||||
.DS_Store
|
||||
.env
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user