Align rent revision validation flows (#56)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 41590a6a78
commit 8b37c96671
17 files changed
+519 -59

No files matched your search

@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
@@ -15,6 +16,12 @@ import java.util.Set;
final class RentRevisionApi {
private RentRevisionApi() {}
record OperationError(String message) {}
record FieldError(String field, String code) {}
record ValidationError(String code, List<FieldError> errors) {}
record PreviewRequest(
@NotBlank String timeZone,
@Size(max = 9) String seriesIdOverride,
@@ -24,7 +31,7 @@ record PreviewRequest(
Boolean manualIndicesConfirmed) {
RentRevisionService.PreviewRequest toApplication() {
return new RentRevisionService.PreviewRequest(
RentPaymentController.timeZone(timeZone),
RentRevisionController.timeZone(timeZone),
seriesIdOverride,
Boolean.TRUE.equals(seriesOverrideConfirmed),
previousIndex,
@@ -37,7 +44,7 @@ record ProposalRequest(
@NotBlank String timeZone,
@NotNull LocalDate requestedOn,
@NotNull @Positive BigDecimal chosenRent,
@Positive BigDecimal chosenCharges,
@PositiveOrZero BigDecimal chosenCharges,
List<@NotBlank String> confirmedWarningCodes,
@Size(max = 9) String seriesIdOverride,
Boolean seriesOverrideConfirmed,
@@ -47,7 +54,7 @@ record ProposalRequest(
RentRevisionService.ProposalRequest toApplication() {
var preview =
new RentRevisionService.PreviewRequest(
RentPaymentController.timeZone(timeZone),
RentRevisionController.timeZone(timeZone),
seriesIdOverride,
Boolean.TRUE.equals(seriesOverrideConfirmed),
previousIndex,
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionValidationFailure;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
@@ -9,16 +10,21 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
import jakarta.validation.Valid;
import java.io.IOException;
import java.time.Clock;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
import java.util.List;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -27,7 +33,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
@RestController
@RequestMapping("/api/leases/{leaseId}/rent-revisions")
@@ -43,6 +51,28 @@ public RentRevisionController(
this.clock = clock;
}
@ExceptionHandler(RentRevisionValidationFailure.class)
ResponseEntity<RentRevisionApi.ValidationError> invalid(RentRevisionValidationFailure failure) {
return ResponseEntity.badRequest()
.body(
new RentRevisionApi.ValidationError(
"validation_failed",
failure.errors().stream()
.map(error -> new RentRevisionApi.FieldError(error.field(), error.code()))
.toList()));
}
@ExceptionHandler({
MissingServletRequestParameterException.class,
MissingServletRequestPartException.class,
MethodArgumentTypeMismatchException.class,
HttpMessageNotReadableException.class
})
ResponseEntity<RentRevisionApi.OperationError> invalidRequest() {
return ResponseEntity.badRequest()
.body(new RentRevisionApi.OperationError("La requête de révision IRL est invalide."));
}
@PostMapping("/preview")
RentRevisionApi.PreviewResponse preview(
@AuthenticationPrincipal OidcUser principal,
@@ -118,18 +148,22 @@ RentRevisionApi.ApplyResponse apply(
@PathVariable String leaseId,
@PathVariable String revisionId,
@RequestParam String timeZone,
@RequestParam LocalDate sentOn,
@RequestParam String sentOn,
@RequestParam String deliveryMethod,
@RequestParam boolean deliveryConfirmed,
@RequestParam String deliveryConfirmed,
@RequestPart(value = "proof", required = false) MultipartFile proof) {
ZoneId zone = RentPaymentController.timeZone(timeZone);
ZoneId zone = timeZone(timeZone);
RentRevisionService.ApplyResult result =
revisions.apply(
authenticatedLandlord.id(principal),
new LeaseId(leaseId),
new RentRevisionId(revisionId),
new RentRevisionService.ApplyRequest(
zone, sentOn, deliveryMethod(deliveryMethod), deliveryConfirmed, upload(proof)),
zone,
date(sentOn),
deliveryMethod(deliveryMethod),
deliveryConfirmed(deliveryConfirmed),
upload(proof)),
principal.getSubject());
LocalDate today = LocalDate.now(clock.withZone(zone));
return new RentRevisionApi.ApplyResponse(
@@ -150,16 +184,40 @@ private static RentRevisionService.PdfUpload upload(MultipartFile file) {
}
private static RentRevision.DeliveryMethod deliveryMethod(String value) {
if (value == null) {
throw new IllegalArgumentException("Le mode d'envoi est invalide.");
}
return switch (value) {
if (value == null) throw new RentRevisionValidationFailure("deliveryMethod", "invalid");
return switch (value.trim()) {
case "REGISTERED_LETTER" -> RentRevision.DeliveryMethod.REGISTERED_LETTER;
case "POSTAL_LETTER" -> RentRevision.DeliveryMethod.POSTAL_LETTER;
case "HAND_DELIVERY" -> RentRevision.DeliveryMethod.HAND_DELIVERY;
case "EMAIL" -> RentRevision.DeliveryMethod.EMAIL;
case "OTHER" -> RentRevision.DeliveryMethod.OTHER;
default -> throw new IllegalArgumentException("Le mode d'envoi est invalide.");
default -> throw new RentRevisionValidationFailure("deliveryMethod", "invalid");
};
}
static ZoneId timeZone(String value) {
if (value == null || value.isBlank()) {
throw new RentRevisionValidationFailure("timeZone", "iana_time_zone");
}
try {
return ZoneId.of(value);
} catch (DateTimeException failure) {
throw new RentRevisionValidationFailure("timeZone", "iana_time_zone");
}
}
private static LocalDate date(String value) {
try {
return LocalDate.parse(value);
} catch (DateTimeParseException failure) {
throw new RentRevisionValidationFailure("sentOn", "invalid");
}
}
private static boolean deliveryConfirmed(String value) {
if ("true".equals(value)) return true;
if ("false".equals(value)) return false;
throw new RentRevisionValidationFailure("deliveryConfirmed", "invalid");
}
}
@@ -213,12 +213,11 @@ public ApplyResult apply(
Objects.requireNonNull(request, "Rent revision application request is required.");
String author = required(authorId, "Application author");
if (!request.deliveryConfirmed()) {
throw new RentRuleViolation("L'envoi de la demande doit être confirmé.");
throw new RentRevisionValidationFailure("deliveryConfirmed", "required");
}
if (request.sentOn() == null
|| request.sentOn().isAfter(LocalDate.now(clock.withZone(request.timeZone())))) {
throw new RentRuleViolation(
"La date d'envoi ne peut pas être future dans le fuseau du bailleur.");
throw new RentRevisionValidationFailure("sentOn", "future");
}
RentRevision proposed =
revisions
@@ -639,7 +638,7 @@ private static String sha256(byte[] content) {
private byte[] pdf(PdfUpload upload) {
byte[] content = upload.content();
if (content == null || content.length == 0) {
throw new RentRuleViolation("Le justificatif PDF est vide.");
throw new RentRevisionValidationFailure("proof", "invalid");
}
if (content.length > maximumPdfSize) {
throw new AttachmentTooLarge(maximumPdfSize);
@@ -647,7 +646,7 @@ private byte[] pdf(PdfUpload upload) {
if (!"application/pdf".equalsIgnoreCase(upload.contentType())
|| content.length < 5
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
throw new RentRuleViolation("Le justificatif doit être un PDF lisible.");
throw new RentRevisionValidationFailure("proof", "invalid");
}
return content;
}
@@ -0,0 +1,24 @@
package fr.nessar.lmnporganizer.rental.application.rent;
import java.util.List;
import java.util.Objects;
public final class RentRevisionValidationFailure extends RuntimeException {
private final List<FieldError> errors;
public RentRevisionValidationFailure(String field, String code) {
super("La requête de révision IRL est invalide.");
this.errors = List.of(new FieldError(field, code));
}
public List<FieldError> errors() {
return errors;
}
public record FieldError(String field, String code) {
public FieldError {
Objects.requireNonNull(field);
Objects.requireNonNull(code);
}
}
}
@@ -1,6 +1,7 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@@ -19,6 +20,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionValidationFailure;
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
@@ -147,7 +149,7 @@ void unavailableOfficialAndCachedIndicesExposeAStableManualFallbackCode() throws
}
@Test
void rejectsAnUnknownDeliveryMethodWithoutExposingInternalClassNames() throws Exception {
void rejectsAnUnknownDeliveryMethodWithAStableFieldCode() throws Exception {
mockMvc
.perform(
multipart("/api/leases/lease-1/rent-revisions/revision-1/apply")
@@ -158,7 +160,55 @@ void rejectsAnUnknownDeliveryMethodWithoutExposingInternalClassNames() throws Ex
.with(owner())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("Le mode d'envoi est invalide."));
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("deliveryMethod"))
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
}
@Test
void translatesInvalidDeliveryFactsAndProofMetadataToFieldErrors() throws Exception {
assertApplyViolation(
"false",
null,
"deliveryConfirmed",
"required");
assertApplyViolation(
"true",
null,
"sentOn",
"future");
assertApplyViolation(
"true",
new MockMultipartFile("proof", "preuve.pdf", "application/pdf", "not a PDF".getBytes()),
"proof",
"invalid");
}
@Test
void rejectsAnInvalidPreviewTimeZoneWithAStableFieldCode() throws Exception {
mockMvc
.perform(
post("/api/leases/lease-1/rent-revisions/preview")
.with(owner())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"timeZone\":\"invalid-zone\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("timeZone"))
.andExpect(jsonPath("$.errors[0].code").value("iana_time_zone"));
}
@Test
void rejectsMissingMultipartParametersWithASafeOperationMessage() throws Exception {
mockMvc
.perform(
multipart("/api/leases/lease-1/rent-revisions/revision-1/apply")
.param("timeZone", "Europe/Paris")
.with(owner())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("La requête de révision IRL est invalide."));
}
@Test
@@ -342,4 +392,27 @@ private static RequestPostProcessor owner() {
.claim("family_name", "Martin")
.claim("email", "camille@example.com"));
}
private void assertApplyViolation(
String deliveryConfirmed, MockMultipartFile proof, String field, String code)
throws Exception {
doThrow(new RentRevisionValidationFailure(field, code))
.when(revisions)
.apply(any(), any(), any(), any(), any());
var request = multipart("/api/leases/lease-1/rent-revisions/revision-1/apply");
if (proof != null) request.file(proof);
mockMvc
.perform(
request
.param("timeZone", "Europe/Paris")
.param("sentOn", "2027-09-02")
.param("deliveryMethod", "POSTAL_LETTER")
.param("deliveryConfirmed", deliveryConfirmed)
.with(owner())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value(field))
.andExpect(jsonPath("$.errors[0].code").value(code));
}
}