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
This commit is contained in:
Bernhard Müller 2026-07-24 11:48:56 +02:00
parent 2bb0f7114b
commit cfc32b3a7d
8 changed files with 1385 additions and 0 deletions

5
.gitignore vendored
View file

@ -43,6 +43,11 @@ eeg_frontend/.angular/
### Test-Dateien ###
test_*.json
*.xlsx
*.ps1
*.http
*.py
*.docx
### Projekt-spezifisch ###
/projekt_kontext.txt

View file

@ -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 | T1T13 |
---
## 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<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:
```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<Void> 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<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_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<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:**
```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

View file

@ -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<User> findAllByOrderByCreatedAtDesc()` vorhanden
- [ ] Kompiliert ohne Fehler
**Details:**
```java
List<User> 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<Membership> 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<PendingMembershipResponse>` 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<List<PendingMembershipResponse>>` 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<List<PendingMembershipResponse>> 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<User>` zurück
**Details:**
```java
@Transactional(readOnly = true)
public List<User> 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<List<UserProfileResponse>>` zurück
- [ ] Swagger-Annotationen
**Details:**
```java
@GetMapping
@Operation(summary = "Lädt alle User", description = "Zeigt alle registrierten Benutzer.")
public ResponseEntity<List<UserProfileResponse>> 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<PendingMembershipResponse[]>` vorhanden
- [ ] Nutzt `GET /api/community/admin/memberships`
**Details:**
```typescript
getAllMemberships(): Observable<PendingMembershipResponse[]> {
return this.http.get<PendingMembershipResponse[]>(`${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<PendingMembershipResponse[]>([])`
- [ ] `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<UserProfileResponse[]>([])`
- [ ] `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

View file

@ -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

View file

@ -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

View file

@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> response = handler.handleAccessDenied(ex);
assertEquals(403, response.getStatusCode().value());
}
@Test
void handleBadCredentials_returns401() {
BadCredentialsException ex = new BadCredentialsException("Falsch");
ResponseEntity<ErrorResponse> response = handler.handleBadCredentials(ex);
assertEquals(401, response.getStatusCode().value());
}
@Test
void handleDisabled_returns403() {
DisabledException ex = new DisabledException("Nicht aktiviert");
ResponseEntity<ErrorResponse> response = handler.handleDisabled(ex);
assertEquals(403, response.getStatusCode().value());
}
@Test
void handleGeneric_returns500() {
Exception ex = new Exception("Unerwartet");
ResponseEntity<ErrorResponse> response = handler.handleGeneric(ex);
assertEquals(500, response.getStatusCode().value());
assertEquals("Ein unerwarteter Fehler ist aufgetreten.", response.getBody().message());
}
}

View file

@ -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<TariffInviteResponse> 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<TariffInviteResponse> response = tariffInviteService.getPendingInvitesForConsumer(consumerUserId);
assertTrue(response.isEmpty());
}
@Test
void getInvitesForProducer_returnsList() {
when(tariffInviteRepository.findByProducerUserIdAndEnergyCommunityId(producerUserId, communityId))
.thenReturn(List.of(existingInvite));
List<TariffInviteResponse> 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));
}
}

View file

@ -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(),
);