From 96bcf57ceec8ee50dc635a689b42ad8e995d7d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernhard=20M=C3=BCller?= Date: Tue, 21 Jul 2026 15:06:48 +0200 Subject: [PATCH] feat(community): add metering point deletion with business rules Backend: - Add deleteByIdAndUserId to MeteringPointRepository - Add deleteOwnMeteringPoint with validation (owner, state checks) - Add DELETE /{id} endpoint to MeteringPointController Frontend: - Add deleteMeteringPoint to MeteringPointService - Add delete button with confirmation dialog - Only show delete for inactive points (NEW, REJECTED, ERROR) Business Rules: - Only owner can delete own metering points - Only inactive points (NEW, REJECTED, ERROR) can be deleted - ACTIVE and WAITING_FOR_CONSENT points cannot be deleted Tests: - Add MeteringPointServiceTest with 10 unit tests - All 37 tests passing --- .../api/MeteringPointController.java | 9 + .../repository/MeteringPointRepository.java | 2 + .../service/MeteringPointService.java | 22 +++ .../service/MeteringPointServiceTest.java | 165 ++++++++++++++++++ .../metering-points/metering-points.html | 14 +- .../pages/metering-points/metering-points.ts | 28 +++ .../src/app/services/metering-point.ts | 4 + 7 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringPointServiceTest.java diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java index b45cd24..7d7885a 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/api/MeteringPointController.java @@ -38,6 +38,15 @@ public class MeteringPointController { return ResponseEntity.ok(meteringPointService.getOwnMeteringPoints(UUID.fromString(userId))); } + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('MEMBER')") + public ResponseEntity deleteOwnMeteringPoint( + @CurrentUserId String userId, + @PathVariable UUID id) { + meteringPointService.deleteOwnMeteringPoint(UUID.fromString(userId), id); + return ResponseEntity.noContent().build(); + } + // --- ADMIN: alle Zählpunkte im System --- @GetMapping diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java index 9fe941b..07f0f4b 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/repository/MeteringPointRepository.java @@ -18,4 +18,6 @@ public interface MeteringPointRepository extends JpaRepository findByAtNumber(String atNumber); void deleteByUserId(UUID userId); + + void deleteByIdAndUserId(UUID id, UUID userId); } diff --git a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java index 15103ff..0a132b4 100644 --- a/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java +++ b/eeg_backend/src/main/java/at/mueller/eeg/backend/community/service/MeteringPointService.java @@ -5,6 +5,7 @@ import at.mueller.eeg.backend.common.exception.AtNumberAlreadyExistsException; import at.mueller.eeg.backend.community.api.dto.CreateMeteringPointRequest; import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; import at.mueller.eeg.backend.community.domain.MeteringPoint; +import at.mueller.eeg.backend.community.domain.state.MakoState; import at.mueller.eeg.backend.community.domain.state.MakoTrigger; import at.mueller.eeg.backend.community.repository.MeteringPointRepository; import at.mueller.eeg.backend.iam.domain.RegistrationStatus; @@ -81,6 +82,27 @@ public class MeteringPointService { .toList(); } + @Transactional + public void deleteOwnMeteringPoint(UUID userId, UUID pointId) { + MeteringPoint point = meteringPointRepository.findById(pointId) + .orElseThrow(() -> new IllegalArgumentException("Zählpunkt nicht gefunden")); + + if (!point.getUserId().equals(userId)) { + throw new SecurityException("Keine Berechtigung zum Löschen dieses Zählpunkts"); + } + + if (point.getMakoState() == MakoState.ACTIVE) { + throw new IllegalStateException("Aktive Zählpunkte können nicht gelöscht werden"); + } + + if (point.getMakoState() == MakoState.WAITING_FOR_CONSENT || + point.getMakoState() == MakoState.CONSENT_GRANTED) { + throw new IllegalStateException("Zählpunkte im Consent-Flow können nicht gelöscht werden"); + } + + meteringPointRepository.deleteById(pointId); + } + private MeteringPointResponse toResponse(MeteringPoint mp, String ownerEmail) { return new MeteringPointResponse( mp.getId(), diff --git a/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringPointServiceTest.java b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringPointServiceTest.java new file mode 100644 index 0000000..27d6a64 --- /dev/null +++ b/eeg_backend/src/test/java/at/mueller/eeg/backend/community/service/MeteringPointServiceTest.java @@ -0,0 +1,165 @@ +package at.mueller.eeg.backend.community.service; + +import at.mueller.eeg.backend.common.event.InitiateMakoConsentEvent; +import at.mueller.eeg.backend.common.exception.AtNumberAlreadyExistsException; +import at.mueller.eeg.backend.community.api.dto.CreateMeteringPointRequest; +import at.mueller.eeg.backend.community.api.dto.MeteringPointResponse; +import at.mueller.eeg.backend.community.domain.MeteringPoint; +import at.mueller.eeg.backend.community.domain.PointType; +import at.mueller.eeg.backend.community.domain.state.MakoState; +import at.mueller.eeg.backend.community.repository.MeteringPointRepository; +import at.mueller.eeg.backend.iam.domain.ParticipantType; +import at.mueller.eeg.backend.iam.domain.RegistrationStatus; +import at.mueller.eeg.backend.iam.domain.User; +import at.mueller.eeg.backend.iam.repository.UserRepository; +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.context.ApplicationEventPublisher; + +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 MeteringPointServiceTest { + + @Mock + private MeteringPointRepository meteringPointRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @InjectMocks + private MeteringPointService meteringPointService; + + private User testUser; + private MeteringPoint testMeteringPoint; + private UUID testUserId; + private UUID testPointId; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testPointId = UUID.randomUUID(); + + testUser = new User(); + testUser.setId(testUserId); + testUser.setEmail("test@example.com"); + testUser.setStatus(RegistrationStatus.APPROVED); + + testMeteringPoint = new MeteringPoint(); + testMeteringPoint.setId(testPointId); + testMeteringPoint.setUserId(testUserId); + testMeteringPoint.setAtNumber("AT0010000000000000000000001234567"); + testMeteringPoint.setType(PointType.CONSUMER); + testMeteringPoint.setMakoState(MakoState.NEW); + } + + @Test + void addOwnMeteringPoint_createsNewPoint() { + when(meteringPointRepository.existsByAtNumber(any())).thenReturn(false); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(meteringPointRepository.save(any())).thenReturn(testMeteringPoint); + + CreateMeteringPointRequest request = new CreateMeteringPointRequest("AT0010000000000000000000001234567", PointType.CONSUMER); + + MeteringPointResponse response = meteringPointService.addOwnMeteringPoint(testUserId, request); + + assertNotNull(response); + assertEquals("AT0010000000000000000000001234567", response.atNumber()); + verify(meteringPointRepository).save(any()); + } + + @Test + void addOwnMeteringPoint_throwsForDuplicateAtNumber() { + when(meteringPointRepository.existsByAtNumber(any())).thenReturn(true); + + CreateMeteringPointRequest request = new CreateMeteringPointRequest("AT0010000000000000000000001234567", PointType.CONSUMER); + + assertThrows(AtNumberAlreadyExistsException.class, () -> + meteringPointService.addOwnMeteringPoint(testUserId, request)); + } + + @Test + void deleteOwnMeteringPoint_deletesInactivePoint() { + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId); + + verify(meteringPointRepository).deleteById(testPointId); + } + + @Test + void deleteOwnMeteringPoint_throwsForWrongOwner() { + UUID otherUserId = UUID.randomUUID(); + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + assertThrows(SecurityException.class, () -> + meteringPointService.deleteOwnMeteringPoint(otherUserId, testPointId)); + } + + @Test + void deleteOwnMeteringPoint_throwsForActivePoint() { + testMeteringPoint.setMakoState(MakoState.ACTIVE); + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + assertThrows(IllegalStateException.class, () -> + meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId)); + } + + @Test + void deleteOwnMeteringPoint_throwsForWaitingPoint() { + testMeteringPoint.setMakoState(MakoState.WAITING_FOR_CONSENT); + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + assertThrows(IllegalStateException.class, () -> + meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId)); + } + + @Test + void deleteOwnMeteringPoint_throwsForConsentGrantedPoint() { + testMeteringPoint.setMakoState(MakoState.CONSENT_GRANTED); + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + assertThrows(IllegalStateException.class, () -> + meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId)); + } + + @Test + void deleteOwnMeteringPoint_allowsDeleteForRejectedPoint() { + testMeteringPoint.setMakoState(MakoState.REJECTED); + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId); + + verify(meteringPointRepository).deleteById(testPointId); + } + + @Test + void deleteOwnMeteringPoint_allowsDeleteForErrorPoint() { + testMeteringPoint.setMakoState(MakoState.ERROR); + when(meteringPointRepository.findById(testPointId)).thenReturn(Optional.of(testMeteringPoint)); + + meteringPointService.deleteOwnMeteringPoint(testUserId, testPointId); + + verify(meteringPointRepository).deleteById(testPointId); + } + + @Test + void deleteOwnMeteringPoint_throwsForUnknownPoint() { + when(meteringPointRepository.findById(any())).thenReturn(Optional.empty()); + + assertThrows(IllegalArgumentException.class, () -> + meteringPointService.deleteOwnMeteringPoint(testUserId, UUID.randomUUID())); + } +} diff --git a/eeg_frontend/src/app/pages/metering-points/metering-points.html b/eeg_frontend/src/app/pages/metering-points/metering-points.html index 08653db..52871a2 100644 --- a/eeg_frontend/src/app/pages/metering-points/metering-points.html +++ b/eeg_frontend/src/app/pages/metering-points/metering-points.html @@ -52,13 +52,14 @@ @if (isAdmin) { Besitzer } + Aktion @if (isLoading()) { - Lade Zählpunkte... + Lade Zählpunkte... } @else if (points().length === 0) { - Keine Zählpunkte vorhanden. + Keine Zählpunkte vorhanden. } @else { @for (point of points(); track point.id) { @@ -68,6 +69,15 @@ @if (isAdmin) { {{ point.ownerEmail }} } + + @if (canDelete(point)) { + + } + } } diff --git a/eeg_frontend/src/app/pages/metering-points/metering-points.ts b/eeg_frontend/src/app/pages/metering-points/metering-points.ts index b30d144..405467b 100644 --- a/eeg_frontend/src/app/pages/metering-points/metering-points.ts +++ b/eeg_frontend/src/app/pages/metering-points/metering-points.ts @@ -6,6 +6,7 @@ import {MatButtonModule} from '@angular/material/button'; import {MatSelectModule} from '@angular/material/select'; import {AuthService} from '../../services/auth'; import {CreateMeteringPointRequest, MeteringPoint, MeteringPointService} from '../../services/metering-point'; +import {ToastService} from '../../services/toast'; @Component({ selector: 'app-metering-points', @@ -17,12 +18,14 @@ export class MeteringPointsComponent implements OnInit { private fb = inject(FormBuilder); private authService = inject(AuthService); private meteringPointService = inject(MeteringPointService); + private toastService = inject(ToastService); isAdmin = this.authService.currentUserRole() === 'ADMIN'; points = signal([]); isLoading = signal(true); errorMessage = signal(''); + deletingId = signal(null); addForm = this.fb.group({ atNumber: ['', [Validators.required, Validators.pattern(/^AT[0-9]{31}$/)]], @@ -66,10 +69,35 @@ export class MeteringPointsComponent implements OnInit { next: created => { this.points.update(list => [...list, created]); this.addForm.reset({atNumber: '', type: 'CONSUMER'}); + this.toastService.success('Zählpunkt erfolgreich hinzugefügt.'); }, error: err => { this.errorMessage.set(err.error?.message || 'Zählpunkt konnte nicht hinzugefügt werden.'); } }); } + + canDelete(point: MeteringPoint): boolean { + return this.isAdmin || point.makoState === 'NEW' || point.makoState === 'REJECTED' || point.makoState === 'ERROR'; + } + + confirmDelete(point: MeteringPoint) { + if (!confirm(`Zählpunkt ${point.atNumber} wirklich löschen?`)) { + return; + } + + this.deletingId.set(point.id); + + this.meteringPointService.deleteMeteringPoint(point.id).subscribe({ + next: () => { + this.points.update(list => list.filter(p => p.id !== point.id)); + this.toastService.success('Zählpunkt erfolgreich gelöscht.'); + this.deletingId.set(null); + }, + error: err => { + this.toastService.error(err.error?.message || 'Zählpunkt konnte nicht gelöscht werden.'); + this.deletingId.set(null); + } + }); + } } diff --git a/eeg_frontend/src/app/services/metering-point.ts b/eeg_frontend/src/app/services/metering-point.ts index 689f555..7671229 100644 --- a/eeg_frontend/src/app/services/metering-point.ts +++ b/eeg_frontend/src/app/services/metering-point.ts @@ -36,4 +36,8 @@ export class MeteringPointService { addMeteringPoint(payload: CreateMeteringPointRequest): Observable { return this.http.post(this.apiUrl, payload); } + + deleteMeteringPoint(id: string): Observable { + return this.http.delete(`${this.apiUrl}/${id}`); + } }