- Add TariffInviteServiceTest, GlobalExceptionHandlerTest, setup-test.ts - Add compose plans/specs docs - Extend .gitignore with *.xlsx, *.ps1, *.http, *.py, *.docx - Remove stale test data files and scripts from working tree
18 KiB
Plan: Code-Review-Fixes — Kritische und hohe Befunde
Task-Übersicht
| ID | Beschreibung | Schwere | Abhängigkeit |
|---|---|---|---|
| T1 | GlobalExceptionHandler: MethodArgumentNotValidException-Handler | KRITISCH | — |
| T2 | GlobalExceptionHandler: AtNumberAlreadyExistsException-Handler fix | HOCH | — |
| T3 | EnergyCommunityAdminController: @PreAuthorize hinzufügen | KRITISCH | — |
| T4 | NotificationController/Service: IDOR-Fix (markAsRead) | KRITISCH | — |
| T5 | AdminIamService.rejectUser: ResponseStatusException → IAE/ISE | HOCH | — |
| T6 | EnergyCommunityService: ResponseStatusException → IAE/ISE | HOCH | — |
| T7 | TariffService: validateUserTariffRequest() extrahieren | HOCH | — |
| T8 | MeteringPoint.memberships: orphanRemoval=true | HOCH | — |
| T9 | application-prod.yml: ddl-auto Default auf validate | HOCH | — |
| T10 | AdminIamServiceTest: rejectUser-Tests aktualisieren | HOCH | T5 |
| T11 | GlobalExceptionHandlerTest: neue Tests schreiben | HOCH | T1, T2 |
| T12 | NotificationServiceTest: Ownership-Check testen | HOCH | T4 |
| T13 | TariffServiceTest: Tests nach Refactoring prüfen | HOCH | T7 |
| T14 | Alle Backend-Tests ausführen | HOCH | T1–T13 |
T1: GlobalExceptionHandler — MethodArgumentNotValidException-Handler
Beschreibung: Füge einen @ExceptionHandler(MethodArgumentNotValidException.class) hinzu, der Spring's @Valid-Fehler im konsistenten ErrorResponse-Format zurückgibt.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandler.java
Akzeptanz:
- Handler vorhanden mit
@ExceptionHandler(MethodArgumentNotValidException.class) - Extrahiert
BindingResult-Fehler als komma-separierte Nachricht - Gibt 400 BAD_REQUEST mit
ErrorResponsezurück - Logging auf WARN-Level
Details:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.joining(", "));
log.warn("Validation error: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(message, HttpStatus.BAD_REQUEST.value()));
}
Import: org.springframework.validation.BindException oder MethodArgumentNotValidException + org.springframework.web.bind.MethodArgumentNotValidException + java.util.stream.Collectors.
T2: GlobalExceptionHandler — AtNumberAlreadyExistsException-Handler fix
Beschreibung: Ersetze den hardcoded String durch ex.getMessage().
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandler.java
Akzeptanz:
- Zeile 59:
"Zählerpunkt existiert bereits"→ex.getMessage() - Kompiliert ohne Fehler
Details: Vorher:
.body(new ErrorResponse("Zählerpunkt existiert bereits", HttpStatus.CONFLICT.value()));
Nachher:
.body(new ErrorResponse(ex.getMessage(), HttpStatus.CONFLICT.value()));
T3: EnergyCommunityAdminController — @PreAuthorize hinzufügen
Beschreibung: Füge @PreAuthorize("hasRole('ADMIN')") auf Klassenebene hinzu, konsistent mit AdminIamController.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/EnergyCommunityAdminController.java
Akzeptanz:
@PreAuthorize("hasRole('ADMIN')")auf Klassenebene (nach@RequestMapping)- Import für
org.springframework.security.access.prepost.PreAuthorizevorhanden - Kompiliert ohne Fehler
Details:
@RestController
@RequestMapping("/api/admin/energy-communities")
@PreAuthorize("hasRole('ADMIN')")
@RequiredArgsConstructor
public class EnergyCommunityAdminController {
T4: NotificationController/Service — IDOR-Fix (markAsRead)
Beschreibung: Füge @CurrentUserId zum Controller hinzu und einen Ownership-Check im Service.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/common/api/NotificationController.javaeeg_backend/src/main/java/at/mueller/eeg/backend/common/service/NotificationService.java
Akzeptanz:
- Controller:
markAsRead(@PathVariable UUID id, @CurrentUserId String userId) - Controller ruft
notificationService.markAsRead(id, UUID.fromString(userId)) - Service:
markAsRead(UUID id, UUID userId)— Ownership-Check - Wenn
notification.getUserId().equals(userId)nicht zutrifft:AccessDeniedException - Import für
org.springframework.security.access.AccessDeniedExceptionim Service - Kompiliert ohne Fehler
Details:
Controller:
@PutMapping("/{id}/read")
@PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')")
public ResponseEntity<Void> markAsRead(@PathVariable UUID id, @CurrentUserId String userId) {
notificationService.markAsRead(id, UUID.fromString(userId));
return ResponseEntity.ok().build();
}
Service:
@Transactional
public void markAsRead(UUID id, UUID userId) {
Notification notification = notificationRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Notification nicht gefunden: " + id));
if (!notification.getUserId().equals(userId)) {
throw new AccessDeniedException("Keine Berechtigung, diese Notification als gelesen zu markieren.");
}
notification.setRead(true);
notificationRepository.save(notification);
}
T5: AdminIamService.rejectUser — ResponseStatusException → IAE/ISE
Beschreibung: Ersetze ResponseStatusException durch IllegalArgumentException/IllegalStateException, konsistent mit approveUser.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AdminIamService.java
Akzeptanz:
- Zeile 56:
ResponseStatusException(NOT_FOUND)→IllegalArgumentException("User nicht gefunden") - Zeile 59:
ResponseStatusException(BAD_REQUEST)→IllegalStateException("Nur User im Status PENDING können abgelehnt werden.") - Importe für
HttpStatusundResponseStatusExceptionkönnen entfernt werden (prüfen ob noch used) - Kompiliert ohne Fehler
Details:
@Transactional
public void rejectUser(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User nicht gefunden"));
if (user.getStatus() != RegistrationStatus.PENDING) {
throw new IllegalStateException("Nur User im Status PENDING können abgelehnt werden.");
}
user.setStatus(RegistrationStatus.REJECTED);
userRepository.save(user);
eventPublisher.publishEvent(new UserRejectedEvent(user.getId()));
}
T6: EnergyCommunityService — ResponseStatusException → IAE/ISE
Beschreibung: Ersetze alle ResponseStatusException durch IllegalArgumentException/IllegalStateException.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/EnergyCommunityService.java
Akzeptanz:
update():ResponseStatusException(NOT_FOUND)→IllegalArgumentException("Energiegemeinschaft nicht gefunden: " + id)delete():ResponseStatusException(CONFLICT)→IllegalStateException("Die Energiegemeinschaft kann nicht gelöscht werden, da noch Zählpunkte zugeordnet sind.")- Importe für
HttpStatusundResponseStatusExceptionkönnen entfernt werden - Kompiliert ohne Fehler
Details:
@Transactional
public EnergyCommunityDto update(UUID id, EnergyCommunityDto dto) {
EnergyCommunity existingEntity = energyCommunityRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Energiegemeinschaft nicht gefunden: " + id));
// ... rest unchanged
}
@Transactional
public void delete(UUID id) {
if (!energyCommunityRepository.existsById(id)) {
throw new IllegalArgumentException("Energy Community mit ID " + id + " existiert nicht.");
}
if (membershipRepository.existsByEnergyCommunityId(id)) {
throw new IllegalStateException("Die Energiegemeinschaft kann nicht gelöscht werden, da noch Zählpunkte zugeordnet sind.");
}
energyCommunityRepository.deleteById(id);
}
T7: TariffService — validateUserTariffRequest() extrahieren
Beschreibung: Extrahiere die gemeinsame Validierungslogik in eine private Methode.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/tariff/service/TariffService.java
Akzeptanz:
- Private Methode
validateUserTariffRequest(UUID communityId, UserTariffRequest request)vorhanden - Enthält: source != target, Points vorhanden, Points ACTIVE, Community-Tarif vorhanden, Preis <= Max, beide User aktive Members
createUserTariffruftvalidateUserTariffRequestauf + Duplicate-Tarif-Check + Invite-CheckupdateUserTariffruftvalidateUserTariffRequestauf + Ownership-Check (Ownership-Check bleibt vor der Validierung)- Alle 25 bestehenden TariffServiceTest weiterhin grün
- Kompiliert ohne Fehler
Details:
Die Methode nimmt communityId und request, lädt die Points, und wirft Exceptions. Rückgabe: Pair von (sourcePoint, targetPoint) odervoid (die Points werden für die Ownership-Check-Logik in updateUserTariff nicht gebraucht, da der Ownership-Check vorher erfolgt).
private void validateUserTariffRequest(UUID communityId, UserTariffRequest request) {
if (request.sourceMeteringPointId().equals(request.targetMeteringPointId())) {
throw new IllegalStateException("Quell- und Ziel-Zählpunkt müssen unterschiedlich sein.");
}
MeteringPoint sourcePoint = meteringPointRepository.findById(request.sourceMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException(
"Quell-Zählpunkt nicht gefunden: " + request.sourceMeteringPointId()));
MeteringPoint targetPoint = meteringPointRepository.findById(request.targetMeteringPointId())
.orElseThrow(() -> new IllegalArgumentException(
"Ziel-Zählpunkt nicht gefunden: " + request.targetMeteringPointId()));
if (sourcePoint.getMakoState() != MakoState.ACTIVE) {
throw new IllegalStateException("Quell-Zählpunkt ist nicht aktiv.");
}
if (targetPoint.getMakoState() != MakoState.ACTIVE) {
throw new IllegalStateException("Ziel-Zählpunkt ist nicht aktiv.");
}
CommunityTariff communityTariff = communityTariffRepository.findByEnergyCommunityId(communityId)
.orElseThrow(() -> new IllegalArgumentException(
"Kein Community-Tarif für Energiegemeinschaft vorhanden: " + communityId));
if (request.pricePerKwhCents().compareTo(communityTariff.getMaxPricePerKwhCents()) > 0) {
throw new IllegalStateException(
"Preis darf den Maximalpreis des Community-Tarifs nicht überschreiten. " +
"Maximalpreis: " + communityTariff.getMaxPricePerKwhCents() + " Cent/kWh");
}
UUID sourceUserId = sourcePoint.getUserId();
UUID targetUserId = targetPoint.getUserId();
if (!membershipRepository.isActiveMemberOfCommunity(sourceUserId, communityId)) {
throw new IllegalStateException("Quell-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
if (!membershipRepository.isActiveMemberOfCommunity(targetUserId, communityId)) {
throw new IllegalStateException("Ziel-Benutzer ist kein Mitglied der Energiegemeinschaft.");
}
}
Achtung: updateUserTariff muss den Ownership-Check BEIDER Validierung durchführen, da der eigene SourcePoint für den Ownership-Check gebraucht wird. Die Validierungsmethode lädt die Points ohnehin, aber der Ownership-Check muss vorher erfolgen (auf dem ursprünglichen tariff.getSourceMeteringPointId(), nicht auf request.sourceMeteringPointId()).
T8: MeteringPoint.memberships — orphanRemoval=true
Beschreibung: Füge orphanRemoval = true zum @OneToMany-Mapping hinzu.
Dateien:
eeg_backend/src/main/java/at/mueller/eeg/backend/community/domain/MeteringPoint.java
Akzeptanz:
- Zeile 51:
cascade = CascadeType.ALL→cascade = CascadeType.ALL, orphanRemoval = true - Kompiliert ohne Fehler
Details:
@OneToMany(mappedBy = "meteringPoint", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Membership> memberships;
T9: application-prod.yml — ddl-auto Default auf validate
Beschreibung: Ändere den Default-Wert für ddl-auto von update auf validate.
Dateien:
eeg_backend/src/main/resources/application-prod.yml
Akzeptanz:
- Zeile 9:
ddl-auto: ${JPA_DDL_AUTO:update}→ddl-auto: ${JPA_DDL_AUTO:validate} - Environment-Variable
JPA_DDL_AUTOkann weiterhin überschreiben
Details:
ddl-auto: ${JPA_DDL_AUTO:validate}
T10: AdminIamServiceTest — rejectUser-Tests aktualisieren
Beschreibung: Aktualisiere die Tests für rejectUser, da die Exception-Typen sich ändern (ResponseStatusException → IllegalArgumentException/IllegalStateException).
Dateien:
eeg_backend/src/test/java/at/mueller/eeg/backend/iam/service/AdminIamServiceTest.java
Akzeptanz:
rejectUser_notPending_throws():assertThrows(ResponseStatusException.class, ...)→assertThrows(IllegalStateException.class, ...)rejectUser_notFound_throws():assertThrows(ResponseStatusException.class, ...)→assertThrows(IllegalArgumentException.class, ...)- Import für
ResponseStatusExceptionkann entfernt werden (prüfen ob noch used) - Alle Tests grün
Details:
@Test
void rejectUser_notPending_throws() {
testUser.setStatus(RegistrationStatus.APPROVED);
when(userRepository.findById(testUser.getId())).thenReturn(Optional.of(testUser));
assertThrows(IllegalStateException.class, () -> adminIamService.rejectUser(testUser.getId()));
verify(userRepository, never()).save(any());
}
@Test
void rejectUser_notFound_throws() {
UUID unknownId = UUID.randomUUID();
when(userRepository.findById(unknownId)).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class, () -> adminIamService.rejectUser(unknownId));
}
T11: GlobalExceptionHandlerTest — neue Tests schreiben
Beschreibung: Erstelle Unit-Tests für den GlobalExceptionHandler.
Dateien:
eeg_backend/src/test/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandlerTest.java(neu)
Akzeptanz:
- Test für
MethodArgumentNotValidException→ 400 mit Fehlermeldungen - Test für
AtNumberAlreadyExistsException→ 409 mit ex.getMessage() - Test für
IllegalArgumentException→ 400 - Test für
IllegalStateException→ 409 - Test für
AccessDeniedException→ 403 - Test für
BadCredentialsException→ 401 - Test für
DisabledException→ 403 - Test für
Exception(catch-all) → 500 - Alle Tests grün
Details:
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
private final GlobalExceptionHandler handler = new GlobalExceptionHandler();
@Test
void handleValidation_returns400WithFieldErrors() {
// Mock MethodArgumentNotValidException mit BindingResult
// Prüfe: 400, ErrorResponse mit Feldfehlern
}
@Test
void handleAtNumberAlreadyExists_returns409WithMessage() {
AtNumberAlreadyExistsException ex = new AtNumberAlreadyExistsException("AT-Nummer 'AT123' existiert bereits.");
ResponseEntity<ErrorResponse> response = handler.handleAtNumberAlreadyExists(ex);
assertEquals(409, response.getStatusCode().value());
assertEquals("AT-Nummer 'AT123' existiert bereits.", response.getBody().message());
}
// ... weitere Tests
}
T12: NotificationServiceTest — Ownership-Check testen
Beschreibung: Aktualisiere den Test für markAsRead und füge Ownership-Check-Tests hinzu.
Dateien:
eeg_backend/src/test/java/at/mueller/eeg/backend/common/service/NotificationServiceTest.java
Akzeptanz:
markAsRead_setsReadToTrue()aktualisiert: Aufruf mit(id, userId)- Neuer Test:
markAsRead_throwsIfNotOwner()— Ownership-Check - Neuer Test:
markAsRead_throwsIfNotFound()— nicht existierende ID - Alle Tests grün
Details:
@Test
void markAsRead_setsReadToTrue() {
when(notificationRepository.findById(testNotification.getId()))
.thenReturn(Optional.of(testNotification));
when(notificationRepository.save(any())).thenReturn(testNotification);
notificationService.markAsRead(testNotification.getId(), testUserId);
assertTrue(testNotification.isRead());
verify(notificationRepository).save(testNotification);
}
@Test
void markAsRead_throwsIfNotOwner() {
UUID otherUserId = UUID.randomUUID();
when(notificationRepository.findById(testNotification.getId()))
.thenReturn(Optional.of(testNotification));
assertThrows(AccessDeniedException.class,
() -> notificationService.markAsRead(testNotification.getId(), otherUserId));
verify(notificationRepository, never()).save(any());
}
@Test
void markAsRead_throwsIfNotFound() {
UUID unknownId = UUID.randomUUID();
when(notificationRepository.findById(unknownId)).thenReturn(Optional.empty());
assertThrows(IllegalArgumentException.class,
() -> notificationService.markAsRead(unknownId, testUserId));
}
T13: TariffServiceTest — Tests nach Refactoring prüfen
Beschreibung: Führe alle 25 TariffServiceTests aus und prüfe, ob sie nach dem Refactoring (T7) weiterhin grün sind.
Dateien:
eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffServiceTest.java
Akzeptanz:
- Alle 25 Tests grün
- Keine neuen Tests nötig (Refactoring ohne Verhaltensänderung)
T14: Alle Backend-Tests ausführen
Beschreibung: Führe die gesamte Backend-Test-Suite aus, um Regressionen auszuschließen.
Befehl:
.\mvnw test -pl eeg_backend
Akzeptanz:
- Alle Tests grün
- Keine Compile-Fehler