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:
parent
2bb0f7114b
commit
cfc32b3a7d
8 changed files with 1385 additions and 0 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -43,6 +43,11 @@ eeg_frontend/.angular/
|
||||||
|
|
||||||
### Test-Dateien ###
|
### Test-Dateien ###
|
||||||
test_*.json
|
test_*.json
|
||||||
|
*.xlsx
|
||||||
|
*.ps1
|
||||||
|
*.http
|
||||||
|
*.py
|
||||||
|
*.docx
|
||||||
|
|
||||||
### Projekt-spezifisch ###
|
### Projekt-spezifisch ###
|
||||||
/projekt_kontext.txt
|
/projekt_kontext.txt
|
||||||
|
|
|
||||||
468
docs/compose/plans/code-review-critical-fixes.md
Normal file
468
docs/compose/plans/code-review-critical-fixes.md
Normal 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 | 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<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
|
||||||
|
|
@ -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
|
||||||
133
docs/compose/specs/code-review-critical-fixes.md
Normal file
133
docs/compose/specs/code-review-critical-fixes.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
eeg_frontend/src/setup-test.ts
Normal file
12
eeg_frontend/src/setup-test.ts
Normal 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(),
|
||||||
|
);
|
||||||
Loading…
Reference in a new issue