From 2cf1304b64409d12d411bc7ed01970a8f7da63f3 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Wed, 8 Apr 2026 15:27:17 +0200 Subject: [PATCH 1/6] feat: extend Deviation domain with related reading and comments functionality --- .../deviations/domain/Deviation.java | 31 ++++++- .../deviations/domain/DeviationComment.java | 81 +++++++++++++++++++ .../DeviationCommentRepository.java | 14 ++++ .../V10__extend_deviation_domain.sql | 30 +++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/backend/fullstack/deviations/domain/DeviationComment.java create mode 100644 backend/src/main/java/backend/fullstack/deviations/infrastructure/DeviationCommentRepository.java create mode 100644 backend/src/main/resources/db/migration/V10__extend_deviation_domain.sql diff --git a/backend/src/main/java/backend/fullstack/deviations/domain/Deviation.java b/backend/src/main/java/backend/fullstack/deviations/domain/Deviation.java index fd63177..d70d912 100644 --- a/backend/src/main/java/backend/fullstack/deviations/domain/Deviation.java +++ b/backend/src/main/java/backend/fullstack/deviations/domain/Deviation.java @@ -1,12 +1,16 @@ package backend.fullstack.deviations.domain; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; import backend.fullstack.organization.Organization; +import backend.fullstack.temperature.domain.TemperatureReading; import backend.fullstack.user.User; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -18,6 +22,7 @@ import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AllArgsConstructor; import lombok.Builder; @@ -30,7 +35,10 @@ name = "deviations", indexes = { @Index(name = "idx_deviations_organization_id", columnList = "organization_id"), - @Index(name = "idx_deviations_status", columnList = "status") + @Index(name = "idx_deviations_status", columnList = "status"), + @Index(name = "idx_deviations_severity", columnList = "severity"), + @Index(name = "idx_deviations_module_type", columnList = "module_type"), + @Index(name = "idx_deviations_related_reading_id", columnList = "related_reading_id") } ) @Getter @@ -70,6 +78,10 @@ public class Deviation { @JoinColumn(name = "reported_by_id", nullable = false) private User reportedBy; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "related_reading_id") + private TemperatureReading relatedReading; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "resolved_by_id") private User resolvedBy; @@ -80,6 +92,14 @@ public class Deviation { @Column(name = "resolution", length = 2000) private String resolution; + @OneToMany( + mappedBy = "deviation", + cascade = CascadeType.ALL, + orphanRemoval = true + ) + @Builder.Default + private List comments = new ArrayList<>(); + @CreationTimestamp @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -99,4 +119,13 @@ public String getReportedByName() { public String getResolvedByName() { return resolvedBy != null ? resolvedBy.getFirstName() + " " + resolvedBy.getLastName() : null; } + + public Long getRelatedReadingId() { + return relatedReading != null ? relatedReading.getId() : null; + } + + public void addComment(DeviationComment comment) { + comments.add(comment); + comment.setDeviation(this); + } } diff --git a/backend/src/main/java/backend/fullstack/deviations/domain/DeviationComment.java b/backend/src/main/java/backend/fullstack/deviations/domain/DeviationComment.java new file mode 100644 index 0000000..65202cf --- /dev/null +++ b/backend/src/main/java/backend/fullstack/deviations/domain/DeviationComment.java @@ -0,0 +1,81 @@ +package backend.fullstack.deviations.domain; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import backend.fullstack.organization.Organization; +import backend.fullstack.user.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table( + name = "deviation_comments", + indexes = { + @Index(name = "idx_deviation_comments_organization_id", columnList = "organization_id"), + @Index(name = "idx_deviation_comments_deviation_id", columnList = "deviation_id"), + @Index(name = "idx_deviation_comments_created_by_id", columnList = "created_by_id"), + @Index(name = "idx_deviation_comments_created_at", columnList = "created_at") + } +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class DeviationComment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "organization_id", nullable = false) + private Organization organization; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "deviation_id", nullable = false) + private Deviation deviation; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "created_by_id", nullable = false) + private User createdBy; + + @Column(name = "comment_text", nullable = false, length = 2000) + private String commentText; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public Long getOrganizationId() { + return organization != null ? organization.getId() : null; + } + + public Long getCreatedById() { + return createdBy != null ? createdBy.getId() : null; + } + + public String getCreatedByName() { + return createdBy != null ? createdBy.getFirstName() + " " + createdBy.getLastName() : null; + } +} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/deviations/infrastructure/DeviationCommentRepository.java b/backend/src/main/java/backend/fullstack/deviations/infrastructure/DeviationCommentRepository.java new file mode 100644 index 0000000..968f1c7 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/deviations/infrastructure/DeviationCommentRepository.java @@ -0,0 +1,14 @@ +package backend.fullstack.deviations.infrastructure; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import backend.fullstack.deviations.domain.DeviationComment; + +@Repository +public interface DeviationCommentRepository extends JpaRepository { + + List findByOrganization_IdAndDeviation_IdOrderByCreatedAtAsc(Long organizationId, Long deviationId); +} \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V10__extend_deviation_domain.sql b/backend/src/main/resources/db/migration/V10__extend_deviation_domain.sql new file mode 100644 index 0000000..c160479 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__extend_deviation_domain.sql @@ -0,0 +1,30 @@ +ALTER TABLE deviations + ADD COLUMN related_reading_id BIGINT NULL, + ADD CONSTRAINT fk_deviations_related_reading + FOREIGN KEY (related_reading_id) REFERENCES temperature_readings(id); + +CREATE INDEX idx_deviations_severity ON deviations(severity); +CREATE INDEX idx_deviations_module_type ON deviations(module_type); +CREATE INDEX idx_deviations_related_reading_id ON deviations(related_reading_id); + +CREATE TABLE deviation_comments ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + organization_id BIGINT NOT NULL, + deviation_id BIGINT NOT NULL, + created_by_id BIGINT NOT NULL, + comment_text VARCHAR(2000) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT fk_deviation_comments_organization + FOREIGN KEY (organization_id) REFERENCES organizations(id), + CONSTRAINT fk_deviation_comments_deviation + FOREIGN KEY (deviation_id) REFERENCES deviations(id) ON DELETE CASCADE, + CONSTRAINT fk_deviation_comments_created_by + FOREIGN KEY (created_by_id) REFERENCES users(id) +); + +CREATE INDEX idx_deviation_comments_organization_id ON deviation_comments(organization_id); +CREATE INDEX idx_deviation_comments_deviation_id ON deviation_comments(deviation_id); +CREATE INDEX idx_deviation_comments_created_by_id ON deviation_comments(created_by_id); +CREATE INDEX idx_deviation_comments_created_at ON deviation_comments(created_at); \ No newline at end of file From 31cc56637d15f7e2e083b7761d079a1e06b0144a Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Wed, 8 Apr 2026 15:33:37 +0200 Subject: [PATCH 2/6] feat: add functionality for updating deviation status and adding comments --- .../deviations/api/DeviationController.java | 51 ++++++++++ .../api/dto/DeviationCommentRequest.java | 10 ++ .../api/dto/DeviationCommentResponse.java | 11 +++ .../api/dto/UpdateDeviationStatusRequest.java | 13 +++ .../application/DeviationService.java | 97 +++++++++++++++++-- 5 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentRequest.java create mode 100644 backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentResponse.java create mode 100644 backend/src/main/java/backend/fullstack/deviations/api/dto/UpdateDeviationStatusRequest.java diff --git a/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java b/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java index b96151e..82c558b 100644 --- a/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java +++ b/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java @@ -16,9 +16,12 @@ import backend.fullstack.config.ApiResponse; import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.deviations.api.dto.DeviationCommentRequest; +import backend.fullstack.deviations.api.dto.DeviationCommentResponse; import backend.fullstack.deviations.api.dto.DeviationRequest; import backend.fullstack.deviations.api.dto.DeviationResponse; import backend.fullstack.deviations.api.dto.ResolveDeviationRequest; +import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest; import backend.fullstack.deviations.application.DeviationService; import backend.fullstack.deviations.domain.DeviationStatus; import io.swagger.v3.oas.annotations.Operation; @@ -87,4 +90,52 @@ public ResponseEntity> resolveDeviation( DeviationResponse deviation = deviationService.resolveDeviation(principal.organizationId(), principal.userId(), id, request); return ResponseEntity.ok(ApiResponse.success("Deviation resolved", deviation)); } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')") + @Operation(summary = "Update deviation status") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Deviation status updated"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Validation error"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Deviation not found") + }) + public ResponseEntity> updateDeviationStatus( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long id, + @Valid @RequestBody UpdateDeviationStatusRequest request + ) { + DeviationResponse deviation = deviationService.updateDeviationStatus( + principal.organizationId(), + principal.userId(), + id, + request + ); + return ResponseEntity.ok(ApiResponse.success("Deviation status updated", deviation)); + } + + @PostMapping("/{id}/comments") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation(summary = "Add comment to deviation") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Comment created"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Validation error"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Deviation not found") + }) + public ResponseEntity> addDeviationComment( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long id, + @Valid @RequestBody DeviationCommentRequest request + ) { + DeviationCommentResponse response = deviationService.addComment( + principal.organizationId(), + principal.userId(), + id, + request + ); + return ResponseEntity.status(201).body(ApiResponse.success("Deviation comment created", response)); + } } diff --git a/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentRequest.java b/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentRequest.java new file mode 100644 index 0000000..8f49444 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentRequest.java @@ -0,0 +1,10 @@ +package backend.fullstack.deviations.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record DeviationCommentRequest( + @NotBlank(message = "Comment is required") + @Size(max = 2000, message = "Comment must be at most 2000 characters") + String comment +) {} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentResponse.java b/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentResponse.java new file mode 100644 index 0000000..96af454 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationCommentResponse.java @@ -0,0 +1,11 @@ +package backend.fullstack.deviations.api.dto; + +import java.time.LocalDateTime; + +public record DeviationCommentResponse( + Long id, + String comment, + Long createdById, + String createdBy, + LocalDateTime createdAt +) {} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/deviations/api/dto/UpdateDeviationStatusRequest.java b/backend/src/main/java/backend/fullstack/deviations/api/dto/UpdateDeviationStatusRequest.java new file mode 100644 index 0000000..df1420c --- /dev/null +++ b/backend/src/main/java/backend/fullstack/deviations/api/dto/UpdateDeviationStatusRequest.java @@ -0,0 +1,13 @@ +package backend.fullstack.deviations.api.dto; + +import backend.fullstack.deviations.domain.DeviationStatus; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record UpdateDeviationStatusRequest( + @NotNull(message = "Status is required") + DeviationStatus status, + + @Size(max = 2000, message = "Resolution must be at most 2000 characters") + String resolution +) {} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java index 69a7292..ac71226 100644 --- a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java +++ b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java @@ -6,12 +6,17 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import backend.fullstack.deviations.api.dto.DeviationCommentRequest; +import backend.fullstack.deviations.api.dto.DeviationCommentResponse; import backend.fullstack.deviations.api.dto.DeviationMapper; import backend.fullstack.deviations.api.dto.DeviationRequest; import backend.fullstack.deviations.api.dto.DeviationResponse; import backend.fullstack.deviations.api.dto.ResolveDeviationRequest; +import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest; +import backend.fullstack.deviations.domain.DeviationComment; import backend.fullstack.deviations.domain.Deviation; import backend.fullstack.deviations.domain.DeviationStatus; +import backend.fullstack.deviations.infrastructure.DeviationCommentRepository; import backend.fullstack.deviations.infrastructure.DeviationRepository; import backend.fullstack.exceptions.ResourceNotFoundException; import backend.fullstack.organization.Organization; @@ -24,17 +29,20 @@ public class DeviationService { private final DeviationRepository deviationRepository; + private final DeviationCommentRepository deviationCommentRepository; private final OrganizationRepository organizationRepository; private final UserRepository userRepository; private final DeviationMapper deviationMapper; public DeviationService( DeviationRepository deviationRepository, + DeviationCommentRepository deviationCommentRepository, OrganizationRepository organizationRepository, UserRepository userRepository, DeviationMapper deviationMapper ) { this.deviationRepository = deviationRepository; + this.deviationCommentRepository = deviationCommentRepository; this.organizationRepository = organizationRepository; this.userRepository = userRepository; this.deviationMapper = deviationMapper; @@ -74,19 +82,96 @@ public DeviationResponse createDeviation(Long organizationId, Long userId, Devia return deviationMapper.toResponse(saved); } - public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long deviationId, ResolveDeviationRequest request) { + public DeviationResponse updateDeviationStatus( + Long organizationId, + Long userId, + Long deviationId, + UpdateDeviationStatusRequest request + ) { Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) .orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId)); - User resolver = userRepository.findById(userId) + User actor = userRepository.findById(userId) .orElseThrow(() -> new ResourceNotFoundException("User", userId)); - deviation.setStatus(DeviationStatus.RESOLVED); - deviation.setResolvedBy(resolver); - deviation.setResolvedAt(LocalDateTime.now()); - deviation.setResolution(request.resolution()); + assertUserInOrganization(actor, organizationId); + assertAllowedTransition(deviation.getStatus(), request.status()); + + deviation.setStatus(request.status()); + + if (request.status() == DeviationStatus.RESOLVED) { + deviation.setResolvedBy(actor); + deviation.setResolvedAt(LocalDateTime.now()); + deviation.setResolution(request.resolution()); + } else { + deviation.setResolvedBy(null); + deviation.setResolvedAt(null); + deviation.setResolution(null); + } Deviation saved = deviationRepository.save(deviation); return deviationMapper.toResponse(saved); } + + public DeviationCommentResponse addComment( + Long organizationId, + Long userId, + Long deviationId, + DeviationCommentRequest request + ) { + Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId)); + + User author = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User", userId)); + + assertUserInOrganization(author, organizationId); + + DeviationComment comment = DeviationComment.builder() + .organization(deviation.getOrganization()) + .deviation(deviation) + .createdBy(author) + .commentText(request.comment()) + .build(); + + DeviationComment saved = deviationCommentRepository.save(comment); + return new DeviationCommentResponse( + saved.getId(), + saved.getCommentText(), + saved.getCreatedById(), + saved.getCreatedByName(), + saved.getCreatedAt() + ); + } + + public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long deviationId, ResolveDeviationRequest request) { + return updateDeviationStatus( + organizationId, + userId, + deviationId, + new UpdateDeviationStatusRequest(DeviationStatus.RESOLVED, request.resolution()) + ); + } + + private void assertUserInOrganization(User user, Long organizationId) { + if (!organizationId.equals(user.getOrganizationId())) { + throw new IllegalArgumentException("User does not belong to this organization"); + } + } + + private void assertAllowedTransition(DeviationStatus current, DeviationStatus target) { + if (current == target) { + return; + } + + boolean valid = switch (current) { + case OPEN -> target == DeviationStatus.IN_PROGRESS || target == DeviationStatus.RESOLVED; + case IN_PROGRESS -> target == DeviationStatus.RESOLVED; + case RESOLVED -> false; + }; + + if (!valid) { + throw new IllegalArgumentException("Invalid status transition: " + current + " -> " + target); + } + } } From 5331805159ebb1373e9d2559a350a8311bd95a7a Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Wed, 8 Apr 2026 15:33:49 +0200 Subject: [PATCH 3/6] fix: reorder import statements in DeviationService for clarity --- .../fullstack/deviations/application/DeviationService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java index ac71226..3574eb9 100644 --- a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java +++ b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java @@ -13,8 +13,8 @@ import backend.fullstack.deviations.api.dto.DeviationResponse; import backend.fullstack.deviations.api.dto.ResolveDeviationRequest; import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest; -import backend.fullstack.deviations.domain.DeviationComment; import backend.fullstack.deviations.domain.Deviation; +import backend.fullstack.deviations.domain.DeviationComment; import backend.fullstack.deviations.domain.DeviationStatus; import backend.fullstack.deviations.infrastructure.DeviationCommentRepository; import backend.fullstack.deviations.infrastructure.DeviationRepository; From aa3a09cdd29ed4d6160e11e2c389a9c0d0463523 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Wed, 8 Apr 2026 15:37:30 +0200 Subject: [PATCH 4/6] feat: add endpoint to retrieve deviation details by ID --- .../deviations/api/DeviationController.java | 18 ++++++++++ .../api/dto/DeviationDetailsResponse.java | 24 +++++++++++++ .../application/DeviationService.java | 36 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationDetailsResponse.java diff --git a/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java b/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java index 82c558b..5f9401f 100644 --- a/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java +++ b/backend/src/main/java/backend/fullstack/deviations/api/DeviationController.java @@ -18,6 +18,7 @@ import backend.fullstack.config.JwtPrincipal; import backend.fullstack.deviations.api.dto.DeviationCommentRequest; import backend.fullstack.deviations.api.dto.DeviationCommentResponse; +import backend.fullstack.deviations.api.dto.DeviationDetailsResponse; import backend.fullstack.deviations.api.dto.DeviationRequest; import backend.fullstack.deviations.api.dto.DeviationResponse; import backend.fullstack.deviations.api.dto.ResolveDeviationRequest; @@ -57,6 +58,23 @@ public ResponseEntity>> getDeviations( return ResponseEntity.ok(ApiResponse.success("Deviations retrieved", deviations)); } + @GetMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") + @Operation(summary = "Get deviation by id", description = "Returns one deviation including comment log") + @ApiResponses(value = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Deviation retrieved"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Unauthorized"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Forbidden"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Deviation not found") + }) + public ResponseEntity> getDeviationById( + @AuthenticationPrincipal JwtPrincipal principal, + @PathVariable Long id + ) { + DeviationDetailsResponse deviation = deviationService.getDeviationById(principal.organizationId(), id); + return ResponseEntity.ok(ApiResponse.success("Deviation retrieved", deviation)); + } + @PostMapping @PreAuthorize("hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')") @Operation(summary = "Create deviation") diff --git a/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationDetailsResponse.java b/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationDetailsResponse.java new file mode 100644 index 0000000..9d31af8 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/deviations/api/dto/DeviationDetailsResponse.java @@ -0,0 +1,24 @@ +package backend.fullstack.deviations.api.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import backend.fullstack.deviations.domain.DeviationModuleType; +import backend.fullstack.deviations.domain.DeviationSeverity; +import backend.fullstack.deviations.domain.DeviationStatus; + +public record DeviationDetailsResponse( + Long id, + String title, + String description, + DeviationStatus status, + DeviationSeverity severity, + DeviationModuleType moduleType, + String reportedBy, + LocalDateTime reportedAt, + String resolvedBy, + LocalDateTime resolvedAt, + String resolution, + Long relatedReadingId, + List comments +) {} \ No newline at end of file diff --git a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java index 3574eb9..cae20cf 100644 --- a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java +++ b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java @@ -8,6 +8,7 @@ import backend.fullstack.deviations.api.dto.DeviationCommentRequest; import backend.fullstack.deviations.api.dto.DeviationCommentResponse; +import backend.fullstack.deviations.api.dto.DeviationDetailsResponse; import backend.fullstack.deviations.api.dto.DeviationMapper; import backend.fullstack.deviations.api.dto.DeviationRequest; import backend.fullstack.deviations.api.dto.DeviationResponse; @@ -61,6 +62,41 @@ public List getDeviations(Long organizationId, DeviationStatu .toList(); } + @Transactional(readOnly = true) + public DeviationDetailsResponse getDeviationById(Long organizationId, Long deviationId) { + Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId)); + + List comments = deviationCommentRepository + .findByOrganization_IdAndDeviation_IdOrderByCreatedAtAsc(organizationId, deviationId) + .stream() + .map(comment -> new DeviationCommentResponse( + comment.getId(), + comment.getCommentText(), + comment.getCreatedById(), + comment.getCreatedByName(), + comment.getCreatedAt() + )) + .toList(); + + DeviationResponse base = deviationMapper.toResponse(deviation); + return new DeviationDetailsResponse( + base.id(), + base.title(), + base.description(), + base.status(), + base.severity(), + base.moduleType(), + base.reportedBy(), + base.reportedAt(), + base.resolvedBy(), + base.resolvedAt(), + base.resolution(), + deviation.getRelatedReadingId(), + comments + ); + } + public DeviationResponse createDeviation(Long organizationId, Long userId, DeviationRequest request) { Organization organization = organizationRepository.findById(organizationId) .orElseThrow(() -> new ResourceNotFoundException("Organization", organizationId)); From e708f9085e52889a2f488907750be740547182b7 Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Wed, 8 Apr 2026 15:43:35 +0200 Subject: [PATCH 5/6] feat: add security tests for updating deviation status and adding comments --- ...tionControllerSecurityAnnotationsTest.java | 15 ++ .../api/DeviationControllerTest.java | 118 ++++++++++++++++ .../application/DeviationServiceTest.java | 129 +++++++++++++++++- 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerTest.java diff --git a/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerSecurityAnnotationsTest.java b/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerSecurityAnnotationsTest.java index b30dc68..ba02493 100644 --- a/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerSecurityAnnotationsTest.java +++ b/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerSecurityAnnotationsTest.java @@ -25,6 +25,21 @@ void resolveDeviationRequiresAdminManagerOrSupervisor() { assertPreAuthorize("resolveDeviation", "hasAnyRole('ADMIN','MANAGER','SUPERVISOR')"); } + @Test + void updateDeviationStatusRequiresAdminManagerOrSupervisor() { + assertPreAuthorize("updateDeviationStatus", "hasAnyRole('ADMIN','MANAGER','SUPERVISOR')"); + } + + @Test + void addDeviationCommentAllowsAllAuthenticatedRoles() { + assertPreAuthorize("addDeviationComment", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')"); + } + + @Test + void getDeviationByIdAllowsAllAuthenticatedRoles() { + assertPreAuthorize("getDeviationById", "hasAnyRole('ADMIN','MANAGER','STAFF','SUPERVISOR')"); + } + private void assertPreAuthorize(String methodName, String expectedValue) { Method method = Arrays.stream(DeviationController.class.getDeclaredMethods()) .filter(candidate -> methodName.equals(candidate.getName())) diff --git a/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerTest.java b/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerTest.java new file mode 100644 index 0000000..f13aa90 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/deviations/api/DeviationControllerTest.java @@ -0,0 +1,118 @@ +package backend.fullstack.deviations.api; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import org.springframework.core.MethodParameter; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import backend.fullstack.config.JwtPrincipal; +import backend.fullstack.deviations.api.dto.DeviationCommentResponse; +import backend.fullstack.deviations.api.dto.DeviationDetailsResponse; +import backend.fullstack.deviations.application.DeviationService; +import backend.fullstack.deviations.domain.DeviationModuleType; +import backend.fullstack.deviations.domain.DeviationSeverity; +import backend.fullstack.deviations.domain.DeviationStatus; +import backend.fullstack.user.role.Role; + +class DeviationControllerTest { + + private static final JwtPrincipal PRINCIPAL = new JwtPrincipal( + 7L, + "manager@everest.no", + Role.MANAGER, + 99L, + List.of(1L) + ); + + private MockMvc mockMvc; + private DeviationService deviationService; + + @BeforeEach + void setUp() { + deviationService = org.mockito.Mockito.mock(DeviationService.class); + DeviationController controller = new DeviationController(deviationService); + + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new FixedJwtPrincipalResolver(PRINCIPAL)) + .build(); + } + + @Test + void getDeviationByIdReturnsDetailsWithCommentLog() throws Exception { + LocalDateTime reportedAt = LocalDateTime.of(2026, 4, 8, 10, 15); + LocalDateTime commentAt = LocalDateTime.of(2026, 4, 8, 11, 0); + + DeviationDetailsResponse details = new DeviationDetailsResponse( + 42L, + "Fryser #2 over grense", + "Malt -12.1C", + DeviationStatus.IN_PROGRESS, + DeviationSeverity.CRITICAL, + DeviationModuleType.IK_MAT, + "Kari Larsen", + reportedAt, + null, + null, + null, + 12L, + List.of(new DeviationCommentResponse( + 501L, + "Har bestilt service", + 3L, + "Ola Nordmann", + commentAt + )) + ); + + when(deviationService.getDeviationById(99L, 42L)).thenReturn(details); + + mockMvc.perform(get("/api/deviations/42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Deviation retrieved")) + .andExpect(jsonPath("$.data.id").value(42)) + .andExpect(jsonPath("$.data.relatedReadingId").value(12)) + .andExpect(jsonPath("$.data.comments", hasSize(1))) + .andExpect(jsonPath("$.data.comments[0].id").value(501)) + .andExpect(jsonPath("$.data.comments[0].comment").value("Har bestilt service")); + + verify(deviationService).getDeviationById(99L, 42L); + } + + private static final class FixedJwtPrincipalResolver implements HandlerMethodArgumentResolver { + private final JwtPrincipal principal; + + private FixedJwtPrincipalResolver(JwtPrincipal principal) { + this.principal = principal; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return JwtPrincipal.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + return principal; + } + } +} diff --git a/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java b/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java index 6a24e36..26c5424 100644 --- a/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java +++ b/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -16,14 +17,20 @@ import static org.mockito.Mockito.when; import org.mockito.junit.jupiter.MockitoExtension; +import backend.fullstack.deviations.api.dto.DeviationCommentRequest; +import backend.fullstack.deviations.api.dto.DeviationCommentResponse; +import backend.fullstack.deviations.api.dto.DeviationDetailsResponse; import backend.fullstack.deviations.api.dto.DeviationMapper; import backend.fullstack.deviations.api.dto.DeviationRequest; import backend.fullstack.deviations.api.dto.DeviationResponse; import backend.fullstack.deviations.api.dto.ResolveDeviationRequest; +import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest; import backend.fullstack.deviations.domain.Deviation; +import backend.fullstack.deviations.domain.DeviationComment; import backend.fullstack.deviations.domain.DeviationModuleType; import backend.fullstack.deviations.domain.DeviationSeverity; import backend.fullstack.deviations.domain.DeviationStatus; +import backend.fullstack.deviations.infrastructure.DeviationCommentRepository; import backend.fullstack.deviations.infrastructure.DeviationRepository; import backend.fullstack.organization.Organization; import backend.fullstack.organization.OrganizationRepository; @@ -36,6 +43,9 @@ class DeviationServiceTest { @Mock private DeviationRepository deviationRepository; + @Mock + private DeviationCommentRepository deviationCommentRepository; + @Mock private OrganizationRepository organizationRepository; @@ -49,7 +59,13 @@ class DeviationServiceTest { @BeforeEach void setUp() { - service = new DeviationService(deviationRepository, organizationRepository, userRepository, deviationMapper); + service = new DeviationService( + deviationRepository, + deviationCommentRepository, + organizationRepository, + userRepository, + deviationMapper + ); } @Test @@ -159,6 +175,117 @@ void resolveDeviation_setsStatusResolved() { assertEquals(resolver, saved.getResolvedBy()); } + @Test + void updateDeviationStatus_openToInProgress_setsStatus() { + Long orgId = 1L; + Long userId = 5L; + Long deviationId = 21L; + + User actor = buildUser(userId, orgId); + Deviation existing = buildDeviation(deviationId, orgId, DeviationStatus.OPEN); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(existing)); + when(userRepository.findById(userId)).thenReturn(Optional.of(actor)); + when(deviationRepository.save(any(Deviation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(deviationMapper.toResponse(any(Deviation.class))).thenReturn(buildResponse(deviationId)); + + service.updateDeviationStatus( + orgId, + userId, + deviationId, + new UpdateDeviationStatusRequest(DeviationStatus.IN_PROGRESS, null) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Deviation.class); + verify(deviationRepository).save(captor.capture()); + assertEquals(DeviationStatus.IN_PROGRESS, captor.getValue().getStatus()); + } + + @Test + void updateDeviationStatus_rejectsInvalidTransition() { + Long orgId = 1L; + Long userId = 5L; + Long deviationId = 22L; + + User actor = buildUser(userId, orgId); + Deviation existing = buildDeviation(deviationId, orgId, DeviationStatus.RESOLVED); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(existing)); + when(userRepository.findById(userId)).thenReturn(Optional.of(actor)); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> service.updateDeviationStatus( + orgId, + userId, + deviationId, + new UpdateDeviationStatusRequest(DeviationStatus.IN_PROGRESS, null) + )); + assertNotNull(ex); + } + + @Test + void addComment_returnsCommentResponse() { + Long orgId = 1L; + Long userId = 5L; + Long deviationId = 30L; + + Organization org = buildOrganization(orgId); + User author = buildUser(userId, orgId); + Deviation deviation = buildDeviation(deviationId, orgId, DeviationStatus.OPEN); + deviation.setOrganization(org); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(deviation)); + when(userRepository.findById(userId)).thenReturn(Optional.of(author)); + when(deviationCommentRepository.save(any(DeviationComment.class))).thenAnswer(invocation -> { + DeviationComment comment = invocation.getArgument(0); + comment.setId(100L); + comment.setCreatedAt(LocalDateTime.now()); + return comment; + }); + + DeviationCommentResponse response = service.addComment( + orgId, + userId, + deviationId, + new DeviationCommentRequest("Follow-up done") + ); + + assertEquals(100L, response.id()); + assertEquals("Follow-up done", response.comment()); + assertEquals(userId, response.createdById()); + } + + @Test + void getDeviationById_includesCommentLog() { + Long orgId = 1L; + Long deviationId = 40L; + + Deviation deviation = buildDeviation(deviationId, orgId, DeviationStatus.OPEN); + User author = buildUser(8L, orgId); + DeviationComment comment = DeviationComment.builder() + .id(501L) + .organization(buildOrganization(orgId)) + .deviation(deviation) + .createdBy(author) + .commentText("Investigating") + .createdAt(LocalDateTime.now()) + .build(); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(deviation)); + when(deviationCommentRepository.findByOrganization_IdAndDeviation_IdOrderByCreatedAtAsc(orgId, deviationId)) + .thenReturn(List.of(comment)); + when(deviationMapper.toResponse(any(Deviation.class))).thenReturn(buildResponse(deviationId)); + + DeviationDetailsResponse response = service.getDeviationById(orgId, deviationId); + + assertEquals(deviationId, response.id()); + assertEquals(1, response.comments().size()); + assertEquals("Investigating", response.comments().get(0).comment()); + } + // --- helpers --- private static Organization buildOrganization(Long id) { From 9d95e2401254693df277b5df883ae263d65cd8cd Mon Sep 17 00:00:00 2001 From: Sindre Boe Date: Wed, 8 Apr 2026 17:51:16 +0200 Subject: [PATCH 6/6] fix: updateDeviationStatus and addComment methods in DeviationService --- .../application/DeviationService.java | 90 ++++++++++++-- .../application/DeviationServiceTest.java | 115 ++++++++++++++++++ 2 files changed, 194 insertions(+), 11 deletions(-) diff --git a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java index 3379cf3..b48f1b3 100644 --- a/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java +++ b/backend/src/main/java/backend/fullstack/deviations/application/DeviationService.java @@ -15,7 +15,6 @@ import backend.fullstack.deviations.api.dto.ResolveDeviationRequest; import backend.fullstack.deviations.api.dto.UpdateDeviationStatusRequest; import backend.fullstack.deviations.domain.Deviation; -import backend.fullstack.deviations.domain.DeviationComment; import backend.fullstack.deviations.domain.DeviationStatus; import backend.fullstack.deviations.infrastructure.DeviationCommentRepository; import backend.fullstack.deviations.infrastructure.DeviationRepository; @@ -62,12 +61,12 @@ public List getDeviations(Long organizationId, DeviationStatu .toList(); } - @Transactional(readOnly = true) - public DeviationDetailsResponse getDeviationById(Long organizationId, Long deviationId) { - Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) + @Transactional(readOnly = true) + public DeviationDetailsResponse getDeviationById(Long organizationId, Long deviationId) { + Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) .orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId)); - List comments = deviationCommentRepository + List comments = deviationCommentRepository .findByOrganization_IdAndDeviation_IdOrderByCreatedAtAsc(organizationId, deviationId) .stream() .map(comment -> new DeviationCommentResponse( @@ -79,8 +78,8 @@ public DeviationDetailsResponse getDeviationById(Long organizationId, Long devia )) .toList(); - DeviationResponse base = deviationMapper.toResponse(deviation); - return new DeviationDetailsResponse( + DeviationResponse base = deviationMapper.toResponse(deviation); + return new DeviationDetailsResponse( base.id(), base.title(), base.description(), @@ -94,8 +93,8 @@ public DeviationDetailsResponse getDeviationById(Long organizationId, Long devia base.resolution(), deviation.getRelatedReadingId(), comments - ); - } + ); + } public DeviationResponse createDeviation(Long organizationId, Long userId, DeviationRequest request) { Organization organization = organizationRepository.findById(organizationId) @@ -118,8 +117,15 @@ public DeviationResponse createDeviation(Long organizationId, Long userId, Devia return deviationMapper.toResponse(saved); } - public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long deviationId, ResolveDeviationRequest request) { - validateResolutionForResolvedStatus(request.resolution()); + public DeviationResponse updateDeviationStatus( + Long organizationId, + Long userId, + Long deviationId, + UpdateDeviationStatusRequest request + ) { + if (request.status() == DeviationStatus.RESOLVED) { + validateResolutionForResolvedStatus(request.resolution()); + } Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) .orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId)); @@ -146,9 +152,71 @@ public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long return deviationMapper.toResponse(saved); } + public DeviationCommentResponse addComment( + Long organizationId, + Long userId, + Long deviationId, + DeviationCommentRequest request + ) { + Deviation deviation = deviationRepository.findByIdAndOrganization_Id(deviationId, organizationId) + .orElseThrow(() -> new ResourceNotFoundException("Deviation", deviationId)); + + User author = userRepository.findById(userId) + .orElseThrow(() -> new ResourceNotFoundException("User", userId)); + + assertUserInOrganization(author, organizationId); + + var comment = backend.fullstack.deviations.domain.DeviationComment.builder() + .organization(deviation.getOrganization()) + .deviation(deviation) + .createdBy(author) + .commentText(request.comment()) + .build(); + + var saved = deviationCommentRepository.save(comment); + return new DeviationCommentResponse( + saved.getId(), + saved.getCommentText(), + saved.getCreatedById(), + saved.getCreatedByName(), + saved.getCreatedAt() + ); + } + + public DeviationResponse resolveDeviation(Long organizationId, Long userId, Long deviationId, ResolveDeviationRequest request) { + return updateDeviationStatus( + organizationId, + userId, + deviationId, + new UpdateDeviationStatusRequest(DeviationStatus.RESOLVED, request.resolution()) + ); + } + private void validateResolutionForResolvedStatus(String resolution) { if (resolution == null || resolution.isBlank()) { throw new IllegalArgumentException("Resolution text is required when status is RESOLVED"); } } + + private void assertUserInOrganization(User user, Long organizationId) { + if (!organizationId.equals(user.getOrganizationId())) { + throw new IllegalArgumentException("User does not belong to this organization"); + } + } + + private void assertAllowedTransition(DeviationStatus current, DeviationStatus target) { + if (current == target) { + return; + } + + boolean valid = switch (current) { + case OPEN -> target == DeviationStatus.IN_PROGRESS || target == DeviationStatus.RESOLVED; + case IN_PROGRESS -> target == DeviationStatus.RESOLVED; + case RESOLVED -> false; + }; + + if (!valid) { + throw new IllegalArgumentException("Invalid status transition: " + current + " -> " + target); + } + } } diff --git a/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java b/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java index 783ba1e..3651afb 100644 --- a/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java +++ b/backend/src/test/java/backend/fullstack/deviations/application/DeviationServiceTest.java @@ -175,6 +175,121 @@ void resolveDeviation_setsStatusResolved() { assertEquals(resolver, saved.getResolvedBy()); } + @Test + void updateDeviationStatus_openToInProgress_setsStatus() { + Long orgId = 1L; + Long userId = 5L; + Long deviationId = 21L; + + User actor = buildUser(userId, orgId); + Deviation existing = buildDeviation(deviationId, orgId, DeviationStatus.OPEN); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(existing)); + when(userRepository.findById(userId)).thenReturn(Optional.of(actor)); + when(deviationRepository.save(any(Deviation.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(deviationMapper.toResponse(any(Deviation.class))).thenReturn(buildResponse(deviationId)); + + service.updateDeviationStatus( + orgId, + userId, + deviationId, + new UpdateDeviationStatusRequest(DeviationStatus.IN_PROGRESS, null) + ); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Deviation.class); + verify(deviationRepository).save(captor.capture()); + assertEquals(DeviationStatus.IN_PROGRESS, captor.getValue().getStatus()); + } + + @Test + void updateDeviationStatus_invalidTransition_throwsIllegalArgumentException() { + Long orgId = 1L; + Long userId = 5L; + Long deviationId = 22L; + + User actor = buildUser(userId, orgId); + Deviation existing = buildDeviation(deviationId, orgId, DeviationStatus.RESOLVED); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(existing)); + when(userRepository.findById(userId)).thenReturn(Optional.of(actor)); + + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> service.updateDeviationStatus( + orgId, + userId, + deviationId, + new UpdateDeviationStatusRequest(DeviationStatus.IN_PROGRESS, null) + ) + ); + + assertEquals("Invalid status transition: RESOLVED -> IN_PROGRESS", ex.getMessage()); + } + + @Test + void addComment_returnsCommentResponse() { + Long orgId = 1L; + Long userId = 5L; + Long deviationId = 30L; + + Organization org = buildOrganization(orgId); + User author = buildUser(userId, orgId); + Deviation deviation = buildDeviation(deviationId, orgId, DeviationStatus.OPEN); + deviation.setOrganization(org); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(deviation)); + when(userRepository.findById(userId)).thenReturn(Optional.of(author)); + when(deviationCommentRepository.save(any(DeviationComment.class))).thenAnswer(invocation -> { + DeviationComment comment = invocation.getArgument(0); + comment.setId(100L); + comment.setCreatedAt(LocalDateTime.now()); + return comment; + }); + + DeviationCommentResponse response = service.addComment( + orgId, + userId, + deviationId, + new DeviationCommentRequest("Follow-up done") + ); + + assertEquals(100L, response.id()); + assertEquals("Follow-up done", response.comment()); + assertEquals(userId, response.createdById()); + } + + @Test + void getDeviationById_includesCommentLog() { + Long orgId = 1L; + Long deviationId = 40L; + + Deviation deviation = buildDeviation(deviationId, orgId, DeviationStatus.OPEN); + User author = buildUser(8L, orgId); + DeviationComment comment = DeviationComment.builder() + .id(501L) + .organization(buildOrganization(orgId)) + .deviation(deviation) + .createdBy(author) + .commentText("Investigating") + .createdAt(LocalDateTime.now()) + .build(); + + when(deviationRepository.findByIdAndOrganization_Id(deviationId, orgId)) + .thenReturn(Optional.of(deviation)); + when(deviationCommentRepository.findByOrganization_IdAndDeviation_IdOrderByCreatedAtAsc(orgId, deviationId)) + .thenReturn(List.of(comment)); + when(deviationMapper.toResponse(any(Deviation.class))).thenReturn(buildResponse(deviationId)); + + DeviationDetailsResponse response = service.getDeviationById(orgId, deviationId); + + assertEquals(deviationId, response.id()); + assertEquals(1, response.comments().size()); + assertEquals("Investigating", response.comments().get(0).comment()); + } + @Test void resolveDeviation_blankResolution_throwsIllegalArgumentException() { IllegalArgumentException ex = assertThrows(