From 9054cc62d32f39f21ca2f8f935a9a82ae971fbc6 Mon Sep 17 00:00:00 2001
From: Nessar
Date: Fri, 24 Jul 2026 19:55:28 +0200
Subject: [PATCH] Keep lease-ending violations domain-shaped (#54)
---
.../in/web/lease/LeaseEndingController.java | 78 +++++++++++++++++--
...aseEndingApplicationValidationFailure.java | 19 +++++
.../application/lease/LeaseEndingService.java | 7 +-
.../rental/domain/lease/Lease.java | 37 ++++++---
.../domain/lease/LeaseEndingHistory.java | 4 +-
.../lease/LeaseEndingValidationFailure.java | 28 +++++--
.../rental/domain/lease/TenantNotice.java | 9 ++-
.../in/web/lease/LeaseEndingWebTest.java | 37 ++++++++-
.../lease/LeaseEndingServiceTest.java | 28 ++++++-
.../rental/domain/lease/LeaseEndingTest.java | 4 +-
10 files changed, 213 insertions(+), 38 deletions(-)
create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingApplicationValidationFailure.java
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));