From fe36d6cae4532de1b6070646b367ca096bc3059a Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 24 Jul 2026 19:55:53 +0200 Subject: [PATCH] Keep revision violations domain-shaped (#56) --- .../in/web/rent/RentRevisionController.java | 36 ++++++++++---- .../application/rent/RentRevisionService.java | 9 ++-- .../rent/RentRevisionValidationFailure.java | 24 +++++----- .../in/web/rent/RentRevisionWebTest.java | 47 ++++++++++++++++--- .../rent/RentRevisionServiceTest.java | 34 ++++++++++++++ 5 files changed, 119 insertions(+), 31 deletions(-) diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionController.java index f585e99..b6aa6f9 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionController.java @@ -56,10 +56,7 @@ ResponseEntity invalid(RentRevisionValidationFa return ResponseEntity.badRequest() .body( new RentRevisionApi.ValidationError( - "validation_failed", - failure.errors().stream() - .map(error -> new RentRevisionApi.FieldError(error.field(), error.code())) - .toList())); + "validation_failed", List.of(fieldError(failure.rule())))); } @ExceptionHandler({ @@ -184,25 +181,30 @@ private static RentRevisionService.PdfUpload upload(MultipartFile file) { } private static RentRevision.DeliveryMethod deliveryMethod(String value) { - if (value == null) throw new RentRevisionValidationFailure("deliveryMethod", "invalid"); + if (value == null) { + throw new RentRevisionValidationFailure( + RentRevisionValidationFailure.Rule.INVALID_DELIVERY_METHOD); + } 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 RentRevisionValidationFailure("deliveryMethod", "invalid"); + default -> + throw new RentRevisionValidationFailure( + RentRevisionValidationFailure.Rule.INVALID_DELIVERY_METHOD); }; } static ZoneId timeZone(String value) { if (value == null || value.isBlank()) { - throw new RentRevisionValidationFailure("timeZone", "iana_time_zone"); + throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_TIME_ZONE); } try { return ZoneId.of(value); } catch (DateTimeException failure) { - throw new RentRevisionValidationFailure("timeZone", "iana_time_zone"); + throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_TIME_ZONE); } } @@ -210,14 +212,28 @@ private static LocalDate date(String value) { try { return LocalDate.parse(value); } catch (DateTimeParseException failure) { - throw new RentRevisionValidationFailure("sentOn", "invalid"); + throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_SENT_ON); } } private static boolean deliveryConfirmed(String value) { if ("true".equals(value)) return true; if ("false".equals(value)) return false; - throw new RentRevisionValidationFailure("deliveryConfirmed", "invalid"); + throw new RentRevisionValidationFailure( + RentRevisionValidationFailure.Rule.INVALID_DELIVERY_CONFIRMATION); } +private static RentRevisionApi.FieldError fieldError(RentRevisionValidationFailure.Rule rule) { + return switch (rule) { + case INVALID_DELIVERY_METHOD -> new RentRevisionApi.FieldError("deliveryMethod", "invalid"); + case INVALID_TIME_ZONE -> new RentRevisionApi.FieldError("timeZone", "iana_time_zone"); + case INVALID_SENT_ON -> new RentRevisionApi.FieldError("sentOn", "invalid"); + case INVALID_DELIVERY_CONFIRMATION -> + new RentRevisionApi.FieldError("deliveryConfirmed", "invalid"); + case DELIVERY_CONFIRMATION_REQUIRED -> + new RentRevisionApi.FieldError("deliveryConfirmed", "required"); + case SENT_ON_IN_FUTURE -> new RentRevisionApi.FieldError("sentOn", "future"); + case INVALID_PROOF -> new RentRevisionApi.FieldError("proof", "invalid"); + }; +} } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionService.java index 79e758c..be96586 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionService.java @@ -213,11 +213,12 @@ public ApplyResult apply( Objects.requireNonNull(request, "Rent revision application request is required."); String author = required(authorId, "Application author"); if (!request.deliveryConfirmed()) { - throw new RentRevisionValidationFailure("deliveryConfirmed", "required"); + throw new RentRevisionValidationFailure( + RentRevisionValidationFailure.Rule.DELIVERY_CONFIRMATION_REQUIRED); } if (request.sentOn() == null || request.sentOn().isAfter(LocalDate.now(clock.withZone(request.timeZone())))) { - throw new RentRevisionValidationFailure("sentOn", "future"); + throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.SENT_ON_IN_FUTURE); } RentRevision proposed = revisions @@ -638,7 +639,7 @@ private static String sha256(byte[] content) { private byte[] pdf(PdfUpload upload) { byte[] content = upload.content(); if (content == null || content.length == 0) { - throw new RentRevisionValidationFailure("proof", "invalid"); + throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_PROOF); } if (content.length > maximumPdfSize) { throw new AttachmentTooLarge(maximumPdfSize); @@ -646,7 +647,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 RentRevisionValidationFailure("proof", "invalid"); + throw new RentRevisionValidationFailure(RentRevisionValidationFailure.Rule.INVALID_PROOF); } return content; } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionValidationFailure.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionValidationFailure.java index 9ebeff1..1570789 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionValidationFailure.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionValidationFailure.java @@ -1,24 +1,26 @@ package fr.nessar.lmnporganizer.rental.application.rent; -import java.util.List; import java.util.Objects; public final class RentRevisionValidationFailure extends RuntimeException { -private final List errors; +private final Rule rule; -public RentRevisionValidationFailure(String field, String code) { +public RentRevisionValidationFailure(Rule rule) { super("La requête de révision IRL est invalide."); - this.errors = List.of(new FieldError(field, code)); + this.rule = Objects.requireNonNull(rule); } -public List errors() { - return errors; +public Rule rule() { + return rule; } -public record FieldError(String field, String code) { - public FieldError { - Objects.requireNonNull(field); - Objects.requireNonNull(code); - } +public enum Rule { + INVALID_DELIVERY_METHOD, + INVALID_TIME_ZONE, + INVALID_SENT_ON, + INVALID_DELIVERY_CONFIRMATION, + DELIVERY_CONFIRMATION_REQUIRED, + SENT_ON_IN_FUTURE, + INVALID_PROOF } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionWebTest.java index 295b413..d3d67f4 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionWebTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/rent/RentRevisionWebTest.java @@ -165,21 +165,52 @@ void rejectsAnUnknownDeliveryMethodWithAStableFieldCode() throws Exception { .andExpect(jsonPath("$.errors[0].code").value("invalid")); } +@Test +void rejectsInvalidApplicationDatesAndDeliveryConfirmationsWithStableFieldCodes() + throws Exception { + mockMvc + .perform( + multipart("/api/leases/lease-1/rent-revisions/revision-1/apply") + .param("timeZone", "Europe/Paris") + .param("sentOn", "not-a-date") + .param("deliveryMethod", "POSTAL_LETTER") + .param("deliveryConfirmed", "true") + .with(owner()) + .with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("validation_failed")) + .andExpect(jsonPath("$.errors[0].field").value("sentOn")) + .andExpect(jsonPath("$.errors[0].code").value("invalid")); + + mockMvc + .perform( + multipart("/api/leases/lease-1/rent-revisions/revision-1/apply") + .param("timeZone", "Europe/Paris") + .param("sentOn", "2027-09-02") + .param("deliveryMethod", "POSTAL_LETTER") + .param("deliveryConfirmed", "yes") + .with(owner()) + .with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("validation_failed")) + .andExpect(jsonPath("$.errors[0].field").value("deliveryConfirmed")) + .andExpect(jsonPath("$.errors[0].code").value("invalid")); +} + @Test void translatesInvalidDeliveryFactsAndProofMetadataToFieldErrors() throws Exception { assertApplyViolation( "false", null, + RentRevisionValidationFailure.Rule.DELIVERY_CONFIRMATION_REQUIRED, "deliveryConfirmed", "required"); assertApplyViolation( - "true", - null, - "sentOn", - "future"); + "true", null, RentRevisionValidationFailure.Rule.SENT_ON_IN_FUTURE, "sentOn", "future"); assertApplyViolation( "true", new MockMultipartFile("proof", "preuve.pdf", "application/pdf", "not a PDF".getBytes()), + RentRevisionValidationFailure.Rule.INVALID_PROOF, "proof", "invalid"); } @@ -394,9 +425,13 @@ private static RequestPostProcessor owner() { } private void assertApplyViolation( - String deliveryConfirmed, MockMultipartFile proof, String field, String code) + String deliveryConfirmed, + MockMultipartFile proof, + RentRevisionValidationFailure.Rule rule, + String field, + String code) throws Exception { - doThrow(new RentRevisionValidationFailure(field, code)) + doThrow(new RentRevisionValidationFailure(rule)) .when(revisions) .apply(any(), any(), any(), any(), any()); var request = multipart("/api/leases/lease-1/rent-revisions/revision-1/apply"); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionServiceTest.java index 670f098..014c2c6 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/rent/RentRevisionServiceTest.java @@ -356,6 +356,40 @@ void refusesToRenderOrStoreAProposalWhoseEffectiveDateIsAfterTheLeaseEnd() { assertEquals(List.of(), revisionRepository.values); } +@Test +void identifiesApplicationValidationRulesWithoutWebDetails() { + RentRevisionService service = + new RentRevisionService( + mock(LeaseRepository.class), + mock(IrlIndexProvider.class), + mock(RentRevisionRepository.class), + mock(RentDueRepository.class), + mock(RentalDocumentRenderer.class), + mock(LeaseDocumentStorage.class), + directTransactions(), + () -> "revision-1", + Clock.fixed(Instant.parse("2027-09-02T10:00:00Z"), ZoneOffset.UTC), + 1024); + + RentRevisionValidationFailure failure = + assertThrows( + RentRevisionValidationFailure.class, + () -> + service.apply( + LANDLORD_ID, + LEASE_ID, + new RentRevisionId("revision-1"), + new RentRevisionService.ApplyRequest( + ZoneId.of("Europe/Paris"), + LocalDate.of(2027, 9, 2), + RentRevision.DeliveryMethod.POSTAL_LETTER, + false, + null), + "oidc:user-1")); + + assertEquals(RentRevisionValidationFailure.Rule.DELIVERY_CONFIRMATION_REQUIRED, failure.rule()); +} + @Test void proposesFromTheFinancialTermsEffectiveOnTheRequestDateAfterAnAmendment() { Lease lease = leaseWithFinancialAmendmentAfterAnniversary();