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
This commit is contained in:
Bernhard Müller 2026-07-21 15:06:48 +02:00
parent 04be9a62ea
commit 96bcf57cee
7 changed files with 242 additions and 2 deletions

View File

@ -38,6 +38,15 @@ public class MeteringPointController {
return ResponseEntity.ok(meteringPointService.getOwnMeteringPoints(UUID.fromString(userId)));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('MEMBER')")
public ResponseEntity<Void> deleteOwnMeteringPoint(
@CurrentUserId String userId,
@PathVariable UUID id) {
meteringPointService.deleteOwnMeteringPoint(UUID.fromString(userId), id);
return ResponseEntity.noContent().build();
}
// --- ADMIN: alle Zählpunkte im System ---
@GetMapping

View File

@ -18,4 +18,6 @@ public interface MeteringPointRepository extends JpaRepository<MeteringPoint, UU
Optional<MeteringPoint> findByAtNumber(String atNumber);
void deleteByUserId(UUID userId);
void deleteByIdAndUserId(UUID id, UUID userId);
}

View File

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

View File

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

View File

@ -52,13 +52,14 @@
@if (isAdmin) {
<th class="py-3 px-6 font-semibold">Besitzer</th>
}
<th class="py-3 px-6 font-semibold">Aktion</th>
</tr>
</thead>
<tbody>
@if (isLoading()) {
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 4 : 3">Lade Zählpunkte...</td></tr>
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 5 : 4">Lade Zählpunkte...</td></tr>
} @else if (points().length === 0) {
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 4 : 3">Keine Zählpunkte vorhanden.</td></tr>
<tr><td class="py-4 px-6 text-slate-400" [attr.colspan]="isAdmin ? 5 : 4">Keine Zählpunkte vorhanden.</td></tr>
} @else {
@for (point of points(); track point.id) {
<tr class="border-b border-slate-100 last:border-0">
@ -68,6 +69,15 @@
@if (isAdmin) {
<td class="py-3 px-6 text-sm text-slate-600">{{ point.ownerEmail }}</td>
}
<td class="py-3 px-6">
@if (canDelete(point)) {
<button (click)="confirmDelete(point)"
[disabled]="deletingId() === point.id"
class="text-red-600 hover:text-red-800 text-sm font-medium disabled:opacity-50">
{{ deletingId() === point.id ? 'Lösche...' : 'Löschen' }}
</button>
}
</td>
</tr>
}
}

View File

@ -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<MeteringPoint[]>([]);
isLoading = signal(true);
errorMessage = signal('');
deletingId = signal<string | null>(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);
}
});
}
}

View File

@ -36,4 +36,8 @@ export class MeteringPointService {
addMeteringPoint(payload: CreateMeteringPointRequest): Observable<MeteringPoint> {
return this.http.post<MeteringPoint>(this.apiUrl, payload);
}
deleteMeteringPoint(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}