From cfc32b3a7d35a642cac340dcfb7cdd7ee4427ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernhard=20M=C3=BCller?= Date: Fri, 24 Jul 2026 11:48:56 +0200 Subject: [PATCH] chore: cleanup test data and add missing test files - 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 --- .gitignore | 5 + .../plans/code-review-critical-fixes.md | 468 ++++++++++++++++++ ...eiterung-der-admin-bersichten-im-eeg-po.md | 344 +++++++++++++ .../specs/code-review-critical-fixes.md | 133 +++++ ...eiterung-der-admin-bersichten-im-eeg-po.md | 76 +++ .../exception/GlobalExceptionHandlerTest.java | 109 ++++ .../service/TariffInviteServiceTest.java | 238 +++++++++ eeg_frontend/src/setup-test.ts | 12 + 8 files changed, 1385 insertions(+) create mode 100644 docs/compose/plans/code-review-critical-fixes.md create mode 100644 docs/compose/plans/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md create mode 100644 docs/compose/specs/code-review-critical-fixes.md create mode 100644 docs/compose/specs/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md create mode 100644 eeg_backend/src/test/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandlerTest.java create mode 100644 eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffInviteServiceTest.java create mode 100644 eeg_frontend/src/setup-test.ts diff --git a/.gitignore b/.gitignore index ccc9287..03cce1f 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,11 @@ eeg_frontend/.angular/ ### Test-Dateien ### test_*.json +*.xlsx +*.ps1 +*.http +*.py +*.docx ### Projekt-spezifisch ### /projekt_kontext.txt diff --git a/docs/compose/plans/code-review-critical-fixes.md b/docs/compose/plans/code-review-critical-fixes.md new file mode 100644 index 0000000..4c399bc --- /dev/null +++ b/docs/compose/plans/code-review-critical-fixes.md @@ -0,0 +1,468 @@ +# 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 `ErrorResponse` zurück +- [ ] Logging auf WARN-Level + +**Details:** +```java +@ExceptionHandler(MethodArgumentNotValidException.class) +public ResponseEntity 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: +```java +.body(new ErrorResponse("Zählerpunkt existiert bereits", HttpStatus.CONFLICT.value())); +``` +Nachher: +```java +.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.PreAuthorize` vorhanden +- [ ] Kompiliert ohne Fehler + +**Details:** +```java +@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.java` +- `eeg_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.AccessDeniedException` im Service +- [ ] Kompiliert ohne Fehler + +**Details:** + +Controller: +```java +@PutMapping("/{id}/read") +@PreAuthorize("hasRole('MEMBER') or hasRole('ADMIN')") +public ResponseEntity markAsRead(@PathVariable UUID id, @CurrentUserId String userId) { + notificationService.markAsRead(id, UUID.fromString(userId)); + return ResponseEntity.ok().build(); +} +``` + +Service: +```java +@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 `HttpStatus` und `ResponseStatusException` können entfernt werden (prüfen ob noch used) +- [ ] Kompiliert ohne Fehler + +**Details:** +```java +@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 `HttpStatus` und `ResponseStatusException` können entfernt werden +- [ ] Kompiliert ohne Fehler + +**Details:** +```java +@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 +- [ ] `createUserTariff` ruft `validateUserTariffRequest` auf + Duplicate-Tarif-Check + Invite-Check +- [ ] `updateUserTariff` ruft `validateUserTariffRequest` auf + 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). + +```java +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:** +```java +@OneToMany(mappedBy = "meteringPoint", cascade = CascadeType.ALL, orphanRemoval = true) +private List 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_AUTO` kann weiterhin überschreiben + +**Details:** +```yaml +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 `ResponseStatusException` kann entfernt werden (prüfen ob noch used) +- [ ] Alle Tests grün + +**Details:** +```java +@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:** +```java +@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 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:** +```java +@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:** +```bash +.\mvnw test -pl eeg_backend +``` + +**Akzeptanz:** +- [ ] Alle Tests grün +- [ ] Keine Compile-Fehler diff --git a/docs/compose/plans/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md b/docs/compose/plans/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md new file mode 100644 index 0000000..f881394 --- /dev/null +++ b/docs/compose/plans/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md @@ -0,0 +1,344 @@ +# Plan: Admin-Übersichten erweitern — Alle Mitgliedschaften & Alle Benutzer + +## Task-Übersicht + +| ID | Beschreibung | Abhängigkeit | Typ | +|----|-------------|--------------|-----| +| T1 | User.java: createdAt-Feld hinzufügen | — | Backend | +| T2 | AuthService.register(): createdAt setzen | T1 | Backend | +| T3 | UserRepository: findAllByOrderByCreatedAtDesc() | T1 | Backend | +| T4 | MembershipRepository: findAllWithDetails() | — | Backend | +| T5 | MembershipService: getAllMemberships() | T4 | Backend | +| T6 | MembershipController: GET /admin/memberships | T5 | Backend | +| T7 | AdminIamService: getAllUsers() | T3 | Backend | +| T8 | AdminIamController: GET /api/admin/users | T7 | Backend | +| T9 | PendingMembershipResponse: validFrom/validTo erweitern | — | Backend | +| T10 | Backend-Tests schreiben | T6, T8, T9 | Test | +| T11 | OpenAPI-Spec generieren + Frontend-API-Client regenerieren | T6, T8 | Build | +| T12 | Frontend MembershipService: getAllMemberships() | T11 | Frontend | +| T13 | AdminMembershipsComponent: Tab-Navigation + Tabelle | T12, T9 | Frontend | +| T14 | AdminUserApprovalComponent: Tab-Navigation + Tabelle | T11 | Frontend | +| T15 | Integrations-Test: UI manuell prüfen | T13, T14 | Test | + +--- + +## T1: User.java — createdAt-Feld hinzufügen + +**Beschreibung:** Füge der User-Entity ein `createdAt`-Feld vom Typ `LocalDateTime` hinzu. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/iam/domain/User.java` + +**Akzeptanz:** +- [ ] `createdAt` Feld mit `@Column(updatable = false)` vorhanden +- [ ] Kein `@GeneratedValue` — wird manuell gesetzt +- [ ] Kompiliert ohne Fehler +- [ ] `ddl-auto: update` erstellt die Spalte automatisch + +**Details:** +```java +@Column(name = "created_at", updatable = false) +private LocalDateTime createdAt; +``` + +--- + +## T2: AuthService.register() — createdAt setzen + +**Beschreibung:** Setze `createdAt` auf `LocalDateTime.now()` in der `register()`-Methode. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AuthService.java` + +**Akzeptanz:** +- [ ] `newUser.setCreatedAt(LocalDateTime.now())` vor `userRepository.save(newUser)` +- [ ] Import für `LocalDateTime` vorhanden + +**Details:** +Zeile ~77, nach `newUser.setVerificationToken(token)`: +```java +newUser.setCreatedAt(LocalDateTime.now()); +``` + +--- + +## T3: UserRepository — findAllByOrderByCreatedAtDesc() + +**Beschreibung:** Füge eine Spring Data Query-Methode hinzu, die alle Users absteigend nach Erstellungszeitpunkt sortiert. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/iam/repository/UserRepository.java` + +**Akzeptanz:** +- [ ] Methode `List findAllByOrderByCreatedAtDesc()` vorhanden +- [ ] Kompiliert ohne Fehler + +**Details:** +```java +List findAllByOrderByCreatedAtDesc(); +``` + +--- + +## T4: MembershipRepository — findAllWithDetails() + +**Beschreibung:** Füge eine JPQL-Query hinzu, die alle Mitgliedschaften mit JOIN FETCH für MeteringPoint und EnergyCommunity zurückgibt. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MembershipRepository.java` + +**Akzeptanz:** +- [ ] `@Query` mit JOIN FETCH auf meteringPoint und energyCommunity +- [ ] Kein WHERE-Clause (alle Status) +- [ ] Kompiliert ohne Fehler + +**Details:** +```java +@Query("SELECT m FROM Membership m JOIN FETCH m.meteringPoint mp JOIN FETCH m.energyCommunity ec") +List findAllWithDetails(); +``` + +--- + +## T5: MembershipService — getAllMemberships() + +**Beschreibung:** Neue Methode, die alle Mitgliedschaften lädt und als `PendingMembershipResponse`-Liste zurückgibt (mit User-Lookup). + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MembershipService.java` + +**Akzeptanz:** +- [ ] `@Transactional(readOnly = true)` Annotation +- [ ] Nutzt `findAllWithDetails()` +- [ ] Gleiche User-Lookup-Logik wie `getPendingMemberships()` (Batch-Fetch aller User-IDs) +- [ ] Gibt `List` zurück + +**Details:** +Kopiere die User-Lookup-Logik aus `getPendingMemberships()`, aber entferne den WHERE-Clause auf Status. Das `PendingMembershipResponse`-DTO muss um `validFrom`/`validTo` erweitert werden (siehe T9). + +--- + +## T6: MembershipController — GET /admin/memberships + +**Beschreibung:** Neuer Admin-Endpoint für alle Mitgliedschaften. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MembershipController.java` + +**Akzeptanz:** +- [ ] `@GetMapping("/admin/memberships")` mit `@PreAuthorize("hasRole('ADMIN')")` +- [ ] Gibt `ResponseEntity>` zurück +- [ ] Swagger-Annotationen (`@Operation`, `@ApiResponse`) + +**Details:** +```java +@GetMapping("/admin/memberships") +@PreAuthorize("hasRole('ADMIN')") +@Operation(summary = "Lädt alle Mitgliedschaften", description = "Zeigt alle Mitgliedschaften aller Status.") +public ResponseEntity> getAllMemberships() { + return ResponseEntity.ok(membershipService.getAllMemberships()); +} +``` + +--- + +## T7: AdminIamService — getAllUsers() + +**Beschreibung:** Neue Methode, die alle Users sortiert nach Erstellungszeitpunkt zurückgibt. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/iam/service/AdminIamService.java` + +**Akzeptanz:** +- [ ] `@Transactional(readOnly = true)` Annotation +- [ ] Nutzt `userRepository.findAllByOrderByCreatedAtDesc()` +- [ ] Gibt `List` zurück + +**Details:** +```java +@Transactional(readOnly = true) +public List getAllUsers() { + return userRepository.findAllByOrderByCreatedAtDesc(); +} +``` + +--- + +## T8: AdminIamController — GET /api/admin/users + +**Beschreibung:** Neuer Admin-Endpoint für alle Benutzer. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/iam/api/AdminIamController.java` + +**Akzeptanz:** +- [ ] `@GetMapping` auf Root-Pfad `/api/admin/users` +- [ ] Nutzt `adminIamService.getAllUsers()` + `userMapper.mapToDto()` +- [ ] Gibt `ResponseEntity>` zurück +- [ ] Swagger-Annotationen + +**Details:** +```java +@GetMapping +@Operation(summary = "Lädt alle User", description = "Zeigt alle registrierten Benutzer.") +public ResponseEntity> getAllUsers() { + return ResponseEntity.ok(userMapper.mapToDto(adminIamService.getAllUsers())); +} +``` + +--- + +## T9: PendingMembershipResponse — validFrom/validTo erweitern + +**Beschreibung:** Erweitere das DTO-Record um `validFrom` und `validTo` für alle Mitgliedschaften. + +**Dateien:** +- `eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/dto/PendingMembershipResponse.java` + +**Akzeptanz:** +- [ ] Record hat neue Felder `LocalDate validFrom`, `LocalDate validTo` +- [ ] `getPendingMemberships()` in MembershipService setzt beide Felder (null für PENDING) +- [ ] `getAllMemberships()` setzt beide Felder aus Entity + +**Details:** +```java +record PendingMembershipResponse( + UUID id, + String userEmail, + String firstName, + String lastName, + String atNumber, + String communityName, + Integer priorityLevel, + String status, + LocalDate validFrom, + LocalDate validTo +) {} +``` + +Achtung: Da `PendingMembershipResponse` ein Java record ist, müssen ALLE Aufrufstellen aktualisiert werden, die dieses DTO erstellen. Insbesondere `getPendingMemberships()` in `MembershipService`. + +--- + +## T10: Backend-Tests schreiben + +**Beschreibung:** Unit-Tests für die neuen Service-Methoden und Integrationstests für die neuen Endpoints. + +**Dateien:** +- `eeg_backend/src/test/java/.../community/service/MembershipServiceTest.java` (neu oder erweitern) +- `eeg_backend/src/test/java/.../iam/service/AdminIamServiceTest.java` (neu oder erweitern) + +**Akzeptanz:** +- [ ] Test für `getAllMemberships()`: gibt alle Status zurück +- [ ] Test für `getAllUsers()`: gibt alle Users sortiert zurück +- [ ] Bestehende Tests weiterhin grün +- [ ] `.\mvnw test -pl eeg_backend` erfolgreich + +--- + +## T11: OpenAPI-Spec generieren + Frontend-API-Client regenerieren + +**Beschreibung:** Generiere die OpenAPI-Spec aus dem Backend und regeneriere den Frontend-API-Client. + +**Befehle:** +```bash +.\mvnw test -pl eeg_backend -Dtest=OpenApiGeneratorTest +cd eeg_frontend && npm run generate-api +``` + +**Akzeptanz:** +- [ ] `eeg_frontend/openapi.yaml` enthält neuen Endpoint `/api/community/admin/memberships` +- [ ] `eeg_frontend/openapi.yaml` enthält neuen Endpoint `/api/admin/users` (Root) +- [ ] Generierter `AdminIAMService` enthält `getAllUsers()` +- [ ] Keine Type-Errors im Frontend + +--- + +## T12: Frontend MembershipService — getAllMemberships() + +**Beschreibung:** Neue Methode im Frontend MembershipService. + +**Dateien:** +- `eeg_frontend/src/app/services/membership.ts` + +**Akzeptanz:** +- [ ] `getAllMemberships(): Observable` vorhanden +- [ ] Nutzt `GET /api/community/admin/memberships` + +**Details:** +```typescript +getAllMemberships(): Observable { + return this.http.get(`${this.apiUrl}/admin/memberships`); +} +``` + +--- + +## T13: AdminMembershipsComponent — Tab-Navigation + Tabelle + +**Beschreibung:** Erweitere die Admin-Mitgliedschafts-Seite mit Tab-Navigation und Tabellen-Ansicht. + +**Dateien:** +- `eeg_frontend/src/app/pages/admin-memberships/admin-memberships.ts` +- `eeg_frontend/src/app/pages/admin-memberships/admin-memberships.html` + +**Akzeptanz:** +- [ ] Tab-Leiste: "Ausstehende Anträge" | "Aktive Mitgliedschaften" | "Alle" +- [ ] Signal `activeTab: signal<'pending' | 'active' | 'all'>('pending')` +- [ ] Signal `allMemberships: signal([])` +- [ ] `loadAllMemberships()` lädt alle Daten beim Wechsel auf Tab "Aktive" oder "Alle" +- [ ] Tab "Ausstehende": Bestehende Karten-Ansicht mit Genehmigen/Ablehnen +- [ ] Tab "Aktive" + "Alle": Tabellen-Ansicht (Name, Email, Community, Zählpunkt, Priorität, Status) +- [ ] Nur PENDING zeigt Aktions-Buttons +- [ ] Hilfsfunktion `getStatusLabel(status: string): string` für deutsche Anzeige +- [ ] Loading-Zustand für Tab-Wechsel + +**Details:** +- Client-seitige Filterung: Beim Laden werden alle Mitgliedschaften in `allMemberships` gespeichert +- Tab "Ausstehende" filtert nach `status === 'PENDING'` +- Tab "Aktive" filtert nach `status === 'ACTIVE'` +- Tab "Alle" zeigt alle +- Tabellen-Header: Benutzer | Email | Gemeinschaft | Zählpunkt | Priorität | Status + +--- + +## T14: AdminUserApprovalComponent — Tab-Navigation + Tabelle + +**Beschreibung:** Erweitere die Admin-Benutzerverwaltung mit Tab-Navigation und Tabelle für alle Users. + +**Dateien:** +- `eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.ts` +- `eeg_frontend/src/app/pages/admin-user-approval/admin-user-approval.html` + +**Akzeptanz:** +- [ ] Tab-Leiste: "Ausstehende Anträge" | "Alle Benutzer" +- [ ] Signal `activeTab: signal<'pending' | 'all'>('pending')` +- [ ] Signal `allUsers: signal([])` +- [ ] `loadAllUsers()` nutzt generierten `AdminIAMService.getAllUsers()` +- [ ] Tab "Ausstehende": Bestehende Tabelle mit Freigeben/Ablehnen +- [ ] Tab "Alle": Tabelle mit Name, Email, Status, Rolle (keine Aktions-Buttons) +- [ ] Status-Badge: PENDING=gelb, APPROVED=grün, REJECTED=rot +- [ ] Hilfsfunktion `getStatusLabel(status: string): string` +- [ ] Bug fix: Doppelte "Status"-Spalten-Überschrift beheben (Zeile 32-33 in aktuellem Template) + +**Details:** +- Tab "Ausstehende" lädt weiterhin `getPendingUsers()` (nur verifizierte PENDING-User) +- Tab "Alle" lädt `getAllUsers()` beim ersten Wechsel +- Status-Badges: `bg-yellow-100 text-yellow-800` (PENDING), `bg-green-100 text-green-800` (APPROVED), `bg-red-100 text-red-800` (REJECTED) + +--- + +## T15: Integrations-Test — UI manuell prüfen + +**Beschreibung:** Manuelle Überprüfung der Admin-Seiten im Browser. + +**Akzeptanz:** +- [ ] Backend startet ohne Fehler +- [ ] `GET /api/community/admin/memberships` gibt alle Mitgliedschaften zurück +- [ ] `GET /api/admin/users` gibt alle Benutzer zurück +- [ ] Admin öffnet Mitgliedschaften → sieht Tabs "Ausstehende | Aktive | Alle" +- [ ] Admin öffnet Benutzerverwaltung → sieht Tabs "Ausstehende | Alle" +- [ ] Tab-Wechsel funktioniert sofort (kein Ladevorgang nötig außer beim ersten Laden) +- [ ] Genehmigen/Ablehnen funktioniert weiterhin auf dem "Ausstehende"-Tab +- [ ] Status-Badges werden korrekt angezeigt +- [ ] `.\mvnw test -pl eeg_backend` alle Tests grün diff --git a/docs/compose/specs/code-review-critical-fixes.md b/docs/compose/specs/code-review-critical-fixes.md new file mode 100644 index 0000000..d9f7e5a --- /dev/null +++ b/docs/compose/specs/code-review-critical-fixes.md @@ -0,0 +1,133 @@ +# Spec: Code-Review-Fixes — Kritische und hohe Befunde + +## Zusammenfassung + +Behebung von 3 kritischen und 5 hohen Code-Review-Findings im EEG Portal Backend. Die kritischen Finds betreffen Sicherheitslücken (fehlende Authorization, IDOR) und inkonsistente Fehlerbehandlung. Die hohen Finds betreffen Code-Qualität, Konsistenz und ein Produktionsrisiko. + +## Ausgangslage + +Ein Code-Review hat 8 Befunde identifiziert: + +| # | Schwere | Befund | +|---|---------|--------| +| 1 | KRITISCH | `EnergyCommunityAdminController` fehlt `@PreAuthorize` — Admin-Endpunkte ohne Rollenprüfung | +| 2 | KRITISCH | `NotificationService.markAsRead` hat IDOR — jede User kann beliebige Notifications lesen | +| 3 | KRITISCH | `GlobalExceptionHandler` fehlt `MethodArgumentNotValidException`-Handler — @Valid-Fehler → 500 statt 400 | +| 4 | HOCH | Inkonsistente Exception-Strategy — `ResponseStatusException` bypassed `GlobalExceptionHandler` | +| 5 | HOCH | `TariffService` Code-Duplizierung — ~30 Zeilen validierende Logik doppelt | +| 6 | HOCH | `AtNumberAlreadyExistsException`-Handler verwirft Exception-Message | +| 7 | HOCH | `MeteringPoint.memberships` — `CascadeType.ALL` ohne `orphanRemoval` (latentes Risiko) | +| 8 | HOCH | `ddl-auto: update` als Prod-Default — kann Schema still ändern | + +## Befund-Detail + +### 1. Fehlende @PreAuthorize auf EnergyCommunityAdminController (KRITISCH) + +**Datei:** `eeg_backend/.../community/api/EnergyCommunityAdminController.java:18-21` + +**Ist-Zustand:** Kein `@PreAuthorize` auf Klassenebene. Endpunkte unter `/api/admin/energy-communities` sind nur durch URL-Muster gesichert (SecurityFilterChain), nicht durch annotation-basierte Authorization. Im Vergleich: `AdminIamController` hat `@PreAuthorize("hasRole('ADMIN')")` auf Klassenebene. + +**Soll-Zustand:** `@PreAuthorize("hasRole('ADMIN')")` auf Klassenebene, konsistent mit `AdminIamController`. + +**Risiko:** Jeder authentifizierte MEMBER kann Admin-Endpoints aufrufen (Community erstellen, bearbeiten, löschen), sofern der SecurityFilterChain keine explizite URL-Prüfung hat. + +### 2. IDOR in NotificationService.markAsRead (KRITISCH) + +**Dateien:** +- `eeg_backend/.../common/api/NotificationController.java:33-38` +- `eeg_backend/.../common/service/NotificationService.java:39-45` + +**Ist-Zustand:** `markAsRead(UUID id)` nimmt nur die Notification-ID, kein `@CurrentUserId`. Der Service macht keinen Ownership-Check. Andere Methoden im selben Controller (`getNotifications`, `getUnreadCount`, `markAllAsRead`) verwenden korrekt `@CurrentUserId`. + +**Soll-Zustand:** Controller fügt `@CurrentUserId String userId` Parameter hinzu. Service-Signatur wird `markAsRead(UUID id, UUID userId)`. Ownership-Check: `notification.getUserId().equals(userId)`, sonst `AccessDeniedException`. + +### 3. Fehlender MethodArgumentNotValidException-Handler (KRITISCH) + +**Datei:** `eeg_backend/.../common/exception/GlobalExceptionHandler.java` + +**Ist-Zustand:** Kein `@ExceptionHandler(MethodArgumentNotValidException.class)`. Spring's Default-Handler liefert ein eigenes JSON-Format (field errors, global errors, object name) statt des projektspezifischen `ErrorResponse(message, status)`. @Valid-Fehler produzieren inkonsistente Responses. + +**Soll-Zustand:** Handler extrahiert `BindingResult`-Fehler, formatiert sie als komma-separierte Nachricht, gibt 400 BAD_REQUEST mit `ErrorResponse` zurück. + +### 4. Inkonsistente Exception-Strategy (HOCH) + +**Dateien:** +- `eeg_backend/.../iam/service/AdminIamService.java:53-66` (`rejectUser`) +- `eeg_backend/.../community/service/EnergyCommunityService.java:53-73` (`update`, `delete`) + +**Ist-Zustand:** +- `AdminIamService.approveUser`: wirft `IllegalArgumentException`/`IllegalStateException` → GlobalExceptionHandler → 400/409 +- `AdminIamService.rejectUser`: wirft `ResponseStatusException` → bypassed GlobalExceptionHandler → Spring-Default +- `EnergyCommunityService.getCommunityById`: `IllegalArgumentException` → 400 +- `EnergyCommunityService.update`: `ResponseStatusException(NOT_FOUND)` → bypassed +- `EnergyCommunityService.delete`: `IllegalArgumentException` + `ResponseStatusException(CONFLICT)` → gemischt + +**Soll-Zustand:** Einheitlich `IllegalArgumentException` (400) und `IllegalStateException` (409) in Services. `ResponseStatusException` nur in Controllern (wie `AdminIamController.approveUser` already macht). + +**Mapping:** IAE → 400 (GlobalExceptionHandler:39-45), ISE → 409 (GlobalExceptionHandler:47-53). + +### 5. TariffService Code-Duplizierung (HOCH) + +**Datei:** `eeg_backend/.../tariff/service/TariffService.java:74-197` + +**Ist-Zustand:** `createUserTariff` (Zeilen 76-126) und `updateUserTariff` (Zeilen 160-197) teilen ~30 Zeilen identische Validierung: +- source != target MeteringPoint +- Beide Points vorhanden (findByById) +- Beide Points ACTIVE (MakoState) +- Community-Tarif vorhanden +- Preis <= Maximalpreis +- Beide User aktive Members der Community + +**Soll-Zustand:** Private Methode `validateUserTariffRequest(UUID communityId, UserTariffRequest request)` extrahieren. `createUserTariff` ruft sie plus Duplicate-Tarif-Check und Invite-Check auf. `updateUserTariff` ruft sie plus Ownership-Check auf. + +### 6. AtNumberAlreadyExistsException-Handler verwirft Message (HOCH) + +**Datei:** `eeg_backend/.../common/exception/GlobalExceptionHandler.java:55-60` + +**Ist-Zustand:** Handler gibt hardcoded `"Zählerpunkt existiert bereits"` zurück, obwohl `AtNumberAlreadyExistsException` eine Message mit der AT-Nummer enthält. + +**Soll-Zustand:** `ex.getMessage()` verwenden, wie bei allen anderen Handlern. + +### 7. MeteringPoint.memberships ohne orphanRemoval (HOCH) + +**Datei:** `eeg_backend/.../community/domain/MeteringPoint.java:51-52` + +**Ist-Zustand:** `@OneToMany(mappedBy = "meteringPoint", cascade = CascadeType.ALL)` — kein `orphanRemoval`. Wenn ein Membership aus der Liste entfernt wird, wird es nicht automatisch aus der DB gelöscht. + +**Soll-Zustand:** `orphanRemoval = true` hinzufügen. Defensive Absicherung, auch wenn aktuell kein Delete-Endpoint existiert. + +### 8. ddl-auto: update als Prod-Default (HOCH) + +**Datei:** `eeg_backend/src/main/resources/application-prod.yml:9` + +**Ist-Zustand:** `ddl-auto: ${JPA_DDL_AUTO:update}` — Default `update` kann in Produktion Spalten still hinzufügen/ändern, aber nie löschen. + +**Soll-Zustand:** Default auf `validate` ändern. Schema-Änderungen nur über Migrationen (Flyway/Liquibase) oder manuell. + +## Technische Abhängigkeiten + +``` +Finding 3 (Handler-Ergänzung) ─┐ +Finding 6 (AtNumber-Handler) ─┤── Finding 4 (Exception-Unification) +Finding 1 (PreAuthorize) ─┤ +Finding 2 (IDOR-Fix) ─┤ +Finding 5 (Refactoring) ─┤ +Finding 7 (orphanRemoval) ─┤ +Finding 8 (ddl-auto) ─┘ +``` + +Finding 3 und 6 müssen VOR Finding 4 kommen, da die Exception-Unification auf den bestehenden Handlern aufbaut. Alle anderen Findings sind unabhängig voneinander. + +## Scope + +### Im Scope +- Nur Backend-Änderungen (Java, YAML) +- Bestehende Tests aktualisieren (nicht neue Test-Suiten erstellen, wo Tests existieren) +- Conventional Commits + +### Nicht im Scope +- Frontend-Änderungen +- Flyway/Liquibase-Migration (nur ddl-auto Default ändern) +- MeteringPoint-Lösch-Endpoint erstellen (nur orphanRemoval als Absicherung) +- Audit-Logging +- Rollenbasierte SecurityFilterChain-Änderungen diff --git a/docs/compose/specs/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md b/docs/compose/specs/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md new file mode 100644 index 0000000..227271e --- /dev/null +++ b/docs/compose/specs/implementiere-die-erweiterung-der-admin-bersichten-im-eeg-po.md @@ -0,0 +1,76 @@ +# Spec: Admin-Übersichten erweitern — Alle Mitgliedschaften & Alle Benutzer + +## Zusammenfassung + +Erweitere die beiden Admin-Seiten im EEG Portal, sodass Admins nicht nur ausstehende Anträge, sondern alle Mitgliedschaften und alle Benutzer mit Status-Filtern einsehen können. + +## Ausgangslage + +Zwei Admin-Seiten zeigen derzeit nur eingeschränkte Daten: + +| Seite | Route | Aktueller Inhalt | +|-------|-------|-----------------| +| Admin-Mitgliedschaften | `/dashboard/admin-memberships` | Nur PENDING-Anträge mit Genehmigen/Ablehnen | +| Admin-Benutzerverwaltung | `/dashboard/approvals` | Nur PENDING+verifizierte User mit Freigeben/Ablehnen | + +## Ziel + +Admin sieht: (1) Alle Mitgliedschaften mit Status-Filter (Ausstehend / Aktive / Alle), (2) Alle Benutzer mit Status-Übersicht (Ausstehende / Alle). + +## Anforderungen + +### Backend + +1. **Neuer Endpoint `GET /api/community/admin/memberships`** — Gibt alle Mitgliedschaften (PENDING, ACTIVE, INACTIVE) zurück. Gleiche DTO-Struktur wie `PendingMembershipResponse` (inkl. validFrom/validTo). +2. **Neuer Endpoint `GET /api/admin/users`** — Gibt alle registrierten Benutzer zurück. DTO: `UserProfileResponse` (bereits vorhanden). +3. **User-Entity erweitern** — `createdAt` (LocalDateTime) Feld hinzufügen für Sortierung nach Registrierungszeitpunkt. Wird in `AuthService.register()` gesetzt. `ddl-auto: update` erstellt die Spalte automatisch. +4. Bestehende `/pending`-Endpoints bleiben unverändert (Abwärtskompatibilität). + +### Frontend + +5. **Admin-Mitgliedschaften** — Tab-Navigation: "Ausstehende Anträge" | "Aktive Mitgliedschaften" | "Alle". Tab "Ausstehende" behält bestehende Karten-Ansicht. Tabs "Aktive" und "Alle" zeigen Tabellen-Ansicht. +6. **Admin-Benutzerverwaltung** — Tab-Navigation: "Ausstehende Anträge" | "Alle Benutzer". Tab "Ausstehende" behält bestehende Tabelle. Tab "Alle" zeigt Tabelle mit Status-Badges. +7. **Status-Anzeige (user-friendly)** — Technische Enums werden übersetzt: + +| Entity | Technischer Status | Anzeige | +|--------|-------------------|---------| +| Membership | PENDING | "Ausstehend" | +| Membership | ACTIVE | "Aktiv" | +| Membership | INACTIVE | "Inaktiv" | +| User | PENDING | "Ausstehend" | +| User | APPROVED | "Freigeschaltet" | +| User | REJECTED | "Abgelehnt" | + +8. Client-seitige Filterung (einmal alle Daten laden, Tabs per Signal filtern). + +### Nicht im Scope + +- Keine Paginierung (Datenmengen sind klein) +- Keine Server-seitige Filterung +- Keine Änderung an bestehenden Admin-Workflows (Genehmigen/Ablehnen) +- Keine Rollenverwaltung + +## Technische Details + +### MembershipService — getAllMemberships() + +Nutzt `MembershipRepository.findAllWithDetails()` mit JOIN FETCH (gleiche Struktur wie `findPendingWithDetails()`, aber ohne WHERE-Clause auf Status). Anschließend gleiche User-Lookup-Logik wie `getPendingMemberships()`. + +### PendingMembershipResponse — Erweiterung + +Das bestehende DTO `PendingMembershipResponse` wird um `validFrom` und `validTo` erweitert. Da es ein Java record ist, müssen beide Felder hinzugefügt werden — der bestehende `getPendingMemberships()`-Code muss entsprechend angepasst werden. + +### UserMapper — Mapping für UserProfileResponse + +`UserMapper.mapToDto()` mappt `User` → `UserProfileResponse`. Das `createdAt`-Feld wird nicht ins DTO aufgenommen (nur für Server-seitige Sortierung). + +## Akzeptanzkriterien + +- [ ] `GET /api/community/admin/memberships` gibt alle Mitgliedschaften zurück (PENDING, ACTIVE, INACTIVE) +- [ ] `GET /api/admin/users` gibt alle Benutzer zurück (PENDING, APPROVED, REJECTED) +- [ ] Admin-Mitgliedschaften-Seite zeigt Tabs "Ausstehende | Aktive | Alle" +- [ ] Admin-Benutzerverwaltung-Seite zeigt Tabs "Ausstehende | Alle" +- [ ] Genehmigen/Ablehnen funktioniert weiterhin auf dem "Ausstehende"-Tab +- [ ] Status-Badges werden korrekt angezeigt (gelb=ausstehend, grün=aktiv/freigeschaltet, rot=inaktiv/abgelehnt) +- [ ] Bestehende REST-API Tests weiterhin bestehen +- [ ] Conventional Commits werden verwendet diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandlerTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..f57ed9a --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/common/exception/GlobalExceptionHandlerTest.java @@ -0,0 +1,109 @@ +package at.mueller.eeg.backend.common.exception; + +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class GlobalExceptionHandlerTest { + + private final GlobalExceptionHandler handler = new GlobalExceptionHandler(); + + @Test + void handleValidation_returns400WithFieldErrors() { + BindingResult bindingResult = mock(BindingResult.class); + FieldError fe1 = new FieldError("dto", "name", "Name ist erforderlich"); + FieldError fe2 = new FieldError("dto", "email", "Email ist ungültig"); + when(bindingResult.getFieldErrors()).thenReturn(List.of(fe1, fe2)); + + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(null, bindingResult); + ResponseEntity response = handler.handleValidation(ex); + + assertEquals(400, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertTrue(response.getBody().message().contains("name: Name ist erforderlich")); + assertTrue(response.getBody().message().contains("email: Email ist ungültig")); + } + + @Test + void handleValidation_singleFieldError() { + BindingResult bindingResult = mock(BindingResult.class); + FieldError fe = new FieldError("dto", "atNumber", "AT-Nummer ungültig"); + when(bindingResult.getFieldErrors()).thenReturn(List.of(fe)); + + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(null, bindingResult); + ResponseEntity response = handler.handleValidation(ex); + + assertEquals(400, response.getStatusCode().value()); + assertEquals("atNumber: AT-Nummer ungültig", response.getBody().message()); + } + + @Test + void handleAtNumberAlreadyExists_returns409WithMessage() { + AtNumberAlreadyExistsException ex = new AtNumberAlreadyExistsException("AT-Nummer 'AT123' existiert bereits."); + ResponseEntity response = handler.handleAtNumberAlreadyExists(ex); + + assertEquals(409, response.getStatusCode().value()); + assertEquals("AT-Nummer 'AT123' existiert bereits.", response.getBody().message()); + } + + @Test + void handleIllegalArgument_returns400() { + IllegalArgumentException ex = new IllegalArgumentException("User nicht gefunden"); + ResponseEntity response = handler.handleIllegalArgument(ex); + + assertEquals(400, response.getStatusCode().value()); + assertEquals("User nicht gefunden", response.getBody().message()); + } + + @Test + void handleIllegalState_returns409() { + IllegalStateException ex = new IllegalStateException("Nur PENDING erlaubt"); + ResponseEntity response = handler.handleIllegalState(ex); + + assertEquals(409, response.getStatusCode().value()); + assertEquals("Nur PENDING erlaubt", response.getBody().message()); + } + + @Test + void handleAccessDenied_returns403() { + AccessDeniedException ex = new AccessDeniedException("Keine Berechtigung"); + ResponseEntity response = handler.handleAccessDenied(ex); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + void handleBadCredentials_returns401() { + BadCredentialsException ex = new BadCredentialsException("Falsch"); + ResponseEntity response = handler.handleBadCredentials(ex); + + assertEquals(401, response.getStatusCode().value()); + } + + @Test + void handleDisabled_returns403() { + DisabledException ex = new DisabledException("Nicht aktiviert"); + ResponseEntity response = handler.handleDisabled(ex); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + void handleGeneric_returns500() { + Exception ex = new Exception("Unerwartet"); + ResponseEntity response = handler.handleGeneric(ex); + + assertEquals(500, response.getStatusCode().value()); + assertEquals("Ein unerwarteter Fehler ist aufgetreten.", response.getBody().message()); + } +} diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffInviteServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffInviteServiceTest.java new file mode 100644 index 0000000..bc1ebe4 --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/tariff/service/TariffInviteServiceTest.java @@ -0,0 +1,238 @@ +package at.mueller.eeg.backend.tariff.service; + +import at.mueller.eeg.backend.community.domain.MembershipStatus; +import at.mueller.eeg.backend.community.repository.MembershipRepository; +import at.mueller.eeg.backend.tariff.api.dto.TariffInviteRequest; +import at.mueller.eeg.backend.tariff.api.dto.TariffInviteResponse; +import at.mueller.eeg.backend.tariff.domain.InviteStatus; +import at.mueller.eeg.backend.tariff.domain.TariffInvite; +import at.mueller.eeg.backend.tariff.repository.TariffInviteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.AccessDeniedException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TariffInviteServiceTest { + + @Mock + private TariffInviteRepository tariffInviteRepository; + + @Mock + private MembershipRepository membershipRepository; + + @InjectMocks + private TariffInviteService tariffInviteService; + + private UUID producerUserId; + private UUID consumerUserId; + private UUID communityId; + private TariffInvite existingInvite; + + @BeforeEach + void setUp() { + producerUserId = UUID.randomUUID(); + consumerUserId = UUID.randomUUID(); + communityId = UUID.randomUUID(); + + existingInvite = new TariffInvite(); + existingInvite.setId(UUID.randomUUID()); + existingInvite.setProducerUserId(producerUserId); + existingInvite.setConsumerUserId(consumerUserId); + existingInvite.setEnergyCommunityId(communityId); + existingInvite.setCode("ABC123"); + existingInvite.setNote("Test note"); + existingInvite.setStatus(InviteStatus.PENDING); + existingInvite.setExpiresAt(LocalDateTime.now().plusDays(7)); + existingInvite.setCreatedAt(LocalDateTime.now()); + } + + @Test + void createInvite_success() { + when(membershipRepository.isActiveMemberOfCommunity(producerUserId, communityId)).thenReturn(true); + when(membershipRepository.isActiveMemberOfCommunity(consumerUserId, communityId)).thenReturn(true); + when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + producerUserId, consumerUserId, communityId, InviteStatus.PENDING)).thenReturn(false); + when(tariffInviteRepository.countByProducerUserIdAndCreatedAtAfter(eq(producerUserId), any(LocalDateTime.class))) + .thenReturn(2L); + when(tariffInviteRepository.findByCode(anyString())).thenReturn(Optional.empty()); + when(tariffInviteRepository.save(any(TariffInvite.class))).thenReturn(existingInvite); + + TariffInviteRequest request = new TariffInviteRequest(consumerUserId, communityId, "Test note"); + TariffInviteResponse response = tariffInviteService.createInvite(producerUserId, request); + + assertNotNull(response); + assertEquals(producerUserId, response.producerUserId()); + assertEquals(consumerUserId, response.consumerUserId()); + assertEquals(communityId, response.energyCommunityId()); + assertEquals("Test note", response.note()); + verify(tariffInviteRepository).save(any(TariffInvite.class)); + } + + @Test + void createInvite_throwsIfProducerNotMember() { + when(membershipRepository.isActiveMemberOfCommunity(producerUserId, communityId)).thenReturn(false); + + TariffInviteRequest request = new TariffInviteRequest(consumerUserId, communityId, null); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.createInvite(producerUserId, request)); + } + + @Test + void createInvite_throwsIfConsumerNotMember() { + when(membershipRepository.isActiveMemberOfCommunity(producerUserId, communityId)).thenReturn(true); + when(membershipRepository.isActiveMemberOfCommunity(consumerUserId, communityId)).thenReturn(false); + + TariffInviteRequest request = new TariffInviteRequest(consumerUserId, communityId, null); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.createInvite(producerUserId, request)); + } + + @Test + void createInvite_throwsIfSameUser() { + when(membershipRepository.isActiveMemberOfCommunity(producerUserId, communityId)).thenReturn(true); + + TariffInviteRequest request = new TariffInviteRequest(producerUserId, communityId, null); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.createInvite(producerUserId, request)); + } + + @Test + void createInvite_throwsIfPendingInviteExists() { + when(membershipRepository.isActiveMemberOfCommunity(producerUserId, communityId)).thenReturn(true); + when(membershipRepository.isActiveMemberOfCommunity(consumerUserId, communityId)).thenReturn(true); + when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + producerUserId, consumerUserId, communityId, InviteStatus.PENDING)).thenReturn(true); + + TariffInviteRequest request = new TariffInviteRequest(consumerUserId, communityId, null); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.createInvite(producerUserId, request)); + } + + @Test + void createInvite_throwsIfRateLimitExceeded() { + when(membershipRepository.isActiveMemberOfCommunity(producerUserId, communityId)).thenReturn(true); + when(membershipRepository.isActiveMemberOfCommunity(consumerUserId, communityId)).thenReturn(true); + when(tariffInviteRepository.existsByProducerUserIdAndConsumerUserIdAndEnergyCommunityIdAndStatus( + producerUserId, consumerUserId, communityId, InviteStatus.PENDING)).thenReturn(false); + when(tariffInviteRepository.countByProducerUserIdAndCreatedAtAfter(eq(producerUserId), any(LocalDateTime.class))) + .thenReturn(5L); + + TariffInviteRequest request = new TariffInviteRequest(consumerUserId, communityId, null); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.createInvite(producerUserId, request)); + } + + @Test + void acceptInvite_success() { + when(tariffInviteRepository.findById(existingInvite.getId())).thenReturn(Optional.of(existingInvite)); + when(tariffInviteRepository.save(any(TariffInvite.class))).thenReturn(existingInvite); + + TariffInviteResponse response = tariffInviteService.acceptInvite(consumerUserId, existingInvite.getId()); + + assertNotNull(response); + assertEquals(InviteStatus.ACCEPTED, response.status()); + assertNotNull(response.acceptedAt()); + verify(tariffInviteRepository).save(any(TariffInvite.class)); + } + + @Test + void acceptInvite_throwsIfNotConsumer() { + when(tariffInviteRepository.findById(existingInvite.getId())).thenReturn(Optional.of(existingInvite)); + + UUID otherUserId = UUID.randomUUID(); + + assertThrows(AccessDeniedException.class, + () -> tariffInviteService.acceptInvite(otherUserId, existingInvite.getId())); + } + + @Test + void acceptInvite_throwsIfNotPending() { + existingInvite.setStatus(InviteStatus.ACCEPTED); + when(tariffInviteRepository.findById(existingInvite.getId())).thenReturn(Optional.of(existingInvite)); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.acceptInvite(consumerUserId, existingInvite.getId())); + } + + @Test + void acceptInvite_throwsIfExpired() { + existingInvite.setExpiresAt(LocalDateTime.now().minusDays(1)); + when(tariffInviteRepository.findById(existingInvite.getId())).thenReturn(Optional.of(existingInvite)); + when(tariffInviteRepository.save(any(TariffInvite.class))).thenReturn(existingInvite); + + assertThrows(IllegalStateException.class, + () -> tariffInviteService.acceptInvite(consumerUserId, existingInvite.getId())); + verify(tariffInviteRepository).save(any(TariffInvite.class)); + } + + @Test + void acceptInvite_throwsIfNotFound() { + when(tariffInviteRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, + () -> tariffInviteService.acceptInvite(consumerUserId, UUID.randomUUID())); + } + + @Test + void getPendingInvitesForConsumer_returnsList() { + when(tariffInviteRepository.findByConsumerUserIdAndStatus(consumerUserId, InviteStatus.PENDING)) + .thenReturn(List.of(existingInvite)); + + List response = tariffInviteService.getPendingInvitesForConsumer(consumerUserId); + + assertEquals(1, response.size()); + assertEquals(consumerUserId, response.get(0).consumerUserId()); + } + + @Test + void getPendingInvitesForConsumer_filtersExpired() { + existingInvite.setExpiresAt(LocalDateTime.now().minusDays(1)); + when(tariffInviteRepository.findByConsumerUserIdAndStatus(consumerUserId, InviteStatus.PENDING)) + .thenReturn(List.of(existingInvite)); + + List response = tariffInviteService.getPendingInvitesForConsumer(consumerUserId); + + assertTrue(response.isEmpty()); + } + + @Test + void getInvitesForProducer_returnsList() { + when(tariffInviteRepository.findByProducerUserIdAndEnergyCommunityId(producerUserId, communityId)) + .thenReturn(List.of(existingInvite)); + + List response = tariffInviteService.getInvitesForProducer(producerUserId, communityId); + + assertEquals(1, response.size()); + assertEquals(producerUserId, response.get(0).producerUserId()); + } + + @Test + void cleanupExpiredInvites_updatesStatus() { + existingInvite.setExpiresAt(LocalDateTime.now().minusDays(1)); + when(tariffInviteRepository.findByStatus(InviteStatus.PENDING)) + .thenReturn(List.of(existingInvite)); + when(tariffInviteRepository.save(any(TariffInvite.class))).thenReturn(existingInvite); + + tariffInviteService.cleanupExpiredInvites(); + + verify(tariffInviteRepository).save(any(TariffInvite.class)); + } +} diff --git a/eeg_frontend/src/setup-test.ts b/eeg_frontend/src/setup-test.ts new file mode 100644 index 0000000..3de3fdd --- /dev/null +++ b/eeg_frontend/src/setup-test.ts @@ -0,0 +1,12 @@ +import 'zone.js'; +import 'zone.js/plugins/zone-testing'; +import { TestBed } from '@angular/core/testing'; +import { + BrowserTestingModule, + platformBrowserTesting, +} from '@angular/platform-browser/testing'; + +TestBed.initTestEnvironment( + BrowserTestingModule, + platformBrowserTesting(), +);