Keep revision violations domain-shaped (#56)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 41b086fb33
commit fe36d6cae4
5 files changed
+119 -31

No files matched your search

@@ -56,10 +56,7 @@ ResponseEntity<RentRevisionApi.ValidationError> 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");
};
}
}
@@ -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;
}
@@ -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<FieldError> 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<FieldError> 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
}
}
@@ -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");
@@ -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();