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