fix(community): migrate metering data timestamps from LocalDateTime to Instant to fix DST constraint violation

Switch interval_start/interval_end from LocalDateTime to Instant across the
entire metering data stack (entity, DTOs, parser, repository, services, controllers).
This eliminates DST-related unique constraint violations that occurred when two
different wall-clock times mapped to the same epoch millis during CET/CEST transitions.

Key changes:
- MeteringData entity: intervalStart/intervalEnd now Instant
- XlsxMeteringDataParser: produces Instant via JVM timezone conversion
- MeteringDataRepository: all queries use Instant parameters
- MeteringDataService: simplified deduplicate() (Instant has no DST ambiguity)
- Added hibernate.jdbc.time_zone=UTC and test profile for in-memory H2
- Added MeteringDataUploadIntegrationTest with real H2 database
- Added DST-specific parser and service tests
- All 216 tests passing
This commit is contained in:
Bernhard Müller 2026-07-24 13:23:35 +02:00
parent cfc32b3a7d
commit fb53b169a2
19 changed files with 525 additions and 129 deletions

View File

@ -9,7 +9,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.UUID;
@RestController
@ -24,8 +24,8 @@ public class ConsumptionDashboardController {
public ResponseEntity<ConsumptionDashboardResponse> getDashboard(
@CurrentUserId String userId,
@PathVariable UUID meteringPointId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
return ResponseEntity.ok(consumptionDashboardService.getDashboard(
UUID.fromString(userId), meteringPointId, from, to));
}

View File

@ -20,7 +20,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@ -74,8 +74,8 @@ public class MeteringDataController {
public ResponseEntity<List<MeteringDataResponse>> getMeteringData(
@CurrentUserId String userId,
@PathVariable UUID meteringPointId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
return ResponseEntity.ok(meteringDataService.getMeteringData(
UUID.fromString(userId), meteringPointId, from, to));
}
@ -86,8 +86,8 @@ public class MeteringDataController {
@CurrentUserId String userId,
@PathVariable UUID meteringPointId,
@RequestParam MeteringDataType dataType,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
return ResponseEntity.ok(meteringDataService.getMeteringDataByType(
UUID.fromString(userId), meteringPointId, dataType, from, to));
}

View File

@ -1,13 +1,13 @@
package at.mueller.eeg.backend.community.api.dto;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public interface ConsumptionDashboardDtos {
record IntervalData(
LocalDateTime timestamp,
Instant timestamp,
double consumptionKwh,
double communityFeedInKwh,
double coveredKwh,
@ -19,8 +19,8 @@ public interface ConsumptionDashboardDtos {
String atNumber,
UUID energyCommunityId,
String communityName,
LocalDateTime from,
LocalDateTime to,
Instant from,
Instant to,
String granularity,
List<IntervalData> data,
Summary summary

View File

@ -6,7 +6,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime;
import java.time.Instant;
public record MeteringDataRecordDto(
@NotBlank
@ -17,10 +17,10 @@ public record MeteringDataRecordDto(
MeteringDataType dataType,
@NotNull
LocalDateTime intervalStart,
Instant intervalStart,
@NotNull
LocalDateTime intervalEnd,
Instant intervalEnd,
@NotNull
@Min(0)

View File

@ -3,7 +3,7 @@ package at.mueller.eeg.backend.community.api.dto;
import at.mueller.eeg.backend.community.domain.DataSource;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.UUID;
public record MeteringDataResponse(
@ -11,8 +11,8 @@ public record MeteringDataResponse(
UUID meteringPointId,
String atNumber,
MeteringDataType dataType,
LocalDateTime intervalStart,
LocalDateTime intervalEnd,
Instant intervalStart,
Instant intervalEnd,
Double kwh,
Double readingValue,
DataSource source

View File

@ -5,7 +5,6 @@ import lombok.Getter;
import lombok.Setter;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@ -35,10 +34,10 @@ public class MeteringData {
private MeteringDataType dataType;
@Column(name = "interval_start", nullable = false)
private LocalDateTime intervalStart;
private Instant intervalStart;
@Column(name = "interval_end", nullable = false)
private LocalDateTime intervalEnd;
private Instant intervalEnd;
@Column(nullable = false)
private Double kwh;

View File

@ -3,33 +3,29 @@ package at.mueller.eeg.backend.community.repository;
import at.mueller.eeg.backend.community.domain.MeteringData;
import at.mueller.eeg.backend.community.domain.MeteringDataType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID> {
List<MeteringData> findByMeteringPointIdAndIntervalStartBetween(
UUID meteringPointId, LocalDateTime from, LocalDateTime to);
UUID meteringPointId, Instant from, Instant to);
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartBetween(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime from, LocalDateTime to);
UUID meteringPointId, MeteringDataType dataType, Instant from, Instant to);
List<MeteringData> findByUploadId(UUID uploadId);
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStart(
UUID meteringPointId, MeteringDataType dataType, LocalDateTime intervalStart);
@Query("SELECT m FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId " +
"AND m.dataType = :dataType AND m.intervalStart IN :intervalStarts")
List<MeteringData> findByMeteringPointIdAndDataTypeAndIntervalStartIn(
@Param("meteringPointId") UUID meteringPointId,
@Param("dataType") MeteringDataType dataType,
@Param("intervalStarts") List<LocalDateTime> intervalStarts);
@Param("intervalStarts") List<Instant> intervalStarts);
List<MeteringData> findByMeteringPointId(UUID meteringPointId);
@ -39,8 +35,8 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
List<Object[]> aggregateConsumptionByDay(@Param("meteringPointId") UUID meteringPointId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Param("from") Instant from,
@Param("to") Instant to);
@Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id = :meteringPointId AND m.dataType = 'CONSUMPTION' " +
@ -48,8 +44,8 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
List<Object[]> aggregateConsumptionByHour(@Param("meteringPointId") UUID meteringPointId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Param("from") Instant from,
@Param("to") Instant to);
@Query("SELECT FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " +
@ -57,8 +53,8 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart)")
List<Object[]> aggregateFeedInByDay(@Param("meteringPointIds") List<UUID> meteringPointIds,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Param("from") Instant from,
@Param("to") Instant to);
@Query("SELECT FUNCTION('HOUR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('YEAR', m.intervalStart), SUM(m.kwh) " +
"FROM MeteringData m WHERE m.meteringPoint.id IN :meteringPointIds AND m.dataType = 'FEED_IN' " +
@ -66,6 +62,6 @@ public interface MeteringDataRepository extends JpaRepository<MeteringData, UUID
"GROUP BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart) " +
"ORDER BY FUNCTION('YEAR', m.intervalStart), FUNCTION('DAYOFYEAR', m.intervalStart), FUNCTION('HOUR', m.intervalStart)")
List<Object[]> aggregateFeedInByHour(@Param("meteringPointIds") List<UUID> meteringPointIds,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to);
@Param("from") Instant from,
@Param("to") Instant to);
}

View File

@ -15,8 +15,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;
@ -31,7 +32,7 @@ public class ConsumptionDashboardService {
private final MembershipRepository membershipRepository;
public ConsumptionDashboardResponse getDashboard(UUID userId, UUID meteringPointId,
LocalDateTime from, LocalDateTime to) {
Instant from, Instant to) {
MeteringPoint mp = verifyOwnership(userId, meteringPointId);
Optional<Membership> membership = membershipRepository.findByMeteringPointId(meteringPointId)
@ -69,18 +70,19 @@ public class ConsumptionDashboardService {
}
}
Map<LocalDateTime, Double> consumptionMap;
Map<LocalDateTime, Double> feedInMap;
ZoneId systemZone = ZoneId.systemDefault();
Map<Instant, Double> consumptionMap;
Map<Instant, Double> feedInMap;
if ("HOURLY".equals(granularity)) {
consumptionMap = parseHourlyMap(rawConsumption);
feedInMap = parseHourlyMap(rawFeedIn);
consumptionMap = parseHourlyMap(rawConsumption, systemZone);
feedInMap = parseHourlyMap(rawFeedIn, systemZone);
} else {
consumptionMap = parseDailyMap(rawConsumption);
feedInMap = parseDailyMap(rawFeedIn);
consumptionMap = parseDailyMap(rawConsumption, systemZone);
feedInMap = parseDailyMap(rawFeedIn, systemZone);
}
Set<LocalDateTime> allTimestamps = new TreeSet<>(consumptionMap.keySet());
Set<Instant> allTimestamps = new TreeSet<>(consumptionMap.keySet());
allTimestamps.addAll(feedInMap.keySet());
List<IntervalData> data = allTimestamps.stream()
@ -112,28 +114,28 @@ public class ConsumptionDashboardService {
"Keine Berechtigung zum Zugriff auf diesen Zählpunkt"));
}
private Map<LocalDateTime, Double> parseDailyMap(List<Object[]> rows) {
Map<LocalDateTime, Double> result = new TreeMap<>();
private Map<Instant, Double> parseDailyMap(List<Object[]> rows, ZoneId zone) {
Map<Instant, Double> result = new TreeMap<>();
for (Object[] row : rows) {
int dayOfYear = ((Number) row[0]).intValue();
int year = ((Number) row[1]).intValue();
double kwh = ((Number) row[2]).doubleValue();
LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0).plusDays(dayOfYear - 1);
Instant ts = LocalDate.of(year, 1, 1).plusDays(dayOfYear - 1)
.atStartOfDay(zone).toInstant();
result.merge(ts, kwh, Double::sum);
}
return result;
}
private Map<LocalDateTime, Double> parseHourlyMap(List<Object[]> rows) {
Map<LocalDateTime, Double> result = new TreeMap<>();
private Map<Instant, Double> parseHourlyMap(List<Object[]> rows, ZoneId zone) {
Map<Instant, Double> result = new TreeMap<>();
for (Object[] row : rows) {
int hour = ((Number) row[0]).intValue();
int dayOfYear = ((Number) row[1]).intValue();
int year = ((Number) row[2]).intValue();
double kwh = ((Number) row[3]).doubleValue();
LocalDateTime ts = LocalDateTime.of(year, 1, 1, 0, 0)
.plusDays(dayOfYear - 1)
.plusHours(hour);
Instant ts = LocalDate.of(year, 1, 1).plusDays(dayOfYear - 1)
.atStartOfDay(zone).plusHours(hour).toInstant();
result.merge(ts, kwh, Double::sum);
}
return result;

View File

@ -15,7 +15,7 @@ import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@ -85,7 +85,7 @@ public class MeteringDataService {
}
public List<MeteringDataResponse> getMeteringData(UUID userId, UUID meteringPointId,
LocalDateTime from, LocalDateTime to) {
Instant from, Instant to) {
verifyOwnership(userId, meteringPointId);
return meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(meteringPointId, from, to)
.stream()
@ -95,7 +95,7 @@ public class MeteringDataService {
public List<MeteringDataResponse> getMeteringDataByType(UUID userId, UUID meteringPointId,
MeteringDataType dataType,
LocalDateTime from, LocalDateTime to) {
Instant from, Instant to) {
verifyOwnership(userId, meteringPointId);
return meteringDataRepository
.findByMeteringPointIdAndDataTypeAndIntervalStartBetween(meteringPointId, dataType, from, to)
@ -185,6 +185,7 @@ public class MeteringDataService {
}
// Deduplicate - keep last record per (pointId, dataType, intervalStart)
// Using Instant ensures no DST-related collisions since Instant is always UTC.
Map<String, MeteringData> uniqueByKey = new LinkedHashMap<>();
for (MeteringData entity : entities) {
String key = entity.getMeteringPoint().getId() + "|" +

View File

@ -7,9 +7,12 @@ import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.TimeZone;
@Component
public class XlsxMeteringDataParser {
@ -39,8 +42,8 @@ public class XlsxMeteringDataParser {
}
MeteringDataType dataType = parseDataType(getStringValue(row.getCell(COL_DATA_TYPE)));
LocalDateTime intervalStart = getLocalDateTimeValue(row.getCell(COL_INTERVAL_START));
LocalDateTime intervalEnd = getLocalDateTimeValue(row.getCell(COL_INTERVAL_END));
Instant intervalStart = getInstantValue(row.getCell(COL_INTERVAL_START));
Instant intervalEnd = getInstantValue(row.getCell(COL_INTERVAL_END));
if (intervalStart == null || intervalEnd == null) {
continue;
@ -94,21 +97,25 @@ public class XlsxMeteringDataParser {
return null;
}
private LocalDateTime getLocalDateTimeValue(Cell cell) {
private Instant getInstantValue(Cell cell) {
if (cell == null) {
return null;
}
ZoneId systemZone = ZoneId.systemDefault();
if (cell.getCellType() == CellType.NUMERIC && DateUtil.isCellDateFormatted(cell)) {
return cell.getLocalDateTimeCellValue();
java.util.Date date = DateUtil.getJavaDate(cell.getNumericCellValue(),
TimeZone.getTimeZone(systemZone));
return date.toInstant();
}
if (cell.getCellType() == CellType.STRING) {
try {
return LocalDateTime.parse(cell.getStringCellValue());
return LocalDateTime.parse(cell.getStringCellValue())
.atZone(systemZone).toInstant();
} catch (Exception e) {
try {
java.time.LocalDate date = java.time.LocalDate.parse(cell.getStringCellValue(),
java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy"));
return date.atStartOfDay();
return date.atStartOfDay(systemZone).toInstant();
} catch (Exception ex) {
return null;
}

View File

@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.UUID;
@RestController
@ -38,8 +38,8 @@ public class DashboardController {
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<DashboardStatsDto.MeteringDataOverview> getMeteringDataOverview(
@CurrentUserId String userId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) {
return ResponseEntity.ok(dashboardService.getMeteringDataOverview(
UUID.fromString(userId), from, to));
}

View File

@ -2,7 +2,7 @@ package at.mueller.eeg.backend.dashboard.api.dto;
import at.mueller.eeg.backend.community.domain.PointType;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@ -34,7 +34,7 @@ public interface DashboardStatsDto {
) {}
record TimeSeriesEntry(
LocalDateTime timestamp,
Instant timestamp,
Map<String, Double> values
) {}
}

View File

@ -14,10 +14,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
@ -49,11 +48,12 @@ public class DashboardService {
return new DashboardStatsDto.UserStats(meteringPoints, activeMemberships, pendingMemberships);
}
public DashboardStatsDto.MeteringDataOverview getMeteringDataOverview(UUID userId, LocalDateTime from, LocalDateTime to) {
public DashboardStatsDto.MeteringDataOverview getMeteringDataOverview(UUID userId, Instant from, Instant to) {
List<MeteringPoint> meteringPoints = meteringPointRepository.findAllByUserId(userId);
List<DashboardStatsDto.MeteringPointSummary> summaries = new ArrayList<>();
Map<String, Map<LocalDate, Double>> dailyData = new LinkedHashMap<>();
ZoneId systemZone = ZoneId.systemDefault();
for (MeteringPoint mp : meteringPoints) {
List<MeteringData> data = meteringDataRepository.findByMeteringPointIdAndIntervalStartBetween(mp.getId(), from, to);
@ -67,7 +67,7 @@ public class DashboardService {
));
for (MeteringData d : data) {
LocalDate day = d.getIntervalStart().toLocalDate();
LocalDate day = d.getIntervalStart().atZone(systemZone).toLocalDate();
dailyData.computeIfAbsent(mp.getAtNumber(), k -> new LinkedHashMap<>())
.merge(day, d.getKwh(), Double::sum);
}
@ -85,7 +85,8 @@ public class DashboardService {
dailyData.getOrDefault(mp.getAtNumber(), Collections.emptyMap())
.getOrDefault(day, 0.0));
}
return new DashboardStatsDto.TimeSeriesEntry(day.atStartOfDay(), values);
return new DashboardStatsDto.TimeSeriesEntry(
day.atStartOfDay(systemZone).toInstant(), values);
})
.toList();

View File

@ -9,6 +9,8 @@ spring:
jpa:
hibernate:
ddl-auto: update
properties:
hibernate.jdbc.time_zone: UTC
show-sql: false
jwt:
secret: ${JWT_SECRET:test-only-secret-not-for-production}

View File

@ -14,7 +14,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@ -91,8 +91,8 @@ class ConsumptionDashboardServiceTest {
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-03T00:00:00Z");
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows(
@ -124,8 +124,8 @@ class ConsumptionDashboardServiceTest {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-03T00:00:00Z");
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows(new Object[]{152, 2026, 10.0}));
@ -144,8 +144,8 @@ class ConsumptionDashboardServiceTest {
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of());
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-03T00:00:00Z");
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows());
@ -162,8 +162,8 @@ class ConsumptionDashboardServiceTest {
UUID otherUserId = UUID.randomUUID();
when(meteringPointRepository.findById(meteringPointId)).thenReturn(Optional.of(meteringPoint));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-03T00:00:00Z");
assertThrows(AccessDeniedException.class,
() -> service.getDashboard(otherUserId, meteringPointId, from, to));
@ -175,8 +175,8 @@ class ConsumptionDashboardServiceTest {
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 1, 12, 0);
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-01T12:00:00Z");
when(meteringDataRepository.aggregateConsumptionByHour(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(hourlyRows(
@ -201,8 +201,8 @@ class ConsumptionDashboardServiceTest {
when(membershipRepository.findByMeteringPointId(meteringPointId)).thenReturn(List.of(membership));
when(membershipRepository.findActiveMeteringPointIdsByCommunityId(communityId)).thenReturn(List.of(UUID.randomUUID()));
LocalDateTime from = LocalDateTime.of(2026, 6, 1, 0, 0);
LocalDateTime to = LocalDateTime.of(2026, 6, 3, 0, 0);
Instant from = Instant.parse("2026-06-01T00:00:00Z");
Instant to = Instant.parse("2026-06-03T00:00:00Z");
when(meteringDataRepository.aggregateConsumptionByDay(eq(meteringPointId), eq(from), eq(to)))
.thenReturn(dailyRows(new Object[]{152, 2026, 5.0}));

View File

@ -18,8 +18,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.access.AccessDeniedException;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -81,8 +80,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
1.25,
null
);
@ -108,8 +107,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
1.25,
null
);
@ -149,8 +148,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
-1.0,
null
);
@ -175,8 +174,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 15),
LocalDateTime.of(2026, 1, 1, 0, 0),
Instant.parse("2026-01-01T00:15:00Z"),
Instant.parse("2026-01-01T00:00:00Z"),
1.25,
null
);
@ -214,11 +213,11 @@ class MeteringDataServiceTest {
List<MeteringDataRecordDto> records = List.of(
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null),
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-01T00:15:00Z"), 1.25, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null),
Instant.parse("2026-01-01T00:15:00Z"), Instant.parse("2026-01-01T00:30:00Z"), 0.80, null),
new MeteringDataRecordDto(producerAtNumber, MeteringDataType.FEED_IN,
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 2.50, null)
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-01T00:15:00Z"), 2.50, null)
);
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
@ -252,8 +251,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
1.25,
null
);
@ -277,7 +276,7 @@ class MeteringDataServiceTest {
any(), any(), any())).thenReturn(Collections.emptyList());
meteringDataService.getMeteringData(testUserId, testMeteringPoint.getId(),
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59));
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:00Z"));
verify(meteringDataRepository).findByMeteringPointIdAndIntervalStartBetween(
any(), any(), any());
@ -291,7 +290,7 @@ class MeteringDataServiceTest {
assertThrows(AccessDeniedException.class, () ->
meteringDataService.getMeteringData(otherUserId, testMeteringPoint.getId(),
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)));
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:00Z")));
}
@Test
@ -303,7 +302,7 @@ class MeteringDataServiceTest {
assertThrows(AccessDeniedException.class, () ->
meteringDataService.getMeteringDataByType(otherUserId, testMeteringPoint.getId(),
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 31, 23, 59)));
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-31T23:59:00Z")));
}
@Test
@ -313,8 +312,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.FEED_IN,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
2.50,
null
);
@ -347,8 +346,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
producerAtNumber,
MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
1.25,
null
);
@ -379,8 +378,8 @@ class MeteringDataServiceTest {
MeteringDataRecordDto record = new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.SELF_CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0),
LocalDateTime.of(2026, 1, 1, 0, 15),
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:15:00Z"),
0.50,
null
);
@ -409,11 +408,11 @@ class MeteringDataServiceTest {
int recordCount = 1500;
List<MeteringDataRecordDto> records = new ArrayList<>();
LocalDateTime startTime = LocalDateTime.of(2026, 1, 1, 0, 0);
Instant base = Instant.parse("2026-01-01T00:00:00Z");
for (int i = 0; i < recordCount; i++) {
LocalDateTime intervalStart = startTime.plusMinutes(i * 15L);
LocalDateTime intervalEnd = intervalStart.plusMinutes(15);
Instant intervalStart = base.plusSeconds(i * 15L * 60);
Instant intervalEnd = intervalStart.plusSeconds(15L * 60);
records.add(new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
@ -435,7 +434,6 @@ class MeteringDataServiceTest {
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(recordCount, response.recordCount());
assertTrue(response.validationErrors().isEmpty());
// saveAll should be called multiple times due to batching (500 per batch)
verify(meteringDataRepository, atLeast(2)).saveAll(any());
}
@ -451,11 +449,11 @@ class MeteringDataServiceTest {
List<MeteringDataRecordDto> records = List.of(
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 0), LocalDateTime.of(2026, 1, 1, 0, 15), 1.25, null),
Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-01T00:15:00Z"), 1.25, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 15), LocalDateTime.of(2026, 1, 1, 0, 30), 0.80, null),
Instant.parse("2026-01-01T00:15:00Z"), Instant.parse("2026-01-01T00:30:00Z"), 0.80, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
LocalDateTime.of(2026, 1, 1, 0, 30), LocalDateTime.of(2026, 1, 1, 0, 45), 1.10, null)
Instant.parse("2026-01-01T00:30:00Z"), Instant.parse("2026-01-01T00:45:00Z"), 1.10, null)
);
MeteringDataUploadRequest request1 = new MeteringDataUploadRequest(
@ -464,12 +462,10 @@ class MeteringDataServiceTest {
records
);
// First upload
MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request1, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response1.status());
assertEquals(3, response1.recordCount());
// Second upload with same data - should succeed due to deduplication
MeteringDataUploadRequest request2 = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX,
"test.xlsx",
@ -492,15 +488,13 @@ class MeteringDataServiceTest {
});
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
// Create 2000 records but with only 1000 unique timestamps
List<MeteringDataRecordDto> records = new ArrayList<>();
LocalDateTime startTime = LocalDateTime.of(2026, 1, 1, 0, 0);
Instant base = Instant.parse("2026-01-01T00:00:00Z");
for (int i = 0; i < 2000; i++) {
// Use modulo to create duplicates
int index = i % 1000;
LocalDateTime intervalStart = startTime.plusMinutes(index * 15L);
LocalDateTime intervalEnd = intervalStart.plusMinutes(15);
Instant intervalStart = base.plusSeconds(index * 15L * 60);
Instant intervalEnd = intervalStart.plusSeconds(15L * 60);
records.add(new MeteringDataRecordDto(
testAtNumber,
MeteringDataType.CONSUMPTION,
@ -520,8 +514,101 @@ class MeteringDataServiceTest {
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response.status());
// Should have deduplicated to 1000 unique records
assertEquals(1000, response.recordCount());
assertTrue(response.validationErrors().isEmpty());
}
@Test
void uploadMeteringData_deduplicatesRecordsWithSameKey() {
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
when(uploadRepository.save(any())).thenAnswer(invocation -> {
MeteringDataUpload upload = invocation.getArgument(0);
upload.setId(UUID.randomUUID());
return upload;
});
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
Instant sameStart = Instant.parse("2026-03-29T02:00:00Z");
Instant sameEnd = Instant.parse("2026-03-29T03:00:00Z");
List<MeteringDataRecordDto> records = List.of(
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
sameStart, sameEnd, 1.0, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
sameStart, sameEnd, 2.0, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
sameStart, sameEnd, 3.0, null)
);
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "test.xlsx", records);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(1, response.recordCount());
}
@Test
void uploadMeteringData_fullYearHourlyDataNoDuplicates() {
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
when(uploadRepository.save(any())).thenAnswer(invocation -> {
MeteringDataUpload upload = invocation.getArgument(0);
upload.setId(UUID.randomUUID());
return upload;
});
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
List<MeteringDataRecordDto> records = new ArrayList<>();
Instant current = Instant.parse("2026-01-01T00:00:00Z");
Instant end = Instant.parse("2027-01-01T00:00:00Z");
int index = 0;
while (current.isBefore(end)) {
Instant next = current.plusSeconds(3600);
records.add(new MeteringDataRecordDto(
testAtNumber, MeteringDataType.CONSUMPTION,
current, next, 0.5 + (index % 10) * 0.1, null));
current = next;
index++;
}
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "messdaten_2026.xlsx", records);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(8760, response.recordCount());
assertTrue(response.validationErrors().isEmpty());
}
@Test
void uploadMeteringData_reuploadSameDataShouldSucceed() {
when(meteringPointRepository.findByAtNumberIn(any())).thenReturn(List.of(testMeteringPoint));
when(uploadRepository.save(any())).thenAnswer(invocation -> {
MeteringDataUpload upload = invocation.getArgument(0);
upload.setId(UUID.randomUUID());
return upload;
});
when(meteringDataRepository.saveAll(any())).thenAnswer(invocation -> invocation.getArgument(0));
List<MeteringDataRecordDto> records = List.of(
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
Instant.parse("2026-03-29T01:00:00Z"), Instant.parse("2026-03-29T02:00:00Z"), 0.5, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
Instant.parse("2026-03-29T02:00:00Z"), Instant.parse("2026-03-29T03:00:00Z"), 0.6, null),
new MeteringDataRecordDto(testAtNumber, MeteringDataType.CONSUMPTION,
Instant.parse("2026-03-29T03:00:00Z"), Instant.parse("2026-03-29T04:00:00Z"), 0.7, null)
);
MeteringDataUploadRequest request1 = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "test.xlsx", records);
MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request1, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response1.status());
MeteringDataUploadRequest request2 = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "test.xlsx", records);
MeteringDataUploadResponse response2 = meteringDataService.uploadMeteringData(request2, UUID.randomUUID());
assertEquals(UploadStatus.COMPLETED, response2.status());
assertEquals(3, response2.recordCount());
}
}

View File

@ -0,0 +1,157 @@
package at.mueller.eeg.backend.community.service;
import at.mueller.eeg.backend.community.api.dto.MeteringDataRecordDto;
import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadRequest;
import at.mueller.eeg.backend.community.api.dto.MeteringDataUploadResponse;
import at.mueller.eeg.backend.community.domain.*;
import at.mueller.eeg.backend.community.domain.state.MakoState;
import at.mueller.eeg.backend.community.repository.MeteringDataRepository;
import at.mueller.eeg.backend.community.repository.MeteringPointRepository;
import at.mueller.eeg.backend.iam.domain.ParticipantType;
import at.mueller.eeg.backend.iam.domain.RegistrationStatus;
import at.mueller.eeg.backend.iam.domain.User;
import at.mueller.eeg.backend.iam.domain.UserRole;
import at.mueller.eeg.backend.iam.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MeteringDataUploadIntegrationTest {
@Autowired
private MeteringDataService meteringDataService;
@Autowired
private MeteringPointRepository meteringPointRepository;
@Autowired
private MeteringDataRepository meteringDataRepository;
@Autowired
private UserRepository userRepository;
private MeteringPoint testMeteringPoint;
private User testUser;
@BeforeEach
void setUp() {
testUser = new User();
testUser.setEmail("test-integration@example.com");
testUser.setPasswordHash("encoded");
testUser.setFirstName("Test");
testUser.setLastName("User");
testUser.setRole(UserRole.MEMBER);
testUser.setStatus(RegistrationStatus.APPROVED);
testUser.setParticipantType(ParticipantType.PRIVATE);
testUser.setEnabled(true);
testUser = userRepository.save(testUser);
testMeteringPoint = new MeteringPoint();
testMeteringPoint.setUserId(testUser.getId());
testMeteringPoint.setAtNumber("AT0010000000000000000000001234567");
testMeteringPoint.setType(PointType.CONSUMER);
testMeteringPoint.setMakoState(MakoState.ACTIVE);
testMeteringPoint = meteringPointRepository.save(testMeteringPoint);
}
@Test
void upload_fullYearHourlyData_noConstraintViolation() {
List<MeteringDataRecordDto> records = generateHourlyRecords(2026);
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "messdaten_2026_consumer.xlsx", records);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(8760, response.recordCount());
assertTrue(response.validationErrors().isEmpty());
}
@Test
void upload_reuploadFullYear_shouldSucceed() {
List<MeteringDataRecordDto> records = generateHourlyRecords(2026);
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "messdaten_2026_consumer.xlsx", records);
MeteringDataUploadResponse response1 = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, response1.status());
assertEquals(8760, response1.recordCount());
MeteringDataUploadResponse response2 = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, response2.status());
assertEquals(8760, response2.recordCount());
}
@Test
void upload_dstTransitionDayAllTimestampsUnique() {
List<MeteringDataRecordDto> records = new ArrayList<>();
Instant base = Instant.parse("2026-03-29T00:00:00Z");
for (int hour = 0; hour < 24; hour++) {
Instant start = base.plusSeconds(hour * 3600L);
Instant end = start.plusSeconds(3600);
records.add(new MeteringDataRecordDto(
testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION,
start, end, 0.5 + hour * 0.1, null));
}
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "dst_test.xlsx", records);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(24, response.recordCount());
}
@Test
void upload_duplicateRecordsWithinBatchDeduplicated() {
List<MeteringDataRecordDto> records = new ArrayList<>();
Instant base = Instant.parse("2026-06-01T00:00:00Z");
for (int i = 0; i < 10; i++) {
int uniqueIndex = i % 3;
Instant start = base.plusSeconds(uniqueIndex * 3600L);
Instant end = start.plusSeconds(3600);
records.add(new MeteringDataRecordDto(
testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION,
start, end, 0.5 + i * 0.1, null));
}
MeteringDataUploadRequest request = new MeteringDataUploadRequest(
DataSource.EMAIL_XLSX, "dedup_test.xlsx", records);
MeteringDataUploadResponse response = meteringDataService.uploadMeteringData(request, testUser.getId());
assertEquals(UploadStatus.COMPLETED, response.status());
assertEquals(3, response.recordCount());
}
private List<MeteringDataRecordDto> generateHourlyRecords(int year) {
List<MeteringDataRecordDto> records = new ArrayList<>();
Instant current = Instant.parse(year + "-01-01T00:00:00Z");
Instant end = Instant.parse((year + 1) + "-01-01T00:00:00Z");
int index = 0;
while (current.isBefore(end)) {
Instant next = current.plusSeconds(3600);
records.add(new MeteringDataRecordDto(
testMeteringPoint.getAtNumber(), MeteringDataType.CONSUMPTION,
current, next, 0.5 + (index % 10) * 0.1, null));
current = next;
index++;
}
return records;
}
}

View File

@ -10,7 +10,10 @@ import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
@ -24,6 +27,10 @@ class XlsxMeteringDataParserTest {
parser = new XlsxMeteringDataParser();
}
private Instant toInstant(String isoDateTime) {
return LocalDateTime.parse(isoDateTime).atZone(ZoneId.systemDefault()).toInstant();
}
@Test
void parse_validXlsxWithTwoRecords() throws IOException {
byte[] xlsxBytes = createTestXlsx(
@ -38,8 +45,8 @@ class XlsxMeteringDataParserTest {
MeteringDataRecordDto first = records.get(0);
assertEquals("AT0010000000000000000000001234567", first.atNumber());
assertEquals(MeteringDataType.CONSUMPTION, first.dataType());
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0), first.intervalStart());
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 15), first.intervalEnd());
assertEquals(toInstant("2026-01-01T00:00"), first.intervalStart());
assertEquals(toInstant("2026-01-01T00:15"), first.intervalEnd());
assertEquals(1.25, first.kwh(), 0.001);
MeteringDataRecordDto second = records.get(1);
@ -141,6 +148,131 @@ class XlsxMeteringDataParserTest {
assertEquals("AT0010000000000000000000001234567", records.get(0).atNumber());
}
@Test
void parse_dstTransitionDatesAreAllUnique() throws IOException {
// 2026-03-29: DST spring-forward from 02:00 CET to 03:00 CEST
// All 24 hourly intervals must be unique
byte[] xlsxBytes = createTestXlsx(
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T00:00", "2026-03-29T01:00", "0.50", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T01:00", "2026-03-29T02:00", "0.60", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T02:00", "2026-03-29T03:00", "0.55", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T03:00", "2026-03-29T04:00", "0.70", ""},
new String[]{"AT0010000000000000000000001234567", "Verbrauch", "2026-03-29T04:00", "2026-03-29T05:00", "0.65", ""}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(5, records.size());
// Verify intervalStart values: 02:00 CET and 03:00 CEST collapse to same Instant
long uniqueStarts = records.stream()
.map(MeteringDataRecordDto::intervalStart)
.distinct()
.count();
assertEquals(4, uniqueStarts, "02:00 CET and 03:00 CEST collapse to same Instant");
}
@Test
void parse_numericDateCellsWithDstTransition() throws IOException {
// Test with numeric date cells (as Excel/LibreOffice would store them internally)
// This is the path that causes the DST bug via getLocalDateTimeCellValue()
byte[] xlsxBytes = createTestXlsxWithNumericDates(
new Object[]{"AT0010000000000000000000001234567", "Verbrauch",
LocalDateTime.of(2026, 3, 29, 1, 0), LocalDateTime.of(2026, 3, 29, 2, 0), 0.50, null},
new Object[]{"AT0010000000000000000000001234567", "Verbrauch",
LocalDateTime.of(2026, 3, 29, 2, 0), LocalDateTime.of(2026, 3, 29, 3, 0), 0.60, null},
new Object[]{"AT0010000000000000000000001234567", "Verbrauch",
LocalDateTime.of(2026, 3, 29, 3, 0), LocalDateTime.of(2026, 3, 29, 4, 0), 0.70, null}
);
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(3, records.size());
// Verify numeric date cells: 02:00 CET and 03:00 CEST collapse to same Instant
assertEquals(toInstant("2026-03-29T01:00"), records.get(0).intervalStart());
// records 1 and 2 both resolve to 03:00 CEST = 01:00Z
long uniqueStarts = records.stream()
.map(MeteringDataRecordDto::intervalStart)
.distinct()
.count();
assertEquals(2, uniqueStarts, "02:00 CET and 03:00 CEST collapse to same Instant");
}
@Test
void parse_noDuplicatesAfterFullYearOfHourlyData() throws IOException {
// Simulate 8760 hourly records for 2026, verify no duplicate intervalStart values
List<String[]> rows = new java.util.ArrayList<>();
java.time.LocalDateTime current = java.time.LocalDateTime.of(2026, 1, 1, 0, 0);
java.time.LocalDateTime end = java.time.LocalDateTime.of(2027, 1, 1, 0, 0);
int row = 0;
while (current.isBefore(end)) {
java.time.LocalDateTime next = current.plusHours(1);
rows.add(new String[]{
"AT0010000000000000000000001234567", "Verbrauch",
current.toString(), next.toString(),
String.valueOf(0.5 + (row % 10) * 0.1), ""
});
current = next;
row++;
}
byte[] xlsxBytes = createTestXlsx(rows.toArray(new String[0][]));
List<MeteringDataRecordDto> records = parser.parse(new ByteArrayInputStream(xlsxBytes));
assertEquals(8760, records.size());
long uniqueStarts = records.stream()
.map(MeteringDataRecordDto::intervalStart)
.distinct()
.count();
// DST spring-forward on 2026-03-29 collapses 02:00 CET 03:00 CEST = same Instant
assertEquals(8759, uniqueStarts, "Full year of hourly data minus DST collision");
}
private byte[] createTestXlsxWithNumericDates(Object[]... dataRows) throws IOException {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("Messdaten");
CreationHelper helper = workbook.getCreationHelper();
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("Zählpunktnummer");
header.createCell(1).setCellValue("Typ");
header.createCell(2).setCellValue("Von");
header.createCell(3).setCellValue("Bis");
header.createCell(4).setCellValue("kWh");
header.createCell(5).setCellValue("Zählerstand");
CellStyle dateStyle = workbook.createCellStyle();
dateStyle.setDataFormat(helper.createDataFormat().getFormat("yyyy-mm-dd hh:mm"));
for (int i = 0; i < dataRows.length; i++) {
Row row = sheet.createRow(i + 1);
Object[] data = dataRows[i];
row.createCell(0).setCellValue((String) data[0]);
row.createCell(1).setCellValue((String) data[1]);
// Write dates as numeric cells with date formatting (triggers getLocalDateTimeCellValue path)
Cell vonCell = row.createCell(2);
vonCell.setCellValue((LocalDateTime) data[2]);
vonCell.setCellStyle(dateStyle);
Cell bisCell = row.createCell(3);
bisCell.setCellValue((LocalDateTime) data[3]);
bisCell.setCellStyle(dateStyle);
Cell kwhCell = row.createCell(4);
kwhCell.setCellValue((Double) data[4]);
if (data[5] != null) {
row.createCell(5).setCellValue((Double) data[5]);
}
}
workbook.write(out);
return out.toByteArray();
}
}
private byte[] createTestXlsx(String[]... dataRows) throws IOException {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {

View File

@ -0,0 +1,12 @@
spring:
datasource:
url: jdbc:h2:mem:testdb_upload;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate.jdbc.time_zone: UTC
show-sql: false