Keep lease-ending violations domain-shaped (#54)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 603b508aec
commit 9054cc62d3
10 files changed
+213 -38

No files matched your search

@@ -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<LeaseEndingApi.OperationError> conflict(LeaseEndingConflict failu
@ExceptionHandler(LeaseEndingValidationFailure.class)
ResponseEntity<LeaseEndingApi.ValidationError> 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<LeaseEndingApi.ValidationError> invalid(
LeaseEndingApplicationValidationFailure failure) {
return validationFailure(applicationError(failure.rule()));
}
@ExceptionHandler(LeaseEndingRequestValidationFailure.class)
ResponseEntity<LeaseEndingApi.ValidationError> 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<LeaseEndingApi.ValidationError> 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;
}
}
}
@@ -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
}
}
@@ -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);
@@ -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<LocalDate> 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);
}
}
@@ -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;
}
@@ -1,18 +1,30 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.util.List;
public final class LeaseEndingValidationFailure extends RuntimeException {
private final List<FieldError> 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<FieldError> 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
}
}
@@ -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);
}
}
@@ -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());
@@ -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);
@@ -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));