feat(notification-service): adapt to Go backend /api/v1/rooms/high-co2 endpoint
Replace SensorReading model with HighCo2Room matching the Go API response
{room, is_high, co2}. Update AirQualityService to call /api/v1/rooms/high-co2.
Simplify scheduler: backend already filters alertable rooms, recovery detected
by absence from response. Adapt Telegram message and all unit tests (20 passing).
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
package ch.hesso.pi.notification.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public record HighCo2Room(
|
||||||
|
String room,
|
||||||
|
@JsonProperty("is_high") boolean isHigh,
|
||||||
|
int co2
|
||||||
|
) {}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package ch.hesso.pi.notification.model;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
|
|
||||||
public record SensorReading(
|
|
||||||
String roomId,
|
|
||||||
String roomName,
|
|
||||||
int co2,
|
|
||||||
double temperature,
|
|
||||||
int humidity,
|
|
||||||
String windowState,
|
|
||||||
Instant timestamp
|
|
||||||
) {}
|
|
||||||
@@ -2,7 +2,7 @@ package ch.hesso.pi.notification.scheduler;
|
|||||||
|
|
||||||
import ch.hesso.pi.notification.config.NotificationProperties;
|
import ch.hesso.pi.notification.config.NotificationProperties;
|
||||||
import ch.hesso.pi.notification.model.Co2Level;
|
import ch.hesso.pi.notification.model.Co2Level;
|
||||||
import ch.hesso.pi.notification.model.SensorReading;
|
import ch.hesso.pi.notification.model.HighCo2Room;
|
||||||
import ch.hesso.pi.notification.service.AirQualityService;
|
import ch.hesso.pi.notification.service.AirQualityService;
|
||||||
import ch.hesso.pi.notification.service.TelegramNotificationService;
|
import ch.hesso.pi.notification.service.TelegramNotificationService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -10,7 +10,6 @@ import org.slf4j.LoggerFactory;
|
|||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -20,17 +19,16 @@ public class AirQualityScheduler {
|
|||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(AirQualityScheduler.class);
|
private static final Logger log = LoggerFactory.getLogger(AirQualityScheduler.class);
|
||||||
|
|
||||||
private static final List<SensorReading> MOCK_READINGS = List.of(
|
private static final List<HighCo2Room> MOCK_ROOMS = List.of(
|
||||||
new SensorReading("A1", "Salle A1", 2150, 23.5, 55, "closed", Instant.now()),
|
new HighCo2Room("A1", true, 2150),
|
||||||
new SensorReading("A2", "Salle A2", 1520, 22.0, 48, "open", Instant.now()),
|
new HighCo2Room("A2", true, 1520)
|
||||||
new SensorReading("B1", "Salle B1", 780, 21.0, 42, "closed", Instant.now())
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private final AirQualityService airQualityService;
|
private final AirQualityService airQualityService;
|
||||||
private final TelegramNotificationService telegramService;
|
private final TelegramNotificationService telegramService;
|
||||||
private final NotificationProperties props;
|
private final NotificationProperties props;
|
||||||
|
|
||||||
// last alerted level per room — null means no alert is active for that room
|
// last alerted level per room — cleared on recovery
|
||||||
private final Map<String, Co2Level> lastAlertedLevel = new ConcurrentHashMap<>();
|
private final Map<String, Co2Level> lastAlertedLevel = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
public AirQualityScheduler(AirQualityService airQualityService,
|
public AirQualityScheduler(AirQualityService airQualityService,
|
||||||
@@ -45,32 +43,37 @@ public class AirQualityScheduler {
|
|||||||
initialDelayString = "5000")
|
initialDelayString = "5000")
|
||||||
public void checkAirQuality() {
|
public void checkAirQuality() {
|
||||||
boolean mockMode = props.getAirQuality().isMockMode();
|
boolean mockMode = props.getAirQuality().isMockMode();
|
||||||
log.debug("Polling air quality data… (mock={})", mockMode);
|
log.debug("Polling high-CO₂ rooms… (mock={})", mockMode);
|
||||||
|
|
||||||
List<SensorReading> readings = mockMode
|
List<HighCo2Room> rooms = mockMode
|
||||||
? MOCK_READINGS
|
? MOCK_ROOMS
|
||||||
: airQualityService.fetchLatestReadings();
|
: airQualityService.fetchHighCo2Rooms();
|
||||||
|
|
||||||
if (readings.isEmpty()) {
|
// rooms not in the response have recovered — clear their deduplication state
|
||||||
log.warn("No readings returned — skipping this cycle");
|
if (!mockMode) {
|
||||||
|
var activeRooms = rooms.stream().map(HighCo2Room::room).collect(java.util.stream.Collectors.toSet());
|
||||||
|
lastAlertedLevel.keySet().removeIf(roomId -> {
|
||||||
|
if (!activeRooms.contains(roomId)) {
|
||||||
|
log.info("Room {} recovered — resetting alert state", roomId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rooms.isEmpty()) {
|
||||||
|
log.debug("No high-CO₂ rooms — skipping this cycle");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (SensorReading reading : readings) {
|
for (HighCo2Room room : rooms) {
|
||||||
Co2Level level = airQualityService.resolveLevel(reading.co2());
|
Co2Level level = airQualityService.resolveLevel(room.co2());
|
||||||
|
Co2Level previous = lastAlertedLevel.get(room.room());
|
||||||
if (airQualityService.isAlertable(level)) {
|
|
||||||
Co2Level previous = lastAlertedLevel.get(reading.roomId());
|
|
||||||
if (level != previous) {
|
if (level != previous) {
|
||||||
log.info("Alert level changed: room={} {} -> {}", reading.roomId(), previous, level);
|
log.info("Alert level changed: room={} {} -> {} ({} ppm)",
|
||||||
telegramService.sendAlert(reading, level);
|
room.room(), previous, level, room.co2());
|
||||||
lastAlertedLevel.put(reading.roomId(), level);
|
telegramService.sendAlert(room, level);
|
||||||
}
|
lastAlertedLevel.put(room.room(), level);
|
||||||
} else {
|
|
||||||
// room recovered — reset so next alert triggers a new notification
|
|
||||||
if (lastAlertedLevel.remove(reading.roomId()) != null) {
|
|
||||||
log.info("Room {} recovered to {}", reading.roomId(), level);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service;
|
|||||||
|
|
||||||
import ch.hesso.pi.notification.config.NotificationProperties;
|
import ch.hesso.pi.notification.config.NotificationProperties;
|
||||||
import ch.hesso.pi.notification.model.Co2Level;
|
import ch.hesso.pi.notification.model.Co2Level;
|
||||||
import ch.hesso.pi.notification.model.SensorReading;
|
import ch.hesso.pi.notification.model.HighCo2Room;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.core.ParameterizedTypeReference;
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
@@ -30,11 +30,11 @@ public class AirQualityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the latest sensor reading for every room from the backend API.
|
* Fetches rooms whose CO₂ exceeds 1400 ppm from GET /api/v1/rooms/high-co2.
|
||||||
* Returns an empty list on error so the scheduler can continue safely.
|
* Returns an empty list on error so the scheduler can continue safely.
|
||||||
*/
|
*/
|
||||||
public List<SensorReading> fetchLatestReadings() {
|
public List<HighCo2Room> fetchHighCo2Rooms() {
|
||||||
String url = props.getAirQuality().getApiUrl() + "/sensors/latest";
|
String url = props.getAirQuality().getApiUrl() + "/api/v1/rooms/high-co2";
|
||||||
String username = props.getAirQuality().getApiUsername();
|
String username = props.getAirQuality().getApiUsername();
|
||||||
String password = props.getAirQuality().getApiPassword();
|
String password = props.getAirQuality().getApiPassword();
|
||||||
|
|
||||||
@@ -42,21 +42,21 @@ public class AirQualityService {
|
|||||||
var request = restClient.get().uri(url);
|
var request = restClient.get().uri(url);
|
||||||
|
|
||||||
if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
|
if (StringUtils.hasText(username) && StringUtils.hasText(password)) {
|
||||||
String encoded = Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
|
String encoded = Base64.getEncoder()
|
||||||
|
.encodeToString((username + ":" + password).getBytes(StandardCharsets.UTF_8));
|
||||||
request = request.header(HttpHeaders.AUTHORIZATION, "Basic " + encoded);
|
request = request.header(HttpHeaders.AUTHORIZATION, "Basic " + encoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<SensorReading> readings = request
|
List<HighCo2Room> rooms = request
|
||||||
.retrieve()
|
.retrieve()
|
||||||
.body(new ParameterizedTypeReference<>() {});
|
.body(new ParameterizedTypeReference<>() {});
|
||||||
return readings != null ? readings : List.of();
|
return rooms != null ? rooms : List.of();
|
||||||
} catch (RestClientException e) {
|
} catch (RestClientException e) {
|
||||||
log.error("Failed to fetch latest readings from {}: {}", url, e.getMessage());
|
log.error("Failed to fetch high-CO₂ rooms from {}: {}", url, e.getMessage());
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves the CO₂ level enum from a raw ppm value using configured thresholds. */
|
|
||||||
public Co2Level resolveLevel(int ppm) {
|
public Co2Level resolveLevel(int ppm) {
|
||||||
NotificationProperties.Thresholds t = props.getAirQuality().getThresholds();
|
NotificationProperties.Thresholds t = props.getAirQuality().getThresholds();
|
||||||
if (ppm < 800) return Co2Level.EXCELLENT;
|
if (ppm < 800) return Co2Level.EXCELLENT;
|
||||||
@@ -65,19 +65,4 @@ public class AirQualityService {
|
|||||||
if (ppm < t.getCritical()) return Co2Level.POOR;
|
if (ppm < t.getCritical()) return Co2Level.POOR;
|
||||||
return Co2Level.CRITICAL;
|
return Co2Level.CRITICAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if the given level meets or exceeds the configured alert threshold. */
|
|
||||||
public boolean isAlertable(Co2Level level) {
|
|
||||||
Co2Level floor = parseAlertFromLevel(props.getAirQuality().getAlertFromLevel());
|
|
||||||
return level.ordinal() >= floor.ordinal();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Co2Level parseAlertFromLevel(String value) {
|
|
||||||
try {
|
|
||||||
return Co2Level.valueOf(value.toUpperCase());
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
log.warn("Unknown alert-from-level '{}', defaulting to POOR", value);
|
|
||||||
return Co2Level.POOR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service;
|
|||||||
|
|
||||||
import ch.hesso.pi.notification.config.NotificationProperties;
|
import ch.hesso.pi.notification.config.NotificationProperties;
|
||||||
import ch.hesso.pi.notification.model.Co2Level;
|
import ch.hesso.pi.notification.model.Co2Level;
|
||||||
import ch.hesso.pi.notification.model.SensorReading;
|
import ch.hesso.pi.notification.model.HighCo2Room;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -11,16 +11,12 @@ import org.springframework.util.StringUtils;
|
|||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TelegramNotificationService {
|
public class TelegramNotificationService {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(TelegramNotificationService.class);
|
private static final Logger log = LoggerFactory.getLogger(TelegramNotificationService.class);
|
||||||
private static final DateTimeFormatter TIME_FMT =
|
|
||||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
|
|
||||||
|
|
||||||
private final RestClient restClient;
|
private final RestClient restClient;
|
||||||
private final NotificationProperties props;
|
private final NotificationProperties props;
|
||||||
@@ -30,17 +26,17 @@ public class TelegramNotificationService {
|
|||||||
this.props = props;
|
this.props = props;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendAlert(SensorReading reading, Co2Level level) {
|
public void sendAlert(HighCo2Room room, Co2Level level) {
|
||||||
String token = props.getTelegram().getBotToken();
|
String token = props.getTelegram().getBotToken();
|
||||||
String chatId = props.getTelegram().getChatId();
|
String chatId = props.getTelegram().getChatId();
|
||||||
|
|
||||||
if (!StringUtils.hasText(token) || !StringUtils.hasText(chatId)) {
|
if (!StringUtils.hasText(token) || !StringUtils.hasText(chatId)) {
|
||||||
log.warn("Telegram credentials not configured — skipping alert for room {}", reading.roomId());
|
log.warn("Telegram credentials not configured — skipping alert for room {}", room.room());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String url = "https://api.telegram.org/bot" + token + "/sendMessage";
|
String url = "https://api.telegram.org/bot" + token + "/sendMessage";
|
||||||
String text = buildMessage(reading, level);
|
String text = buildMessage(room, level);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
restClient.post()
|
restClient.post()
|
||||||
@@ -50,32 +46,20 @@ public class TelegramNotificationService {
|
|||||||
.retrieve()
|
.retrieve()
|
||||||
.toBodilessEntity();
|
.toBodilessEntity();
|
||||||
|
|
||||||
log.info("Telegram alert sent for room {} — {} ({} ppm)", reading.roomId(), level.getLabel(), reading.co2());
|
log.info("Telegram alert sent for room {} — {} ({} ppm)", room.room(), level.getLabel(), room.co2());
|
||||||
} catch (RestClientException e) {
|
} catch (RestClientException e) {
|
||||||
log.error("Failed to send Telegram alert for room {}: {}", reading.roomId(), e.getMessage());
|
log.error("Failed to send Telegram alert for room {}: {}", room.room(), e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String buildMessage(SensorReading reading, Co2Level level) {
|
private String buildMessage(HighCo2Room room, Co2Level level) {
|
||||||
String timestamp = reading.timestamp() != null
|
|
||||||
? TIME_FMT.format(reading.timestamp())
|
|
||||||
: "—";
|
|
||||||
|
|
||||||
return String.format(
|
return String.format(
|
||||||
"<b>%s — Air Quality Alert</b>\n\n" +
|
"<b>%s — Air Quality Alert</b>\n\n" +
|
||||||
"<b>Room:</b> %s\n" +
|
"<b>Room:</b> %s\n" +
|
||||||
"<b>CO₂:</b> %d ppm\n" +
|
"<b>CO₂:</b> %d ppm",
|
||||||
"<b>Temperature:</b> %.1f °C\n" +
|
|
||||||
"<b>Humidity:</b> %d %%\n" +
|
|
||||||
"<b>Windows:</b> %s\n" +
|
|
||||||
"<b>Time:</b> %s",
|
|
||||||
level.getLabel(),
|
level.getLabel(),
|
||||||
reading.roomName(),
|
room.room(),
|
||||||
reading.co2(),
|
room.co2()
|
||||||
reading.temperature(),
|
|
||||||
reading.humidity(),
|
|
||||||
reading.windowState(),
|
|
||||||
timestamp
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service;
|
|||||||
|
|
||||||
import ch.hesso.pi.notification.config.NotificationProperties;
|
import ch.hesso.pi.notification.config.NotificationProperties;
|
||||||
import ch.hesso.pi.notification.model.Co2Level;
|
import ch.hesso.pi.notification.model.Co2Level;
|
||||||
import ch.hesso.pi.notification.model.SensorReading;
|
import ch.hesso.pi.notification.model.HighCo2Room;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -14,7 +14,6 @@ import org.springframework.core.ParameterizedTypeReference;
|
|||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -38,7 +37,7 @@ class AirQualityServiceTest {
|
|||||||
service = new AirQualityService(restClient, props);
|
service = new AirQualityService(restClient, props);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── resolveLevel ─────────────────────────────────────────────────────────
|
// ── resolveLevel ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} ppm -> {1}")
|
@ParameterizedTest(name = "{0} ppm -> {1}")
|
||||||
@CsvSource({
|
@CsvSource({
|
||||||
@@ -57,70 +56,61 @@ class AirQualityServiceTest {
|
|||||||
assertThat(service.resolveLevel(ppm)).isEqualTo(expected);
|
assertThat(service.resolveLevel(ppm)).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── isAlertable ───────────────────────────────────────────────────────────
|
// ── fetchHighCo2Rooms ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@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")
|
@SuppressWarnings("unchecked")
|
||||||
@Test
|
@Test
|
||||||
void fetchLatestReadings_returnsReadingsOnSuccess() {
|
void fetchHighCo2Rooms_returnsRoomsOnSuccess() {
|
||||||
List<SensorReading> expected = List.of(
|
List<HighCo2Room> expected = List.of(
|
||||||
new SensorReading("A1", "Salle A1", 1500, 22.0, 50, "closed", Instant.now())
|
new HighCo2Room("A2", true, 1718),
|
||||||
|
new HighCo2Room("A3", true, 1653)
|
||||||
);
|
);
|
||||||
|
|
||||||
doReturn(uriSpec).when(restClient).get();
|
doReturn(uriSpec).when(restClient).get();
|
||||||
doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest");
|
doReturn(uriSpec).when(uriSpec).uri("http://test-api/api/v1/rooms/high-co2");
|
||||||
doReturn(responseSpec).when(uriSpec).retrieve();
|
doReturn(responseSpec).when(uriSpec).retrieve();
|
||||||
doReturn(expected).when(responseSpec).body(any(ParameterizedTypeReference.class));
|
doReturn(expected).when(responseSpec).body(any(ParameterizedTypeReference.class));
|
||||||
|
|
||||||
assertThat(service.fetchLatestReadings()).isEqualTo(expected);
|
assertThat(service.fetchHighCo2Rooms()).isEqualTo(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@Test
|
@Test
|
||||||
void fetchLatestReadings_returnsEmptyListOnApiError() {
|
void fetchHighCo2Rooms_returnsEmptyListOnApiError() {
|
||||||
doReturn(uriSpec).when(restClient).get();
|
doReturn(uriSpec).when(restClient).get();
|
||||||
doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest");
|
doReturn(uriSpec).when(uriSpec).uri("http://test-api/api/v1/rooms/high-co2");
|
||||||
doReturn(responseSpec).when(uriSpec).retrieve();
|
doReturn(responseSpec).when(uriSpec).retrieve();
|
||||||
doThrow(new RestClientException("connection refused")).when(responseSpec).body(any(ParameterizedTypeReference.class));
|
doThrow(new RestClientException("connection refused"))
|
||||||
|
.when(responseSpec).body(any(ParameterizedTypeReference.class));
|
||||||
|
|
||||||
assertThat(service.fetchLatestReadings()).isEmpty();
|
assertThat(service.fetchHighCo2Rooms()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@Test
|
@Test
|
||||||
void fetchLatestReadings_returnsEmptyListWhenApiReturnsNull() {
|
void fetchHighCo2Rooms_returnsEmptyListWhenApiReturnsNull() {
|
||||||
doReturn(uriSpec).when(restClient).get();
|
doReturn(uriSpec).when(restClient).get();
|
||||||
doReturn(uriSpec).when(uriSpec).uri("http://test-api/sensors/latest");
|
doReturn(uriSpec).when(uriSpec).uri("http://test-api/api/v1/rooms/high-co2");
|
||||||
doReturn(responseSpec).when(uriSpec).retrieve();
|
doReturn(responseSpec).when(uriSpec).retrieve();
|
||||||
doReturn(null).when(responseSpec).body(any(ParameterizedTypeReference.class));
|
doReturn(null).when(responseSpec).body(any(ParameterizedTypeReference.class));
|
||||||
|
|
||||||
assertThat(service.fetchLatestReadings()).isEmpty();
|
assertThat(service.fetchHighCo2Rooms()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
@Test
|
||||||
|
void fetchHighCo2Rooms_sendsBasicAuthWhenCredentialsConfigured() {
|
||||||
|
props.getAirQuality().setApiUsername("user");
|
||||||
|
props.getAirQuality().setApiPassword("pass");
|
||||||
|
|
||||||
|
doReturn(uriSpec).when(restClient).get();
|
||||||
|
doReturn(uriSpec).when(uriSpec).uri(anyString());
|
||||||
|
doReturn(uriSpec).when(uriSpec).header(anyString(), anyString());
|
||||||
|
doReturn(responseSpec).when(uriSpec).retrieve();
|
||||||
|
doReturn(List.of()).when(responseSpec).body(any(ParameterizedTypeReference.class));
|
||||||
|
|
||||||
|
service.fetchHighCo2Rooms();
|
||||||
|
|
||||||
|
verify(uriSpec).header(eq("Authorization"), any(String.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package ch.hesso.pi.notification.service;
|
|||||||
|
|
||||||
import ch.hesso.pi.notification.config.NotificationProperties;
|
import ch.hesso.pi.notification.config.NotificationProperties;
|
||||||
import ch.hesso.pi.notification.model.Co2Level;
|
import ch.hesso.pi.notification.model.Co2Level;
|
||||||
import ch.hesso.pi.notification.model.SensorReading;
|
import ch.hesso.pi.notification.model.HighCo2Room;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
@@ -10,8 +10,6 @@ import org.springframework.http.MediaType;
|
|||||||
import org.springframework.test.web.client.MockRestServiceServer;
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
import org.springframework.web.client.RestClient;
|
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.match.MockRestRequestMatchers.*;
|
||||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
|
||||||
|
|
||||||
@@ -21,9 +19,7 @@ class TelegramNotificationServiceTest {
|
|||||||
private TelegramNotificationService service;
|
private TelegramNotificationService service;
|
||||||
private NotificationProperties props;
|
private NotificationProperties props;
|
||||||
|
|
||||||
private static final SensorReading READING = new SensorReading(
|
private static final HighCo2Room ROOM = new HighCo2Room("A2", true, 1718);
|
||||||
"A1", "Salle A1", 1500, 22.5, 55, "closed", Instant.parse("2026-01-01T10:00:00Z")
|
|
||||||
);
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
@@ -44,7 +40,7 @@ class TelegramNotificationServiceTest {
|
|||||||
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
|
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
|
||||||
.andRespond(withSuccess());
|
.andRespond(withSuccess());
|
||||||
|
|
||||||
service.sendAlert(READING, Co2Level.POOR);
|
service.sendAlert(ROOM, Co2Level.POOR);
|
||||||
|
|
||||||
server.verify();
|
server.verify();
|
||||||
}
|
}
|
||||||
@@ -54,11 +50,11 @@ class TelegramNotificationServiceTest {
|
|||||||
server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage"))
|
server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage"))
|
||||||
.andExpect(jsonPath("$.chat_id").value("123456789"))
|
.andExpect(jsonPath("$.chat_id").value("123456789"))
|
||||||
.andExpect(jsonPath("$.parse_mode").value("HTML"))
|
.andExpect(jsonPath("$.parse_mode").value("HTML"))
|
||||||
.andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("Salle A1")))
|
.andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("A2")))
|
||||||
.andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("1500")))
|
.andExpect(jsonPath("$.text").value(org.hamcrest.Matchers.containsString("1718")))
|
||||||
.andRespond(withSuccess());
|
.andRespond(withSuccess());
|
||||||
|
|
||||||
service.sendAlert(READING, Co2Level.POOR);
|
service.sendAlert(ROOM, Co2Level.POOR);
|
||||||
|
|
||||||
server.verify();
|
server.verify();
|
||||||
}
|
}
|
||||||
@@ -67,18 +63,18 @@ class TelegramNotificationServiceTest {
|
|||||||
void sendAlert_skipsWhenTokenMissing() {
|
void sendAlert_skipsWhenTokenMissing() {
|
||||||
props.getTelegram().setBotToken("");
|
props.getTelegram().setBotToken("");
|
||||||
|
|
||||||
service.sendAlert(READING, Co2Level.POOR);
|
service.sendAlert(ROOM, Co2Level.POOR);
|
||||||
|
|
||||||
server.verify(); // expects no requests
|
server.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void sendAlert_skipsWhenChatIdMissing() {
|
void sendAlert_skipsWhenChatIdMissing() {
|
||||||
props.getTelegram().setChatId("");
|
props.getTelegram().setChatId("");
|
||||||
|
|
||||||
service.sendAlert(READING, Co2Level.POOR);
|
service.sendAlert(ROOM, Co2Level.POOR);
|
||||||
|
|
||||||
server.verify(); // expects no requests
|
server.verify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -86,7 +82,6 @@ class TelegramNotificationServiceTest {
|
|||||||
server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage"))
|
server.expect(requestTo("https://api.telegram.org/bottest-token/sendMessage"))
|
||||||
.andRespond(withServerError());
|
.andRespond(withServerError());
|
||||||
|
|
||||||
// must not propagate the exception
|
service.sendAlert(ROOM, Co2Level.CRITICAL);
|
||||||
service.sendAlert(READING, Co2Level.CRITICAL);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user