diff --git a/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java b/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java index 2e6dd9a..41abe34 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java +++ b/src/main/java/org/example/projektarendehantering/application/service/AuditEventMapper.java @@ -22,6 +22,7 @@ public AuditEventDTO toDTO(AuditEventEntity entity) { .responseStatus(entity.getResponseStatus()) .errorType(entity.getErrorType()) .caseId(entity.getCaseId()) + .statusChange(entity.getStatusChange()) .clientIp(entity.getClientIp()) .userAgent(entity.getUserAgent()) .build(); diff --git a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java index bfcc2b4..3df175f 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/CaseService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/CaseService.java @@ -2,8 +2,10 @@ import org.example.projektarendehantering.common.Actor; import org.example.projektarendehantering.common.BadRequestException; +import org.example.projektarendehantering.common.CaseStatus; import org.example.projektarendehantering.common.NotAuthorizedException; import org.example.projektarendehantering.common.Role; +import org.example.projektarendehantering.infrastructure.persistence.AuditEventEntity; import org.example.projektarendehantering.infrastructure.persistence.CaseEntity; import org.example.projektarendehantering.infrastructure.persistence.CaseNoteEntity; import org.example.projektarendehantering.infrastructure.persistence.CaseNoteRepository; @@ -37,6 +39,7 @@ public class CaseService { private final PatientRepository patientRepository; private final CaseNoteRepository caseNoteRepository; private final EmployeeRepository employeeRepository; + private final AuditService auditService; @Transactional public void addNote(UUID caseId, String content, Actor actor) { @@ -45,6 +48,9 @@ public void addNote(UUID caseId, String content, Actor actor) { } CaseEntity caseEntity = caseRepository.findById(caseId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Case not found")); + if (caseEntity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } requireCanRead(actor, caseEntity); CaseNoteEntity note = caseNoteMapper.toEntity(actor, content); @@ -52,6 +58,11 @@ public void addNote(UUID caseId, String content, Actor actor) { note.setCaseEntity(caseEntity); caseNoteRepository.save(note); + + CaseStatus previousStatus = caseEntity.getStatus(); + caseEntity.setStatus(CaseStatus.COMMUNICATION); + caseRepository.save(caseEntity); + recordStatusChange(actor, caseEntity.getId(), previousStatus, CaseStatus.COMMUNICATION); } @Transactional @@ -70,13 +81,13 @@ public CaseDTO createCase(Actor actor, CaseDTO caseDTO) { if (isDoctor(actor) || isManager(actor)) { entity.setOwnerId(actor.userId()); } - if (entity.getStatus() == null) { - entity.setStatus("OPEN"); - } + entity.setStatus(CaseStatus.CREATED); + if (entity.getCreatedAt() == null) { entity.setCreatedAt(Instant.now()); } CaseEntity savedEntity = caseRepository.save(entity); + recordStatusChange(actor, savedEntity.getId(), null, CaseStatus.CREATED); return caseMapper.toDTO(savedEntity); } @@ -100,13 +111,19 @@ public CaseDTO updateCase(Actor actor, UUID caseId, CaseDTO caseDTO) { CaseEntity entity = caseRepository.findById(caseId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Case not found")); + if (entity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } requireCanEdit(actor, entity); + CaseStatus previousStatus = entity.getStatus(); entity.setTitle(title); entity.setDescription(description); + entity.setStatus(CaseStatus.UPDATED); CaseEntity savedEntity = caseRepository.save(entity); + recordStatusChange(actor, savedEntity.getId(), previousStatus, CaseStatus.UPDATED); return caseMapper.toDTO(savedEntity); } @@ -114,34 +131,53 @@ public CaseDTO updateCase(Actor actor, UUID caseId, CaseDTO caseDTO) { public void deleteCase(Actor actor, UUID caseId) { CaseEntity entity = caseRepository.findById(caseId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Case not found")); + if (entity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } requireCanDelete(actor, entity); - caseRepository.delete(entity); + CaseStatus previousStatus = entity.getStatus(); + entity.setStatus(CaseStatus.CLOSED); + caseRepository.save(entity); + recordStatusChange(actor, entity.getId(), previousStatus, CaseStatus.CLOSED); } @Transactional(readOnly = true) public Optional getCase(Actor actor, UUID id) { - return caseRepository.findById(id) - .map(entity -> { + Optional entityOpt = caseRepository.findById(id); + if (entityOpt.isPresent() && entityOpt.get().getStatus() == CaseStatus.CLOSED && !isManager(actor)) { + throw new BadRequestException("Case is closed"); + } + return entityOpt.map(entity -> { requireCanRead(actor, entity); return caseMapper.toDTO(entity); }); } + @Transactional(readOnly = true) + public List getClosedCases(Actor actor) { + if (!isManager(actor)) { + throw new NotAuthorizedException("Not allowed to view closed cases"); + } + return caseRepository.findAllByStatus(CaseStatus.CLOSED).stream() + .map(caseMapper::toDTO) + .collect(Collectors.toList()); + } + @Transactional(readOnly = true) public List getAllCases(Actor actor) { if (isManager(actor)) { - return caseRepository.findAll().stream() + return caseRepository.findAllByStatusNot(CaseStatus.CLOSED).stream() .map(caseMapper::toDTO) .collect(Collectors.toList()); } if (isDoctor(actor)) { - return caseRepository.findAllByOwnerId(actor.userId()).stream() + return caseRepository.findAllByOwnerIdAndStatusNot(actor.userId(), CaseStatus.CLOSED).stream() .map(caseMapper::toDTO) .collect(Collectors.toList()); } if (isNurse(actor)) { - return caseRepository.findAllByHandlerId(actor.userId()).stream() + return caseRepository.findAllByHandlerIdAndStatusNot(actor.userId(), CaseStatus.CLOSED).stream() .map(caseMapper::toDTO) .collect(Collectors.toList()); } @@ -150,8 +186,8 @@ public List getAllCases(Actor actor) { @Transactional(readOnly = true) public List getCasesForPatient(Actor actor, UUID patientId) { - return caseRepository.findAllByPatient_Id(patientId).stream() - .peek(entity -> requireCanRead(actor, entity)) + return caseRepository.findAllByPatient_IdAndStatusNot(patientId, CaseStatus.CLOSED).stream() + .filter(entity -> canRead(actor, entity)) .map(caseMapper::toDTO) .collect(Collectors.toList()); } @@ -163,6 +199,9 @@ public CaseDTO assignUsers(Actor actor, UUID caseId, CaseAssignmentDTO dto) { } CaseEntity entity = caseRepository.findById(caseId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Case not found")); + if (entity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } if (isDoctor(actor)) { if (entity.getOwnerId() == null || !entity.getOwnerId().equals(actor.userId())) { throw new NotAuthorizedException("Not allowed to modify assignments for this case"); @@ -180,7 +219,12 @@ public CaseDTO assignUsers(Actor actor, UUID caseId, CaseAssignmentDTO dto) { UUID handlerId = requireEmployeeWithRole(dto.getHandlerId(), Set.of(Role.NURSE), "handlerId"); entity.setHandlerId(handlerId); } - return caseMapper.toDTO(caseRepository.save(entity)); + + CaseStatus previousStatus = entity.getStatus(); + entity.setStatus(CaseStatus.HANDLER_ASSIGNED); + CaseEntity savedEntity = caseRepository.save(entity); + recordStatusChange(actor, savedEntity.getId(), previousStatus, CaseStatus.HANDLER_ASSIGNED); + return caseMapper.toDTO(savedEntity); } private UUID requireEmployeeWithRole(UUID id, Set allowedRoles, String fieldName) { @@ -227,6 +271,13 @@ private boolean canCreate(Actor actor) { return isManager(actor) || isDoctor(actor); } + private boolean canRead(Actor actor, CaseEntity entity) { + if (isManager(actor)) return true; + if (isDoctor(actor) && actor.userId().equals(entity.getOwnerId())) return true; + if (isNurse(actor) && actor.userId().equals(entity.getHandlerId())) return true; + return false; + } + private boolean isManager(Actor actor) { return actor.role() == Role.MANAGER; } @@ -238,4 +289,15 @@ private boolean isDoctor(Actor actor) { private boolean isNurse(Actor actor) { return actor.role() == Role.NURSE; } + + private void recordStatusChange(Actor actor, UUID caseId, CaseStatus from, CaseStatus to) { + String statusChange = (from != null ? from.name() : "NEW") + " -> " + to.name(); + AuditEventEntity event = AuditEventEntity.builder() + .caseId(caseId) + .statusChange(statusChange) + .actorId(actor != null ? actor.userId() : null) + .actorRole(actor != null && actor.role() != null ? actor.role().name() : null) + .build(); + auditService.record(event); + } } diff --git a/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java b/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java index ec3b118..55a1ded 100644 --- a/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java +++ b/src/main/java/org/example/projektarendehantering/application/service/DocumentService.java @@ -5,14 +5,8 @@ import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.example.projektarendehantering.common.Actor; -import org.example.projektarendehantering.common.AppException; -import org.example.projektarendehantering.common.BadRequestException; -import org.example.projektarendehantering.common.NotAuthorizedException; -import org.example.projektarendehantering.infrastructure.persistence.CaseEntity; -import org.example.projektarendehantering.infrastructure.persistence.CaseRepository; -import org.example.projektarendehantering.infrastructure.persistence.DocumentEntity; -import org.example.projektarendehantering.infrastructure.persistence.DocumentRepository; +import org.example.projektarendehantering.common.*; +import org.example.projektarendehantering.infrastructure.persistence.*; import org.example.projektarendehantering.presentation.dto.DocumentDTO; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -36,6 +30,7 @@ public class DocumentService { private final CaseRepository caseRepository; private final S3Template s3Template; private final DocumentMapper documentMapper; + private final AuditService auditService; @Value("${app.s3.bucket}") private String bucket; @@ -57,6 +52,10 @@ public DocumentDTO uploadDocument(Actor actor, UUID caseId, MultipartFile file) CaseEntity caseEntity = caseRepository.findById(caseId) .orElseThrow(() -> new BadRequestException("Case not found")); + if (caseEntity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } + validateAccess(actor, caseEntity); String s3Key = UUID.randomUUID().toString() + "-" + originalFilename; @@ -100,6 +99,20 @@ public void afterCompletion(int status) { // --- SAFE DB SAVE WITH COMPENSATION IF IT FAILS --- try { DocumentEntity saved = documentRepository.save(entity); + CaseStatus previousStatus = caseEntity.getStatus(); + if (previousStatus != CaseStatus.COMMUNICATION) { + caseEntity.setStatus(CaseStatus.COMMUNICATION); + caseRepository.save(caseEntity); + + String statusChange = (previousStatus != null ? previousStatus.name() : "NEW") + + " -> " + CaseStatus.COMMUNICATION.name(); + auditService.record(AuditEventEntity.builder() + .caseId(caseEntity.getId()) + .statusChange(statusChange) + .actorId(actor.userId()) + .actorRole(actor.role() != null ? actor.role().name() : null) + .build()); + } return documentMapper.toDTO(saved); } catch (RuntimeException ex) { if (!syncActive) { @@ -117,6 +130,10 @@ public List listDocuments(Actor actor, UUID caseId) { CaseEntity caseEntity = caseRepository.findById(caseId) .orElseThrow(() -> new BadRequestException("Case not found")); + if (caseEntity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } + validateAccess(actor, caseEntity); return documentRepository.findAllByCaseEntityId(caseId).stream() @@ -180,6 +197,9 @@ public DocumentEntity getEntity(Actor actor, UUID documentId) { } private void validateAccess(Actor actor, CaseEntity caseEntity) { + if (caseEntity.getStatus() == CaseStatus.CLOSED) { + throw new BadRequestException("Case is closed"); + } if (actor.isManager()) return; if (actor.isDoctor() && actor.userId().equals(caseEntity.getOwnerId())) return; if (actor.isNurse() && actor.userId().equals(caseEntity.getHandlerId())) return; diff --git a/src/main/java/org/example/projektarendehantering/common/CaseStatus.java b/src/main/java/org/example/projektarendehantering/common/CaseStatus.java new file mode 100644 index 0000000..988b805 --- /dev/null +++ b/src/main/java/org/example/projektarendehantering/common/CaseStatus.java @@ -0,0 +1,9 @@ +package org.example.projektarendehantering.common; + +public enum CaseStatus { + CREATED, + HANDLER_ASSIGNED, + COMMUNICATION, + UPDATED, + CLOSED +} diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java index bdfa67d..55eeaa8 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/AuditEventEntity.java @@ -50,6 +50,8 @@ public class AuditEventEntity { private UUID caseId; + private String statusChange; + private String clientIp; private String userAgent; } diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java index 1d0d214..f80cafb 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseEntity.java @@ -6,6 +6,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.example.projektarendehantering.common.CaseStatus; import java.time.Instant; import java.util.ArrayList; @@ -23,7 +24,10 @@ public class CaseEntity { @Id private UUID id; - private String status; + + @Enumerated(EnumType.STRING) + private CaseStatus status; + private UUID ownerId; private String title; private String description; diff --git a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java index a6ba1a0..17219af 100644 --- a/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java +++ b/src/main/java/org/example/projektarendehantering/infrastructure/persistence/CaseRepository.java @@ -1,13 +1,19 @@ package org.example.projektarendehantering.infrastructure.persistence; +import org.example.projektarendehantering.common.CaseStatus; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; import java.util.UUID; public interface CaseRepository extends JpaRepository { + List findAllByStatus(CaseStatus status); + List findAllByStatusNot(CaseStatus status); List findAllByPatient_Id(UUID patientId); + List findAllByPatient_IdAndStatusNot(UUID patientId, CaseStatus status); List findAllByOwnerId(UUID ownerId); + List findAllByOwnerIdAndStatusNot(UUID ownerId, CaseStatus status); List findAllByHandlerId(UUID handlerId); + List findAllByHandlerIdAndStatusNot(UUID handlerId, CaseStatus status); List findAllByOtherId(UUID otherId); } diff --git a/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java b/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java index 26747f7..c9cf283 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java +++ b/src/main/java/org/example/projektarendehantering/presentation/dto/AuditEventDTO.java @@ -31,6 +31,8 @@ public class AuditEventDTO { private UUID caseId; + private String statusChange; + private String clientIp; private String userAgent; diff --git a/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java b/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java index b273d71..e8ba0ba 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java +++ b/src/main/java/org/example/projektarendehantering/presentation/dto/CaseDTO.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import org.example.projektarendehantering.common.CaseStatus; import java.time.Instant; import java.util.ArrayList; @@ -17,7 +18,7 @@ public class CaseDTO { private UUID id; - private String status; + private CaseStatus status; private String title; private String description; private Instant createdAt; diff --git a/src/main/java/org/example/projektarendehantering/presentation/rest/CaseController.java b/src/main/java/org/example/projektarendehantering/presentation/rest/CaseController.java index 988ac88..239f1e7 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/rest/CaseController.java +++ b/src/main/java/org/example/projektarendehantering/presentation/rest/CaseController.java @@ -40,6 +40,11 @@ public ResponseEntity> getAllCases() { return ResponseEntity.ok(caseService.getAllCases(securityActorAdapter.currentUser())); } + @GetMapping("/closed") + public ResponseEntity> getClosedCases() { + return ResponseEntity.ok(caseService.getClosedCases(securityActorAdapter.currentUser())); + } + @PutMapping("/{id}") public ResponseEntity updateCase(@PathVariable UUID id, @RequestBody @Valid UpdateCaseRequest request) { CaseDTO caseDTO = new CaseDTO(); diff --git a/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java b/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java index 674cfcf..497b778 100644 --- a/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java +++ b/src/main/java/org/example/projektarendehantering/presentation/web/UiController.java @@ -52,6 +52,12 @@ public String listCases(Model model) { return "cases/list"; } + @GetMapping("/ui/cases/closed") + public String listClosedCases(Model model) { + model.addAttribute("cases", caseService.getClosedCases(securityActorAdapter.currentUser())); + return "cases/closed"; + } + @GetMapping("/ui/cases/new") public String newCase(Model model) { model.addAttribute("createCaseForm", new CreateCaseForm()); diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index d58ccad..715fff5 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -26,9 +26,9 @@ ON CONFLICT (id) DO NOTHING; -- Seed Cases INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'OPEN', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) +VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING; INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'OPEN', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) +VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP) ON CONFLICT (id) DO NOTHING; diff --git a/src/main/resources/templates/cases/closed.html b/src/main/resources/templates/cases/closed.html new file mode 100644 index 0000000..ad08449 --- /dev/null +++ b/src/main/resources/templates/cases/closed.html @@ -0,0 +1,39 @@ + + + + + +
+ +
+
+

Closed Cases

+ Back to Active Cases +
+ +
+ + + + + + + + + + + + + + + +
TitleCreatedAction
TitleDate + View +
+

No closed cases found.

+
+
+ +
+ + diff --git a/src/main/resources/templates/cases/list.html b/src/main/resources/templates/cases/list.html index a7a6eee..425efa2 100644 --- a/src/main/resources/templates/cases/list.html +++ b/src/main/resources/templates/cases/list.html @@ -8,6 +8,7 @@
diff --git a/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java b/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java index 40851a3..f428052 100644 --- a/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java +++ b/src/test/java/org/example/projektarendehantering/application/service/CaseServiceTest.java @@ -1,6 +1,8 @@ package org.example.projektarendehantering.application.service; import org.example.projektarendehantering.common.Actor; +import org.example.projektarendehantering.common.BadRequestException; +import org.example.projektarendehantering.common.CaseStatus; import org.example.projektarendehantering.common.NotAuthorizedException; import org.example.projektarendehantering.common.Role; import org.example.projektarendehantering.infrastructure.persistence.*; @@ -13,12 +15,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ResponseStatusException; +import java.util.List; import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -36,6 +40,8 @@ class CaseServiceTest { private EmployeeRepository employeeRepository; @Mock private CaseNoteMapper caseNoteMapper; + @Mock + private AuditService auditService; @InjectMocks private CaseService caseService; @@ -128,6 +134,7 @@ void createCase_shouldAllowDoctor() { caseService.createCase(doctorActor, dto); verify(caseRepository).save(any(CaseEntity.class)); + verify(auditService).record(argThat(e -> "NEW -> CREATED".equals(e.getStatusChange()))); } @Test @@ -158,6 +165,7 @@ void updateCase_shouldAllowOwnerDoctor() { assertThat(caseEntity.getTitle()).isEqualTo("Updated title"); assertThat(caseEntity.getDescription()).isEqualTo("Updated description"); verify(caseRepository).save(caseEntity); + verify(auditService).record(argThat(e -> e.getStatusChange() != null && e.getStatusChange().endsWith("-> UPDATED"))); } @Test @@ -229,7 +237,9 @@ void deleteCase_shouldAllowOwnerDoctor() { caseService.deleteCase(doctorActor, caseId); - verify(caseRepository).delete(caseEntity); + assertThat(caseEntity.getStatus()).isEqualTo(CaseStatus.CLOSED); + verify(caseRepository).save(caseEntity); + verify(auditService).record(argThat(e -> e.getStatusChange() != null && e.getStatusChange().endsWith("-> CLOSED"))); } @Test @@ -238,7 +248,9 @@ void deleteCase_shouldAllowManager() { caseService.deleteCase(managerActor, caseId); - verify(caseRepository).delete(caseEntity); + assertThat(caseEntity.getStatus()).isEqualTo(CaseStatus.CLOSED); + verify(caseRepository).save(caseEntity); + verify(auditService).record(argThat(e -> e.getStatusChange() != null && e.getStatusChange().endsWith("-> CLOSED"))); } @Test @@ -249,7 +261,8 @@ void deleteCase_shouldDenyNonOwnerDoctor() { .isInstanceOf(NotAuthorizedException.class) .hasMessageContaining("Not allowed to delete this case"); - verify(caseRepository, never()).delete(any(CaseEntity.class)); + assertThat(caseEntity.getStatus()).isNotEqualTo(CaseStatus.CLOSED); + verify(caseRepository, never()).save(any(CaseEntity.class)); } @Test @@ -260,7 +273,8 @@ void deleteCase_shouldDenyNurse() { .isInstanceOf(NotAuthorizedException.class) .hasMessageContaining("Not allowed to delete this case"); - verify(caseRepository, never()).delete(any(CaseEntity.class)); + assertThat(caseEntity.getStatus()).isNotEqualTo(CaseStatus.CLOSED); + verify(caseRepository, never()).save(any(CaseEntity.class)); } @Test @@ -272,6 +286,156 @@ void deleteCase_shouldReturnNotFoundWhenCaseDoesNotExist() { .hasMessageContaining("404 NOT_FOUND") .hasMessageContaining("Case not found"); - verify(caseRepository, never()).delete(any(CaseEntity.class)); + verify(caseRepository, never()).save(any(CaseEntity.class)); + } + + @Test + void createCase_shouldRecordStatusChangeAuditWithCaseId() { + CaseDTO dto = new CaseDTO(); + dto.setPatientId(patientId); + PatientEntity patient = new PatientEntity(); + patient.setId(patientId); + + CaseEntity savedEntity = new CaseEntity(); + savedEntity.setId(caseId); + + when(caseMapper.toEntity(dto)).thenReturn(new CaseEntity()); + when(patientRepository.findById(patientId)).thenReturn(Optional.of(patient)); + when(caseRepository.save(any(CaseEntity.class))).thenReturn(savedEntity); + when(caseMapper.toDTO(any(CaseEntity.class))).thenReturn(new CaseDTO()); + + caseService.createCase(doctorActor, dto); + + verify(auditService).record(argThat(e -> + "NEW -> CREATED".equals(e.getStatusChange()) && + caseId.equals(e.getCaseId()) && + doctorActor.userId().equals(e.getActorId()) + )); + } + + @Test + void updateCase_shouldRecordPreviousStatusInAudit() { + caseEntity.setStatus(CaseStatus.HANDLER_ASSIGNED); + CaseDTO updateDto = new CaseDTO(); + updateDto.setTitle("New title"); + updateDto.setDescription("New description"); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + when(caseRepository.save(caseEntity)).thenReturn(caseEntity); + when(caseMapper.toDTO(caseEntity)).thenReturn(new CaseDTO()); + + caseService.updateCase(doctorActor, caseId, updateDto); + + verify(auditService).record(argThat(e -> + "HANDLER_ASSIGNED -> UPDATED".equals(e.getStatusChange()) && + caseId.equals(e.getCaseId()) + )); + } + + @Test + void deleteCase_shouldNotRecordAuditOnAuthorizationFailure() { + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + + assertThatThrownBy(() -> caseService.deleteCase(nurseActor, caseId)) + .isInstanceOf(NotAuthorizedException.class); + + verify(auditService, never()).record(any()); + } + + // --- Closed-case state conflict (409) --- + + @Test + void updateCase_shouldReturnConflictWhenCaseIsClosed() { + caseEntity.setStatus(CaseStatus.CLOSED); + CaseDTO updateDto = new CaseDTO(); + updateDto.setTitle("Updated title"); + updateDto.setDescription("Updated description"); + + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + + assertThatThrownBy(() -> caseService.updateCase(doctorActor, caseId, updateDto)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Case is closed"); + + verify(caseRepository, never()).save(any(CaseEntity.class)); + } + + @Test + void deleteCase_shouldReturnConflictWhenCaseIsClosed() { + caseEntity.setStatus(CaseStatus.CLOSED); + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + + assertThatThrownBy(() -> caseService.deleteCase(doctorActor, caseId)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Case is closed"); + + verify(caseRepository, never()).save(any(CaseEntity.class)); + } + + @Test + void addNote_shouldReturnConflictWhenCaseIsClosed() { + caseEntity.setStatus(CaseStatus.CLOSED); + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + + assertThatThrownBy(() -> caseService.addNote(caseId, "Some note", doctorActor)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Case is closed"); + + verify(caseNoteRepository, never()).save(any()); + verify(caseRepository, never()).save(any(CaseEntity.class)); + } + + // --- Manager visibility of closed cases --- + + @Test + void getCase_shouldAllowManagerToReadClosedCase() { + caseEntity.setStatus(CaseStatus.CLOSED); + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + when(caseMapper.toDTO(caseEntity)).thenReturn(new CaseDTO()); + + Optional result = caseService.getCase(managerActor, caseId); + + assertThat(result).isPresent(); + } + + @Test + void getCase_shouldReturnEmptyForNonManagerOnClosedCase() { + caseEntity.setStatus(CaseStatus.CLOSED); + when(caseRepository.findById(caseId)).thenReturn(Optional.of(caseEntity)); + + assertThatThrownBy(() -> caseService.getCase(doctorActor, caseId)) + .isInstanceOf(BadRequestException.class) + .hasMessageContaining("Case is closed"); + } + + // --- getClosedCases --- + + @Test + void getClosedCases_shouldReturnListForManager() { + CaseEntity closedCase = new CaseEntity(); + closedCase.setId(UUID.randomUUID()); + closedCase.setStatus(CaseStatus.CLOSED); + + when(caseRepository.findAllByStatus(CaseStatus.CLOSED)).thenReturn(List.of(closedCase)); + when(caseMapper.toDTO(closedCase)).thenReturn(new CaseDTO()); + + List result = caseService.getClosedCases(managerActor); + + assertThat(result).hasSize(1); + verify(caseRepository).findAllByStatus(CaseStatus.CLOSED); + } + + @Test + void getClosedCases_shouldDenyDoctor() { + assertThatThrownBy(() -> caseService.getClosedCases(doctorActor)) + .isInstanceOf(NotAuthorizedException.class) + .hasMessageContaining("Not allowed to view closed cases"); + } + + @Test + void getClosedCases_shouldDenyNurse() { + assertThatThrownBy(() -> caseService.getClosedCases(nurseActor)) + .isInstanceOf(NotAuthorizedException.class) + .hasMessageContaining("Not allowed to view closed cases"); } } diff --git a/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java b/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java index b38d697..b59b564 100644 --- a/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java +++ b/src/test/java/org/example/projektarendehantering/application/service/DocumentServiceTest.java @@ -42,6 +42,8 @@ class DocumentServiceTest { private S3Template s3Template; @Mock private DocumentMapper documentMapper; + @Mock + private AuditService auditService; @InjectMocks private DocumentService documentService; diff --git a/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java b/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java index 595b122..745307e 100644 --- a/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java +++ b/src/test/java/org/example/projektarendehantering/presentation/rest/CaseControllerTest.java @@ -198,4 +198,28 @@ void getAllCases_shouldReturnUnauthorized_whenNotLoggedIn() throws Exception { mockMvc.perform(get("/api/cases")) .andExpect(status().is3xxRedirection()); // Redirect to login in OAuth2 setup } + + @Test + @WithMockUser(roles = "MANAGER") + void getClosedCases_shouldReturnList_forManager() throws Exception { + Actor managerActor = new Actor(UUID.randomUUID(), Role.MANAGER, "Manager", "manager_user"); + when(securityActorAdapter.currentUser()).thenReturn(managerActor); + + CaseDTO caseDTO = new CaseDTO(); + caseDTO.setId(caseId); + caseDTO.setDescription("Closed Case"); + + when(caseService.getClosedCases(managerActor)).thenReturn(List.of(caseDTO)); + + mockMvc.perform(get("/api/cases/closed")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(caseId.toString())) + .andExpect(jsonPath("$[0].description").value("Closed Case")); + } + + @Test + void getClosedCases_shouldReturnRedirect_whenNotLoggedIn() throws Exception { + mockMvc.perform(get("/api/cases/closed")) + .andExpect(status().is3xxRedirection()); + } } diff --git a/src/test/resources/data-test.sql b/src/test/resources/data-test.sql index 4a192c8..bc9ea56 100644 --- a/src/test/resources/data-test.sql +++ b/src/test/resources/data-test.sql @@ -20,7 +20,7 @@ VALUES ('770e8400-e29b-41d4-a716-446655440002', 'Nurse Bob Jones', 'NURSE', CURR -- Seed Cases INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'OPEN', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP); +VALUES ('990e8400-e29b-41d4-a716-446655440000', 'Acute Chest Pain', 'Patient arrived with severe chest pain and shortness of breath.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440000', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP); INSERT INTO cases (id, title, description, status, patient_id, owner_id, handler_id, created_at) -VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'OPEN', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP); +VALUES ('990e8400-e29b-41d4-a716-446655440001', 'Follow-up: Fracture', 'Routine follow-up for a healed radial fracture.', 'HANDLER_ASSIGNED', '550e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440001', '770e8400-e29b-41d4-a716-446655440002', CURRENT_TIMESTAMP);