diff --git a/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java b/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java index 3cdd0c6..6f47bc5 100644 --- a/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java +++ b/backend/src/main/java/backend/fullstack/temperature/api/TemperatureReadingController.java @@ -23,6 +23,8 @@ import backend.fullstack.config.JwtPrincipal; import backend.fullstack.temperature.api.dto.TemperatureReadingRequest; import backend.fullstack.temperature.api.dto.TemperatureReadingResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse; import backend.fullstack.temperature.application.TemperatureReadingService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -91,6 +93,32 @@ public ResponseEntity>> getReadings return ResponseEntity.ok(ApiResponse.success("Readings retrieved", readings)); } + @GetMapping("/readings/stats") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + @Operation(summary = "Get reading statistics for charts") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Reading statistics retrieved"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden") + }) + public ResponseEntity> getReadingStats( + @AuthenticationPrincipal JwtPrincipal principal, + @RequestParam(required = false) List unitIds, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to, + @RequestParam(defaultValue = "DAY") TemperatureReadingStatsGroupBy groupBy + ) { + TemperatureReadingStatsResponse response = readingService.getReadingStats( + principal.organizationId(), + unitIds, + from, + to, + groupBy + ); + + return ResponseEntity.ok(ApiResponse.success("Reading statistics retrieved", response)); + } + @PostMapping("/units/{unitId}/readings") @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Register temperature reading") diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingDeviationResponse.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingDeviationResponse.java new file mode 100644 index 0000000..a86737b --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingDeviationResponse.java @@ -0,0 +1,13 @@ +package backend.fullstack.temperature.api.dto; + +import java.time.LocalDateTime; + +public record TemperatureReadingDeviationResponse( + Long id, + Long unitId, + String unitName, + Double temperature, + Double threshold, + LocalDateTime timestamp +) { +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsGroupBy.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsGroupBy.java new file mode 100644 index 0000000..3120ec7 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsGroupBy.java @@ -0,0 +1,7 @@ +package backend.fullstack.temperature.api.dto; + +public enum TemperatureReadingStatsGroupBy { + HOUR, + DAY, + WEEK +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsPointResponse.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsPointResponse.java new file mode 100644 index 0000000..493d1df --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsPointResponse.java @@ -0,0 +1,10 @@ +package backend.fullstack.temperature.api.dto; + +import java.time.LocalDateTime; + +public record TemperatureReadingStatsPointResponse( + LocalDateTime timestamp, + Double avgTemperature, + boolean isDeviation +) { +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsResponse.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsResponse.java new file mode 100644 index 0000000..1db93e1 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsResponse.java @@ -0,0 +1,9 @@ +package backend.fullstack.temperature.api.dto; + +import java.util.List; + +public record TemperatureReadingStatsResponse( + List series, + List deviations +) { +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsSeriesResponse.java b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsSeriesResponse.java new file mode 100644 index 0000000..f3f6bcf --- /dev/null +++ b/backend/src/main/java/backend/fullstack/temperature/api/dto/TemperatureReadingStatsSeriesResponse.java @@ -0,0 +1,10 @@ +package backend.fullstack.temperature.api.dto; + +import java.util.List; + +public record TemperatureReadingStatsSeriesResponse( + Long unitId, + String unitName, + List dataPoints +) { +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java b/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java index ce7ecc0..6616e8c 100644 --- a/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java +++ b/backend/src/main/java/backend/fullstack/temperature/application/TemperatureReadingService.java @@ -1,7 +1,11 @@ package backend.fullstack.temperature.application; import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -12,9 +16,14 @@ import backend.fullstack.exceptions.ResourceNotFoundException; import backend.fullstack.exceptions.UnitInactiveException; import backend.fullstack.exceptions.UnitNotFoundException; +import backend.fullstack.temperature.api.dto.TemperatureReadingDeviationResponse; import backend.fullstack.temperature.api.dto.TemperatureReadingMapper; import backend.fullstack.temperature.api.dto.TemperatureReadingRequest; import backend.fullstack.temperature.api.dto.TemperatureReadingResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsPointResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsSeriesResponse; import backend.fullstack.temperature.domain.TemperatureReading; import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository; import backend.fullstack.units.domain.TemperatureUnit; @@ -69,6 +78,36 @@ public Page listReadings( .map(readingMapper::toResponse); } + @Transactional(readOnly = true) + public TemperatureReadingStatsResponse getReadingStats( + Long organizationId, + List unitIds, + LocalDateTime from, + LocalDateTime to, + TemperatureReadingStatsGroupBy groupBy + ) { + List distinctUnitIds = sanitizeUnitIds(unitIds); + List readings = distinctUnitIds == null + ? readingRepository.findForStatsByOrganizationAndRange(organizationId, from, to) + : readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(organizationId, distinctUnitIds, from, to); + + Map> readingsByUnit = readings.stream() + .collect(Collectors.groupingBy(reading -> reading.getUnit().getId())); + + List series = readingsByUnit.values().stream() + .sorted(Comparator.comparing(unitReadings -> unitReadings.get(0).getUnit().getName(), String.CASE_INSENSITIVE_ORDER)) + .map(unitReadings -> toSeries(unitReadings, groupBy)) + .toList(); + + List deviations = readings.stream() + .filter(TemperatureReading::isDeviation) + .sorted(Comparator.comparing(TemperatureReading::getRecordedAt).reversed()) + .map(this::toDeviationResponse) + .toList(); + + return new TemperatureReadingStatsResponse(series, deviations); + } + public TemperatureReadingResponse createReading( JwtPrincipal principal, Long unitId, @@ -115,4 +154,90 @@ private User findScopedUser(Long userId, Long organizationId) { return user; } + + private List sanitizeUnitIds(List unitIds) { + if (unitIds == null || unitIds.isEmpty()) { + return null; + } + + List sanitized = unitIds.stream() + .filter(java.util.Objects::nonNull) + .distinct() + .toList(); + + return sanitized.isEmpty() ? null : sanitized; + } + + private TemperatureReadingStatsSeriesResponse toSeries( + List unitReadings, + TemperatureReadingStatsGroupBy groupBy + ) { + TemperatureReading firstReading = unitReadings.get(0); + Map> groupedByTime = unitReadings.stream() + .collect(Collectors.groupingBy( + reading -> truncateTimestamp(reading.getRecordedAt(), groupBy), + java.util.TreeMap::new, + Collectors.toList() + )); + + List dataPoints = groupedByTime.entrySet().stream() + .map(entry -> { + double avg = entry.getValue().stream() + .map(TemperatureReading::getTemperature) + .filter(java.util.Objects::nonNull) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0d); + + boolean hasDeviation = entry.getValue().stream().anyMatch(TemperatureReading::isDeviation); + return new TemperatureReadingStatsPointResponse(entry.getKey(), avg, hasDeviation); + }) + .toList(); + + return new TemperatureReadingStatsSeriesResponse( + firstReading.getUnit().getId(), + firstReading.getUnit().getName(), + dataPoints + ); + } + + private TemperatureReadingDeviationResponse toDeviationResponse(TemperatureReading reading) { + return new TemperatureReadingDeviationResponse( + reading.getId(), + reading.getUnit().getId(), + reading.getUnit().getName(), + reading.getTemperature(), + resolveBreachedThreshold(reading), + reading.getRecordedAt() + ); + } + + private Double resolveBreachedThreshold(TemperatureReading reading) { + if (reading.getUnit() == null || reading.getTemperature() == null) { + return null; + } + + Double minThreshold = reading.getUnit().getMinThreshold(); + Double maxThreshold = reading.getUnit().getMaxThreshold(); + + if (minThreshold != null && reading.getTemperature() < minThreshold) { + return minThreshold; + } + + if (maxThreshold != null && reading.getTemperature() > maxThreshold) { + return maxThreshold; + } + + return null; + } + + private LocalDateTime truncateTimestamp(LocalDateTime timestamp, TemperatureReadingStatsGroupBy groupBy) { + return switch (groupBy) { + case HOUR -> timestamp.withMinute(0).withSecond(0).withNano(0); + case DAY -> timestamp.toLocalDate().atStartOfDay(); + case WEEK -> timestamp.toLocalDate() + .with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + .atStartOfDay(); + }; + } } \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java b/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java index b9b77fa..2cf7647 100644 --- a/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java +++ b/backend/src/main/java/backend/fullstack/temperature/infrastructure/TemperatureReadingRepository.java @@ -55,6 +55,36 @@ List findByOrganizationAndUnitAndRecordedAtBetween( @Param("to") LocalDateTime to ); + @Query(""" + SELECT r + FROM TemperatureReading r + WHERE r.organization.id = :organizationId + AND (:from IS NULL OR r.recordedAt >= :from) + AND (:to IS NULL OR r.recordedAt <= :to) + ORDER BY r.unit.name ASC, r.recordedAt ASC + """) + List findForStatsByOrganizationAndRange( + @Param("organizationId") Long organizationId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + + @Query(""" + SELECT r + FROM TemperatureReading r + WHERE r.organization.id = :organizationId + AND r.unit.id IN :unitIds + AND (:from IS NULL OR r.recordedAt >= :from) + AND (:to IS NULL OR r.recordedAt <= :to) + ORDER BY r.unit.name ASC, r.recordedAt ASC + """) + List findForStatsByOrganizationAndUnitIdsAndRange( + @Param("organizationId") Long organizationId, + @Param("unitIds") List unitIds, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to + ); + long countByOrganization_IdAndIsDeviationTrue(Long organizationId); long countByOrganization_IdAndIsDeviationTrueAndRecordedAtAfter(Long organizationId, LocalDateTime since); diff --git a/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java b/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java index eb1bc78..4409637 100644 --- a/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java +++ b/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerSecurityAnnotationsTest.java @@ -20,6 +20,11 @@ void globalReadingsRequireAdminOrManager() { assertPreAuthorize("getReadings", "hasAnyRole('ADMIN','MANAGER')"); } + @Test + void readingStatsRequireAdminOrManager() { + assertPreAuthorize("getReadingStats", "hasAnyRole('ADMIN','MANAGER')"); + } + @Test void createReadingAllowsAllOperationalRoles() { assertPreAuthorize("createReading", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')"); diff --git a/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerTest.java b/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerTest.java new file mode 100644 index 0000000..4cffa8c --- /dev/null +++ b/backend/src/test/java/backend/fullstack/temperature/api/TemperatureReadingControllerTest.java @@ -0,0 +1,159 @@ +package backend.fullstack.temperature.api; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import org.mockito.Mockito; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.temperature.api.dto.TemperatureReadingDeviationResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsPointResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsSeriesResponse; +import backend.fullstack.temperature.application.TemperatureReadingService; +import backend.fullstack.user.role.Role; + +class TemperatureReadingControllerTest { + + private static final JwtPrincipal PRINCIPAL = new JwtPrincipal( + 7L, + "manager@everest.no", + Role.MANAGER, + 99L, + List.of(1L) + ); + + private MockMvc mockMvc; + private TemperatureReadingService readingService; + + @BeforeEach + void setUp() { + readingService = Mockito.mock(TemperatureReadingService.class); + TemperatureReadingController controller = new TemperatureReadingController(readingService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new FixedJwtPrincipalResolver(PRINCIPAL)) + .build(); + } + + @Test + void getReadingStatsReturnsExpectedJsonContract() throws Exception { + LocalDateTime from = LocalDateTime.of(2026, 3, 20, 0, 0); + LocalDateTime to = LocalDateTime.of(2026, 3, 21, 23, 59); + + TemperatureReadingStatsResponse response = new TemperatureReadingStatsResponse( + List.of(new TemperatureReadingStatsSeriesResponse( + 2L, + "Fryser #2", + List.of(new TemperatureReadingStatsPointResponse( + LocalDateTime.of(2026, 3, 20, 8, 0), + -17.4, + true + )) + )), + List.of(new TemperatureReadingDeviationResponse( + 42L, + 2L, + "Fryser #2", + -12.1, + -16.0, + LocalDateTime.of(2026, 3, 20, 8, 10) + )) + ); + + when(readingService.getReadingStats(99L, List.of(1L, 2L), from, to, TemperatureReadingStatsGroupBy.DAY)) + .thenReturn(response); + + mockMvc.perform(get("/api/readings/stats") + .param("unitIds", "1", "2") + .param("from", "2026-03-20T00:00:00") + .param("to", "2026-03-21T23:59:00") + .param("groupBy", "DAY")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Reading statistics retrieved")) + .andExpect(jsonPath("$.data.series", hasSize(1))) + .andExpect(jsonPath("$.data.series[0].unitId").value(2)) + .andExpect(jsonPath("$.data.series[0].unitName").value("Fryser #2")) + .andExpect(jsonPath("$.data.series[0].dataPoints", hasSize(1))) + .andExpect(jsonPath("$.data.series[0].dataPoints[0].timestamp").value("2026-03-20T08:00:00")) + .andExpect(jsonPath("$.data.series[0].dataPoints[0].avgTemperature").value(-17.4)) + .andExpect(jsonPath("$.data.series[0].dataPoints[0].isDeviation").value(true)) + .andExpect(jsonPath("$.data.deviations", hasSize(1))) + .andExpect(jsonPath("$.data.deviations[0].id").value(42)) + .andExpect(jsonPath("$.data.deviations[0].threshold").value(-16.0)); + + verify(readingService).getReadingStats(99L, List.of(1L, 2L), from, to, TemperatureReadingStatsGroupBy.DAY); + } + + @Test + void getReadingStatsParsesCommaSeparatedUnitIdsAndDefaultsGroupBy() throws Exception { + when(readingService.getReadingStats(eq(99L), any(), any(), any(), any())) + .thenReturn(new TemperatureReadingStatsResponse(List.of(), List.of())); + + mockMvc.perform(get("/api/readings/stats") + .param("unitIds", "3,4") + .param("from", "2026-03-01T00:00:00") + .param("to", "2026-03-31T23:59:00")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.series", hasSize(0))) + .andExpect(jsonPath("$.data.deviations", hasSize(0))); + + verify(readingService).getReadingStats( + eq(99L), + eq(List.of(3L, 4L)), + eq(LocalDateTime.of(2026, 3, 1, 0, 0)), + eq(LocalDateTime.of(2026, 3, 31, 23, 59)), + eq(TemperatureReadingStatsGroupBy.DAY) + ); + verify(readingService, never()).getReadingStats( + eq(99L), + eq(List.of(3L)), + any(), + any(), + any() + ); + } + + private static final class FixedJwtPrincipalResolver implements HandlerMethodArgumentResolver { + private final JwtPrincipal principal; + + private FixedJwtPrincipalResolver(JwtPrincipal principal) { + this.principal = principal; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return JwtPrincipal.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return principal; + } + } +} \ No newline at end of file diff --git a/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java b/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java index 6c86b96..03e51f3 100644 --- a/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java +++ b/backend/src/test/java/backend/fullstack/temperature/application/TemperatureReadingServiceTest.java @@ -28,9 +28,12 @@ import backend.fullstack.exceptions.UnitNotFoundException; import backend.fullstack.organization.Organization; import backend.fullstack.temperature.api.dto.RecordedByResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingDeviationResponse; import backend.fullstack.temperature.api.dto.TemperatureReadingMapper; import backend.fullstack.temperature.api.dto.TemperatureReadingRequest; import backend.fullstack.temperature.api.dto.TemperatureReadingResponse; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsGroupBy; +import backend.fullstack.temperature.api.dto.TemperatureReadingStatsResponse; import backend.fullstack.temperature.domain.TemperatureReading; import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository; import backend.fullstack.units.domain.TemperatureUnit; @@ -176,6 +179,58 @@ void listUnitReadingsMapsResults() { assertFalse(results.get(0).isDeviation()); } + @Test + void getReadingStatsBuildsSeriesAndDeviationList() { + TemperatureReading freezerMorning = readingAt(11L, 5L, "Freezer A", 2.0, false, LocalDateTime.of(2026, 3, 20, 8, 10)); + TemperatureReading freezerEveningDeviation = readingAt(12L, 5L, "Freezer A", 6.0, true, LocalDateTime.of(2026, 3, 20, 20, 15)); + TemperatureReading fridgeDayTwo = readingAt(13L, 9L, "Fridge B", 3.0, false, LocalDateTime.of(2026, 3, 21, 9, 30)); + + when(readingRepository.findForStatsByOrganizationAndRange(1L, null, null)) + .thenReturn(List.of(freezerMorning, freezerEveningDeviation, fridgeDayTwo)); + + TemperatureReadingStatsResponse stats = service.getReadingStats( + 1L, + null, + null, + null, + TemperatureReadingStatsGroupBy.DAY + ); + + assertEquals(2, stats.series().size()); + + var freezerSeries = stats.series().stream() + .filter(series -> series.unitId().equals(5L)) + .findFirst() + .orElseThrow(); + assertEquals(1, freezerSeries.dataPoints().size()); + assertTrue(freezerSeries.dataPoints().get(0).isDeviation()); + assertEquals(4.0, freezerSeries.dataPoints().get(0).avgTemperature()); + + assertEquals(1, stats.deviations().size()); + TemperatureReadingDeviationResponse deviation = stats.deviations().get(0); + assertEquals(12L, deviation.id()); + assertEquals(5.0, deviation.threshold()); + } + + @Test + void getReadingStatsUsesUnitFilterWhenUnitIdsProvided() { + TemperatureReading freezerDeviation = readingAt(22L, 5L, "Freezer A", 6.0, true, LocalDateTime.of(2026, 3, 20, 8, 10)); + + when(readingRepository.findForStatsByOrganizationAndUnitIdsAndRange(1L, List.of(5L), null, null)) + .thenReturn(List.of(freezerDeviation)); + + TemperatureReadingStatsResponse stats = service.getReadingStats( + 1L, + List.of(5L, 5L), + null, + null, + TemperatureReadingStatsGroupBy.HOUR + ); + + verify(readingRepository).findForStatsByOrganizationAndUnitIdsAndRange(1L, List.of(5L), null, null); + assertEquals(1, stats.series().size()); + } + private static TemperatureUnit unit(Long id, Long organizationId, boolean active, double min, double max) { Organization organization = Organization.builder() .id(organizationId) @@ -235,6 +290,42 @@ private static TemperatureReading reading(Long id, boolean deviation) { .build(); } + private static TemperatureReading readingAt( + Long id, + Long unitId, + String unitName, + Double temperature, + boolean deviation, + LocalDateTime recordedAt + ) { + Organization organization = Organization.builder() + .id(1L) + .name("Everest") + .organizationNumber("123456789") + .build(); + + TemperatureUnit unit = TemperatureUnit.builder() + .id(unitId) + .organization(organization) + .name(unitName) + .type(UnitType.FREEZER) + .targetTemperature(3.0) + .minThreshold(1.0) + .maxThreshold(5.0) + .active(true) + .build(); + + return TemperatureReading.builder() + .id(id) + .organization(organization) + .unit(unit) + .recordedBy(user(10L, 1L)) + .temperature(temperature) + .recordedAt(recordedAt) + .isDeviation(deviation) + .build(); + } + private static TemperatureReadingResponse response(Long id, boolean deviation) { return new TemperatureReadingResponse( id,