diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java index 99b557a..eeeacba 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingController.java @@ -1,6 +1,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.lease; import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord; +import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingApplicationValidationFailure; import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService; import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict; @@ -59,13 +60,19 @@ ResponseEntity conflict(LeaseEndingConflict failu @ExceptionHandler(LeaseEndingValidationFailure.class) ResponseEntity invalid(LeaseEndingValidationFailure failure) { - return ResponseEntity.badRequest() - .body( - new LeaseEndingApi.ValidationError( - "validation_failed", - failure.errors().stream() - .map(error -> new LeaseEndingApi.FieldError(error.field(), error.code())) - .toList())); + return validationFailure(domainError(failure.rule())); +} + +@ExceptionHandler(LeaseEndingApplicationValidationFailure.class) +ResponseEntity invalid( + LeaseEndingApplicationValidationFailure failure) { + return validationFailure(applicationError(failure.rule())); +} + +@ExceptionHandler(LeaseEndingRequestValidationFailure.class) +ResponseEntity invalid( + LeaseEndingRequestValidationFailure failure) { + return validationFailure(failure.error()); } @ExceptionHandler({ @@ -242,10 +249,53 @@ private static ZoneId zone(String value) { try { return ZoneId.of(value); } catch (DateTimeException failure) { - throw new LeaseEndingValidationFailure("timeZone", "iana_time_zone"); + throw new LeaseEndingRequestValidationFailure("timeZone", "iana_time_zone"); } } +private static ResponseEntity validationFailure( + LeaseEndingApi.FieldError error) { + return ResponseEntity.badRequest() + .body(new LeaseEndingApi.ValidationError("validation_failed", java.util.List.of(error))); +} + +private static LeaseEndingApi.FieldError domainError(LeaseEndingValidationFailure.Rule rule) { + return switch (rule) { + case RECEIVED_ON_IN_FUTURE -> new LeaseEndingApi.FieldError("receivedOn", "not_future"); + case LATE_REGISTRATION_NOT_CONFIRMED -> + new LeaseEndingApi.FieldError("lateRegistrationConfirmed", "required"); + case REQUESTED_DEPARTURE_BEFORE_RECEIPT -> + new LeaseEndingApi.FieldError("requestedDepartureOn", "after_or_equal_received_on"); + case NON_STANDARD_DELIVERY_NOT_CONFIRMED -> + new LeaseEndingApi.FieldError("nonStandardDeliveryConfirmed", "required"); + case OTHER_DELIVERY_MODE_TOO_LONG -> + new LeaseEndingApi.FieldError("otherDeliveryMode", "max_length"); + case OTHER_DELIVERY_MODE_MISSING -> + new LeaseEndingApi.FieldError("otherDeliveryMode", "required"); + case OTHER_DELIVERY_MODE_UNEXPECTED -> + new LeaseEndingApi.FieldError("otherDeliveryMode", "forbidden"); + case WITHDRAWAL_ON_OUTSIDE_NOTICE_PERIOD -> + new LeaseEndingApi.FieldError("acceptedOn", "between_notice_received_on_and_today"); + case INVALIDATION_ON_OUTSIDE_NOTICE_PERIOD -> + new LeaseEndingApi.FieldError("invalidatedOn", "between_notice_received_on_and_today"); + case AGREED_END_BEFORE_LEASE_START -> + new LeaseEndingApi.FieldError("endOn", "after_or_equal_lease_start"); + case AGREED_END_DOES_NOT_ADVANCE_CURRENT_END -> + new LeaseEndingApi.FieldError("endOn", "before_current_end"); + case REASON_REQUIRED -> new LeaseEndingApi.FieldError("reason", "required"); + case REASON_TOO_LONG -> new LeaseEndingApi.FieldError("reason", "max_length"); + }; +} + +private static LeaseEndingApi.FieldError applicationError( + LeaseEndingApplicationValidationFailure.Rule rule) { + return switch (rule) { + case MISSING_EVIDENCE_CONFIRMATION -> + new LeaseEndingApi.FieldError("missingEvidenceConfirmed", "required"); + case EMPTY_EVIDENCE -> new LeaseEndingApi.FieldError("evidence", "empty"); + }; +} + private static String safeFileName(String value) { String normalized = value == null ? "justificatif" : value.replace('\\', '/'); String leaf = normalized.substring(normalized.lastIndexOf('/') + 1).trim(); @@ -274,4 +324,16 @@ record AgreedEndRequest( @NotBlank @Size(max = 500) String reason) {} record ClosureRequest(@NotBlank String timeZone, @AssertTrue boolean confirmed) {} + +private static final class LeaseEndingRequestValidationFailure extends RuntimeException { + private final LeaseEndingApi.FieldError error; + + private LeaseEndingRequestValidationFailure(String field, String code) { + this.error = new LeaseEndingApi.FieldError(field, code); + } + + private LeaseEndingApi.FieldError error() { + return error; + } +} } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingApplicationValidationFailure.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingApplicationValidationFailure.java new file mode 100644 index 0000000..eade2a2 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingApplicationValidationFailure.java @@ -0,0 +1,19 @@ +package fr.nessar.lmnporganizer.rental.application.lease; + +public final class LeaseEndingApplicationValidationFailure extends RuntimeException { +private final Rule rule; + +public LeaseEndingApplicationValidationFailure(Rule rule) { + super("La demande de fin de bail est invalide."); + this.rule = rule; +} + +public Rule rule() { + return rule; +} + +public enum Rule { + MISSING_EVIDENCE_CONFIRMATION, + EMPTY_EVIDENCE +} +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java index c03f1a7..3ea7d13 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingService.java @@ -15,7 +15,6 @@ import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancyRepository; import fr.nessar.lmnporganizer.rental.domain.lease.Lease; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingConflict; -import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndingValidationFailure; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRepository; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation; @@ -94,7 +93,8 @@ public LeaseEndingState recordNotice( EvidenceUpload evidence) { Objects.requireNonNull(request); if (evidence == null && !request.missingEvidenceConfirmed()) { - throw new LeaseEndingValidationFailure("missingEvidenceConfirmed", "required"); + throw new LeaseEndingApplicationValidationFailure( + LeaseEndingApplicationValidationFailure.Rule.MISSING_EVIDENCE_CONFIRMATION); } TenantNoticeId noticeId = new TenantNoticeId(identifiers.next()); StoredEvidence stored = evidence == null ? null : store(evidence); @@ -343,7 +343,8 @@ private StoredEvidence store(EvidenceUpload upload) { byte[] content = Objects.requireNonNull(upload.content(), "Evidence content is required.").clone(); if (content.length == 0) { - throw new LeaseEndingValidationFailure("evidence", "empty"); + throw new LeaseEndingApplicationValidationFailure( + LeaseEndingApplicationValidationFailure.Rule.EMPTY_EVIDENCE); } if (content.length > maxEvidenceBytes) { throw new AttachmentTooLarge(maxEvidenceBytes); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java index ef8a3b8..da98412 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/Lease.java @@ -507,17 +507,21 @@ public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionA Objects.requireNonNull(command); Objects.requireNonNull(audit); if (command.receivedOn().isAfter(command.today())) { - throw new LeaseEndingValidationFailure("receivedOn", "not_future"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.RECEIVED_ON_IN_FUTURE); } if (command.receivedOn().isBefore(command.today()) && !command.lateRegistrationConfirmed()) { - throw new LeaseEndingValidationFailure("lateRegistrationConfirmed", "required"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.LATE_REGISTRATION_NOT_CONFIRMED); } if (command.requestedDepartureOn().isBefore(command.receivedOn())) { - throw new LeaseEndingValidationFailure("requestedDepartureOn", "after_or_equal_received_on"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.REQUESTED_DEPARTURE_BEFORE_RECEIPT); } if (command.deliveryMode() == NoticeDeliveryMode.OTHER && !command.nonStandardDeliveryConfirmed()) { - throw new LeaseEndingValidationFailure("nonStandardDeliveryConfirmed", "required"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.NON_STANDARD_DELIVERY_NOT_CONFIRMED); } TenantNotice notice = new TenantNotice( @@ -546,7 +550,11 @@ public NoticeWithdrawal acceptNoticeWithdrawal( endingHistory .notice(noticeId) .orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable.")); - validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn(), "acceptedOn"); + validateAdministrativeFactDate( + acceptedOn, + today, + notice.receivedOn(), + LeaseEndingValidationFailure.Rule.WITHDRAWAL_ON_OUTSIDE_NOTICE_PERIOD); return endingHistory.withdraw(noticeId, acceptedOn, reason, audit); } @@ -561,7 +569,11 @@ public NoticeInvalidation invalidateNotice( endingHistory .notice(noticeId) .orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable.")); - validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn(), "invalidatedOn"); + validateAdministrativeFactDate( + invalidatedOn, + today, + notice.receivedOn(), + LeaseEndingValidationFailure.Rule.INVALIDATION_ON_OUTSIDE_NOTICE_PERIOD); return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit); } @@ -570,11 +582,13 @@ public AgreedEarlyLeaseEnd recordAgreedEarlyEnd( requireActiveEndingSupported(); Objects.requireNonNull(endOn, "Agreed end date is required."); if (endOn.isBefore(period.startDate())) { - throw new LeaseEndingValidationFailure("endOn", "after_or_equal_lease_start"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.AGREED_END_BEFORE_LEASE_START); } Optional currentEnd = contractEndOn(); if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) { - throw new LeaseEndingValidationFailure("endOn", "before_current_end"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.AGREED_END_DOES_NOT_ADVANCE_CURRENT_END); } return endingHistory.agree(endOn, reason, audit); } @@ -694,11 +708,14 @@ private void requireEndingSupported() { } private static void validateAdministrativeFactDate( - LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn, String field) { + LocalDate factOn, + LocalDate today, + LocalDate noticeReceivedOn, + LeaseEndingValidationFailure.Rule rule) { Objects.requireNonNull(factOn, "Administrative fact date is required."); Objects.requireNonNull(today, "Current civil date is required."); if (factOn.isBefore(noticeReceivedOn) || factOn.isAfter(today)) { - throw new LeaseEndingValidationFailure(field, "between_notice_received_on_and_today"); + throw new LeaseEndingValidationFailure(rule); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java index 0cf2194..82605b3 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingHistory.java @@ -215,11 +215,11 @@ private void requireKnownNotice(TenantNoticeId noticeId) { static String requiredReason(String reason) { if (reason == null || reason.isBlank()) { - throw new LeaseEndingValidationFailure("reason", "required"); + throw new LeaseEndingValidationFailure(LeaseEndingValidationFailure.Rule.REASON_REQUIRED); } String value = reason.trim(); if (value.length() > 500) { - throw new LeaseEndingValidationFailure("reason", "max_length"); + throw new LeaseEndingValidationFailure(LeaseEndingValidationFailure.Rule.REASON_TOO_LONG); } return value; } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java index 4a06a83..02e4455 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java @@ -1,18 +1,30 @@ package fr.nessar.lmnporganizer.rental.domain.lease; -import java.util.List; - public final class LeaseEndingValidationFailure extends RuntimeException { -private final List errors; +private final Rule rule; -public LeaseEndingValidationFailure(String field, String code) { +public LeaseEndingValidationFailure(Rule rule) { super("La demande de fin de bail est invalide."); - this.errors = List.of(new FieldError(field, code)); + this.rule = rule; } -public List errors() { - return errors; +public Rule rule() { + return rule; } -public record FieldError(String field, String code) {} +public enum Rule { + RECEIVED_ON_IN_FUTURE, + LATE_REGISTRATION_NOT_CONFIRMED, + REQUESTED_DEPARTURE_BEFORE_RECEIPT, + NON_STANDARD_DELIVERY_NOT_CONFIRMED, + OTHER_DELIVERY_MODE_TOO_LONG, + OTHER_DELIVERY_MODE_MISSING, + OTHER_DELIVERY_MODE_UNEXPECTED, + WITHDRAWAL_ON_OUTSIDE_NOTICE_PERIOD, + INVALIDATION_ON_OUTSIDE_NOTICE_PERIOD, + AGREED_END_BEFORE_LEASE_START, + AGREED_END_DOES_NOT_ADVANCE_CURRENT_END, + REASON_REQUIRED, + REASON_TOO_LONG +} } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java index 0c5e153..47608dc 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/TenantNotice.java @@ -33,13 +33,16 @@ public TenantNotice { ruleVersion = ruleVersion.trim(); otherDeliveryMode = optional(otherDeliveryMode); if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) { - throw new LeaseEndingValidationFailure("otherDeliveryMode", "max_length"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.OTHER_DELIVERY_MODE_TOO_LONG); } if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) { - throw new LeaseEndingValidationFailure("otherDeliveryMode", "required"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.OTHER_DELIVERY_MODE_MISSING); } if (deliveryMode != NoticeDeliveryMode.OTHER && otherDeliveryMode != null) { - throw new LeaseEndingValidationFailure("otherDeliveryMode", "forbidden"); + throw new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.OTHER_DELIVERY_MODE_UNEXPECTED); } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java index 840064f..9e312b3 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingWebTest.java @@ -20,6 +20,7 @@ import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguratio import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord; import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler; import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService; +import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingApplicationValidationFailure; import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingService; import fr.nessar.lmnporganizer.rental.application.lease.LeaseEndingState; import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId; @@ -143,7 +144,9 @@ void lifecycleConflictsRemainDistinctFromRequestValidation() throws Exception { @Test void domainValidationUsesStableFieldPathsAndCodes() throws Exception { when(endings.recordNotice(any(), any(), any(), any(), any())) - .thenThrow(new LeaseEndingValidationFailure("receivedOn", "not_future")); + .thenThrow( + new LeaseEndingValidationFailure( + LeaseEndingValidationFailure.Rule.RECEIVED_ON_IN_FUTURE)); mockMvc .perform( @@ -163,6 +166,38 @@ void domainValidationUsesStableFieldPathsAndCodes() throws Exception { .andExpect(jsonPath("$.errors[0].code").value("not_future")); } +@Test +void applicationValidationAndInvalidTimeZonesUseTheSameStableFieldContract() throws Exception { + when(endings.recordNotice(any(), any(), any(), any(), any())) + .thenThrow( + new LeaseEndingApplicationValidationFailure( + LeaseEndingApplicationValidationFailure.Rule.MISSING_EVIDENCE_CONFIRMATION)); + + mockMvc + .perform( + multipart("/api/leases/lease-1/end/notices") + .param("timeZone", "Europe/Paris") + .param("receivedOn", "2026-07-19") + .param("requestedDepartureOn", "2026-08-19") + .param("deliveryMode", "REGISTERED_LETTER") + .param("lateRegistrationConfirmed", "false") + .param("nonStandardDeliveryConfirmed", "false") + .param("missingEvidenceConfirmed", "false") + .with(landlord()) + .with(csrf())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("validation_failed")) + .andExpect(jsonPath("$.errors[0].field").value("missingEvidenceConfirmed")) + .andExpect(jsonPath("$.errors[0].code").value("required")); + + mockMvc + .perform(get("/api/leases/lease-1/end").param("timeZone", "Mars/Olympus").with(landlord())) + .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 noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception { when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state()); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java index 1438a1b..16346ec 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingServiceTest.java @@ -120,7 +120,7 @@ void noticeWithoutEvidenceRequiresTheExplicitAttestation() { storage); assertThrows( - LeaseEndingValidationFailure.class, + LeaseEndingApplicationValidationFailure.class, () -> service.recordNotice( LANDLORD_ID, @@ -141,6 +141,32 @@ void noticeWithoutEvidenceRequiresTheExplicitAttestation() { verify(storage, never()).store(any()); } +@Test +void emptyEvidenceIsAnApplicationValidationFailure() { + LeaseDocumentStorage storage = mock(LeaseDocumentStorage.class); + LeaseEndingService service = + service( + mock(LeaseRepository.class), + mock(ApartmentOccupancyRepository.class), + mock(RentDueRepository.class), + storage); + + LeaseEndingApplicationValidationFailure failure = + assertThrows( + LeaseEndingApplicationValidationFailure.class, + () -> + service.appendEvidence( + LANDLORD_ID, + LEASE_ID, + new TenantNoticeId("notice-1"), + "oidc:landlord-1", + new LeaseEndingService.EvidenceUpload( + PARIS, "conge.pdf", "application/pdf", new byte[0], null))); + + assertEquals(LeaseEndingApplicationValidationFailure.Rule.EMPTY_EVIDENCE, failure.rule()); + verify(storage, never()).store(any()); +} + @Test void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEnteredDate() { Clock boundaryClock = Clock.fixed(Instant.parse("2026-07-19T00:30:00Z"), ZoneOffset.UTC); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java index 4cc02d8..ee930b8 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingTest.java @@ -321,8 +321,8 @@ void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() { assertThrows( LeaseEndingValidationFailure.class, () -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT)); - assertEquals("endOn", noChange.errors().getFirst().field()); - assertEquals("before_current_end", noChange.errors().getFirst().code()); + assertEquals( + LeaseEndingValidationFailure.Rule.AGREED_END_DOES_NOT_ADVANCE_CURRENT_END, noChange.rule()); assertThrows( LeaseEndingValidationFailure.class, () -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT));