Align lease ending validation flows (#54)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent ca3e28d3a2
commit a0f86bb511
25 files changed
+847 -130

No files matched your search

@@ -19,14 +19,22 @@ import java.util.List;
final class LeaseEndingApi {
private LeaseEndingApi() {}
record OperationError(String message) {}
record ValidationError(String code, List<FieldError> errors) {}
record FieldError(String field, String code) {}
record StateResponse(
String leaseId,
boolean supported,
String unsupportedReason,
LeaseEndPhase phase,
LeaseType leaseType,
LocalDate leaseStartOn,
LocalDate fixedEndOn,
LocalDate effectiveEndOn,
long maxEvidenceBytes,
boolean correctionRequired,
List<NoticeResponse> notices,
AgreedEarlyEndResponse agreedEarlyEnd,
@@ -41,8 +49,10 @@ record StateResponse(
state.unsupportedReason().orElse(null),
state.phase(),
state.leaseType(),
state.leaseStartOn(),
state.fixedEndOn().orElse(null),
state.effectiveEndOn().orElse(null),
state.maxEvidenceBytes(),
state.correctionRequired(),
history.notices().stream().map(notice -> NoticeResponse.from(notice, history)).toList(),
history.latestAgreement().map(AgreedEarlyEndResponse::from).orElse(null),
@@ -3,12 +3,16 @@ 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.LeaseEndingService;
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
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.NoticeDeliveryMode;
import fr.nessar.lmnporganizer.rental.domain.lease.TenantNoticeId;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.IOException;
import java.time.DateTimeException;
import java.time.LocalDate;
@@ -18,8 +22,11 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -28,7 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
@RestController("rentalLeaseEndingController")
@RequestMapping("/api/leases/{leaseId}/end")
@@ -42,6 +51,34 @@ public LeaseEndingController(
this.authenticatedLandlord = authenticatedLandlord;
}
@ExceptionHandler(LeaseEndingConflict.class)
ResponseEntity<LeaseEndingApi.OperationError> conflict(LeaseEndingConflict failure) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new LeaseEndingApi.OperationError(failure.getMessage()));
}
@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()));
}
@ExceptionHandler({
MissingServletRequestParameterException.class,
MissingServletRequestPartException.class,
MethodArgumentTypeMismatchException.class,
HttpMessageNotReadableException.class
})
ResponseEntity<LeaseEndingApi.OperationError> invalidRequest() {
return ResponseEntity.badRequest()
.body(new LeaseEndingApi.OperationError("La requête de fin de bail est invalide."));
}
@GetMapping
LeaseEndingApi.StateResponse state(
@AuthenticationPrincipal OidcUser principal,
@@ -205,7 +242,7 @@ private static ZoneId zone(String value) {
try {
return ZoneId.of(value);
} catch (DateTimeException failure) {
throw new IllegalArgumentException("Le fuseau horaire IANA est invalide.", failure);
throw new LeaseEndingValidationFailure("timeZone", "iana_time_zone");
}
}
@@ -222,13 +259,19 @@ private static String safeFileName(String value) {
}
record WithdrawalRequest(
@NotBlank String timeZone, @NotNull LocalDate acceptedOn, @NotBlank String reason) {}
@NotBlank String timeZone,
@NotNull LocalDate acceptedOn,
@NotBlank @Size(max = 500) String reason) {}
record InvalidationRequest(
@NotBlank String timeZone, @NotNull LocalDate invalidatedOn, @NotBlank String reason) {}
@NotBlank String timeZone,
@NotNull LocalDate invalidatedOn,
@NotBlank @Size(max = 500) String reason) {}
record AgreedEndRequest(
@NotBlank String timeZone, @NotNull LocalDate endOn, @NotBlank String reason) {}
@NotBlank String timeZone,
@NotNull LocalDate endOn,
@NotBlank @Size(max = 500) String reason) {}
record ClosureRequest(@NotBlank String timeZone, boolean confirmed) {}
record ClosureRequest(@NotBlank String timeZone, @AssertTrue boolean confirmed) {}
}
@@ -14,6 +14,8 @@ import fr.nessar.lmnporganizer.rental.domain.lease.ApartmentOccupancy;
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;
@@ -92,7 +94,7 @@ public LeaseEndingState recordNotice(
EvidenceUpload evidence) {
Objects.requireNonNull(request);
if (evidence == null && !request.missingEvidenceConfirmed()) {
throw new LeaseRuleViolation("L'absence de justificatif doit être confirmée.");
throw new LeaseEndingValidationFailure("missingEvidenceConfirmed", "required");
}
TenantNoticeId noticeId = new TenantNoticeId(identifiers.next());
StoredEvidence stored = evidence == null ? null : store(evidence);
@@ -191,7 +193,7 @@ public LeaseEndingState close(
.filter(PropertyConditionReport::finalized)
.orElseThrow(
() ->
new LeaseRuleViolation(
new LeaseEndingConflict(
"Un état des lieux de sortie finalisé est requis pour clôturer le bail."));
lease.close(
exitReport.occurredOn(),
@@ -295,8 +297,10 @@ private LeaseEndingState state(
"La fin de bail locataire n'est pas prise en charge pour une colocation."),
lease.endPhaseOn(today),
lease.leaseType(),
lease.period().startDate(),
lease.fixedEndOn(),
lease.contractEndOn(),
maxEvidenceBytes,
dues.stream().anyMatch(RentDue::correctionRequired),
reports
.find(landlordId, lease.id(), PropertyConditionReportType.EXIT)
@@ -339,7 +343,7 @@ private StoredEvidence store(EvidenceUpload upload) {
byte[] content =
Objects.requireNonNull(upload.content(), "Evidence content is required.").clone();
if (content.length == 0) {
throw new LeaseRuleViolation("Le justificatif est vide.");
throw new LeaseEndingValidationFailure("evidence", "empty");
}
if (content.length > maxEvidenceBytes) {
throw new AttachmentTooLarge(maxEvidenceBytes);
@@ -14,8 +14,10 @@ public record LeaseEndingState(
Optional<String> unsupportedReason,
LeaseEndPhase phase,
LeaseType leaseType,
LocalDate leaseStartOn,
Optional<LocalDate> fixedEndOn,
Optional<LocalDate> effectiveEndOn,
long maxEvidenceBytes,
boolean correctionRequired,
Optional<ExitConditionReport> exitConditionReport,
LeaseEndingHistory history) {
@@ -24,8 +26,12 @@ public LeaseEndingState {
unsupportedReason = unsupportedReason == null ? Optional.empty() : unsupportedReason;
Objects.requireNonNull(phase);
Objects.requireNonNull(leaseType);
Objects.requireNonNull(leaseStartOn);
fixedEndOn = fixedEndOn == null ? Optional.empty() : fixedEndOn;
effectiveEndOn = effectiveEndOn == null ? Optional.empty() : effectiveEndOn;
if (maxEvidenceBytes <= 0) {
throw new IllegalArgumentException("Maximum evidence size must be positive.");
}
exitConditionReport = exitConditionReport == null ? Optional.empty() : exitConditionReport;
Objects.requireNonNull(history);
}
@@ -507,18 +507,17 @@ public TenantNotice recordTenantNotice(TenantNoticeCommand command, LeaseActionA
Objects.requireNonNull(command);
Objects.requireNonNull(audit);
if (command.receivedOn().isAfter(command.today())) {
throw new LeaseRuleViolation("La date de réception du congé ne peut pas être future.");
throw new LeaseEndingValidationFailure("receivedOn", "not_future");
}
if (command.receivedOn().isBefore(command.today()) && !command.lateRegistrationConfirmed()) {
throw new LeaseRuleViolation("La saisie tardive du congé doit être confirmée.");
throw new LeaseEndingValidationFailure("lateRegistrationConfirmed", "required");
}
if (command.requestedDepartureOn().isBefore(command.receivedOn())) {
throw new LeaseRuleViolation(
"La date de départ demandée ne peut pas précéder la réception du congé.");
throw new LeaseEndingValidationFailure("requestedDepartureOn", "after_or_equal_received_on");
}
if (command.deliveryMode() == NoticeDeliveryMode.OTHER
&& !command.nonStandardDeliveryConfirmed()) {
throw new LeaseRuleViolation("Le mode de remise non standard doit être confirmé.");
throw new LeaseEndingValidationFailure("nonStandardDeliveryConfirmed", "required");
}
TenantNotice notice =
new TenantNotice(
@@ -547,7 +546,7 @@ public NoticeWithdrawal acceptNoticeWithdrawal(
endingHistory
.notice(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn());
validateAdministrativeFactDate(acceptedOn, today, notice.receivedOn(), "acceptedOn");
return endingHistory.withdraw(noticeId, acceptedOn, reason, audit);
}
@@ -562,7 +561,7 @@ public NoticeInvalidation invalidateNotice(
endingHistory
.notice(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn());
validateAdministrativeFactDate(invalidatedOn, today, notice.receivedOn(), "invalidatedOn");
return endingHistory.invalidate(noticeId, invalidatedOn, reason, audit);
}
@@ -571,11 +570,11 @@ public AgreedEarlyLeaseEnd recordAgreedEarlyEnd(
requireActiveEndingSupported();
Objects.requireNonNull(endOn, "Agreed end date is required.");
if (endOn.isBefore(period.startDate())) {
throw new LeaseRuleViolation("La fin convenue ne peut pas précéder le début du bail.");
throw new LeaseEndingValidationFailure("endOn", "after_or_equal_lease_start");
}
Optional<LocalDate> currentEnd = contractEndOn();
if (currentEnd.isPresent() && !endOn.isBefore(currentEnd.orElseThrow())) {
throw new LeaseRuleViolation("La fin convenue doit avancer la fin de bail actuelle.");
throw new LeaseEndingValidationFailure("endOn", "before_current_end");
}
return endingHistory.agree(endOn, reason, audit);
}
@@ -597,11 +596,11 @@ public LeaseClosure close(
LocalDate endOn =
contractEndOn()
.orElseThrow(
() -> new LeaseRuleViolation("Le bail n'a pas encore de fin contractuelle."));
() -> new LeaseEndingConflict("Le bail n'a pas encore de fin contractuelle."));
Objects.requireNonNull(keysReturnedOn, "Keys return date is required.");
Objects.requireNonNull(today, "Current civil date is required.");
if (today.isBefore(endOn)) {
throw new LeaseRuleViolation("Le bail ne peut être clôturé avant sa fin contractuelle.");
throw new LeaseEndingConflict("Le bail ne peut être clôturé avant sa fin contractuelle.");
}
if (keysReturnedOn.isBefore(period.startDate()) || keysReturnedOn.isAfter(today)) {
throw new LeaseRuleViolation(
@@ -682,25 +681,24 @@ public LeaseEndPhase endPhaseOn(LocalDate date) {
private void requireActiveEndingSupported() {
if (status != LeaseStatus.ACTIVE) {
throw new LeaseRuleViolation("Seul un bail actif peut recevoir un fait de fin de bail.");
throw new LeaseEndingConflict("Seul un bail actif peut recevoir un fait de fin de bail.");
}
requireEndingSupported();
}
private void requireEndingSupported() {
if (!endingSupported()) {
throw new LeaseRuleViolation(
throw new LeaseEndingConflict(
"La fin de bail locataire n'est pas prise en charge pour une colocation.");
}
}
private static void validateAdministrativeFactDate(
LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn) {
LocalDate factOn, LocalDate today, LocalDate noticeReceivedOn, String field) {
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 LeaseRuleViolation(
"La date administrative doit suivre le congé et ne peut pas être future.");
throw new LeaseEndingValidationFailure(field, "between_notice_received_on_and_today");
}
}
@@ -0,0 +1,7 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
public final class LeaseEndingConflict extends RuntimeException {
public LeaseEndingConflict(String message) {
super(message);
}
}
@@ -51,7 +51,7 @@ TenantNotice recordNotice(TenantNotice notice) {
throw new LeaseRuleViolation("Ce congé existe déjà.");
}
if (activeNotice().isPresent()) {
throw new LeaseRuleViolation("Un congé locataire est déjà actif pour ce bail.");
throw new LeaseEndingConflict("Un congé locataire est déjà actif pour ce bail.");
}
notices = appended(notices, notice);
return notice;
@@ -94,7 +94,7 @@ NoticeEvidenceVersion appendEvidence(
LeaseClosure close(LocalDate keysReturnedOn, LeaseActionAudit audit) {
if (closure != null) {
throw new LeaseRuleViolation("Le bail est déjà clôturé.");
throw new LeaseEndingConflict("Le bail est déjà clôturé.");
}
closure = new LeaseClosure(keysReturnedOn, audit);
return closure;
@@ -157,7 +157,7 @@ private TenantNotice requireActive(TenantNoticeId noticeId) {
find(noticeId)
.orElseThrow(() -> new LeaseRuleViolation("Le congé locataire est introuvable."));
if (statusOf(noticeId) != TenantNoticeStatus.ACTIVE) {
throw new LeaseRuleViolation("Seul un congé actif peut être modifié.");
throw new LeaseEndingConflict("Seul un congé actif peut être modifié.");
}
return notice;
}
@@ -215,11 +215,11 @@ private void requireKnownNotice(TenantNoticeId noticeId) {
static String requiredReason(String reason) {
if (reason == null || reason.isBlank()) {
throw new LeaseRuleViolation("La raison est obligatoire.");
throw new LeaseEndingValidationFailure("reason", "required");
}
String value = reason.trim();
if (value.length() > 500) {
throw new LeaseRuleViolation("La raison ne peut pas dépasser 500 caractères.");
throw new LeaseEndingValidationFailure("reason", "max_length");
}
return value;
}
@@ -0,0 +1,18 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import java.util.List;
public final class LeaseEndingValidationFailure extends RuntimeException {
private final List<FieldError> errors;
public LeaseEndingValidationFailure(String field, String code) {
super("La demande de fin de bail est invalide.");
this.errors = List.of(new FieldError(field, code));
}
public List<FieldError> errors() {
return errors;
}
public record FieldError(String field, String code) {}
}
@@ -33,14 +33,13 @@ public TenantNotice {
ruleVersion = ruleVersion.trim();
otherDeliveryMode = optional(otherDeliveryMode);
if (otherDeliveryMode != null && otherDeliveryMode.length() > 200) {
throw new LeaseRuleViolation(
"Le libellé du mode de remise ne peut pas dépasser 200 caractères.");
throw new LeaseEndingValidationFailure("otherDeliveryMode", "max_length");
}
if (deliveryMode == NoticeDeliveryMode.OTHER && otherDeliveryMode == null) {
throw new LeaseRuleViolation("Le mode de remise alternatif est obligatoire.");
throw new LeaseEndingValidationFailure("otherDeliveryMode", "required");
}
if (deliveryMode != NoticeDeliveryMode.OTHER && otherDeliveryMode != null) {
throw new LeaseRuleViolation("Le détail du mode de remise est réservé au mode OTHER.");
throw new LeaseEndingValidationFailure("otherDeliveryMode", "forbidden");
}
}
@@ -29,6 +29,8 @@ import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.Lease;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseActionAudit;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseEndPhase;
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.LeaseSnapshot;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
@@ -102,6 +104,8 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
.with(landlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.phase").value("NOTICE_PERIOD"))
.andExpect(jsonPath("$.leaseStartOn").value("2026-01-01"))
.andExpect(jsonPath("$.maxEvidenceBytes").value(262144000))
.andExpect(jsonPath("$.notices[0].ruleVersion").value("2026-07"))
.andExpect(jsonPath("$.notices[0].recordedWithoutEvidenceConfirmed").value(true))
.andExpect(jsonPath("$.notices[0].authorId").value("subject-1"))
@@ -115,6 +119,50 @@ void getRequiresAValidIanaTimeZoneAndExposesTheCanonicalAppendOnlyHistory() thro
verify(endings).state(LANDLORD_ID, LEASE_ID, ZoneId.of("America/Los_Angeles"));
}
@Test
void lifecycleConflictsRemainDistinctFromRequestValidation() throws Exception {
when(endings.recordAgreedEarlyEnd(any(), any(), any(), any()))
.thenThrow(
new LeaseEndingConflict("La fin convenue doit avancer la fin de bail actuelle."));
mockMvc
.perform(
post("/api/leases/lease-1/end/agreed-early-end")
.with(landlord())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"timeZone":"Europe/Paris","endOn":"2026-08-18","reason":"Accord écrit"}
"""))
.andExpect(status().isConflict())
.andExpect(
jsonPath("$.message").value("La fin convenue doit avancer la fin de bail actuelle."));
}
@Test
void domainValidationUsesStableFieldPathsAndCodes() throws Exception {
when(endings.recordNotice(any(), any(), any(), any(), any()))
.thenThrow(new LeaseEndingValidationFailure("receivedOn", "not_future"));
mockMvc
.perform(
multipart("/api/leases/lease-1/end/notices")
.param("timeZone", "Europe/Paris")
.param("receivedOn", "2026-07-20")
.param("requestedDepartureOn", "2026-08-20")
.param("deliveryMode", "REGISTERED_LETTER")
.param("lateRegistrationConfirmed", "false")
.param("nonStandardDeliveryConfirmed", "false")
.param("missingEvidenceConfirmed", "true")
.with(landlord())
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("receivedOn"))
.andExpect(jsonPath("$.errors[0].code").value("not_future"));
}
@Test
void noticeMultipartForwardsTimezoneEvidenceAttestationAndAuthenticatedAuthor() throws Exception {
when(endings.recordNotice(any(), any(), any(), any(), any())).thenReturn(state());
@@ -310,8 +358,10 @@ private static LeaseEndingState state() {
Optional.empty(),
LeaseEndPhase.NOTICE_PERIOD,
LeaseType.STANDARD,
LocalDate.of(2026, 1, 1),
Optional.empty(),
Optional.of(LocalDate.of(2026, 8, 18)),
250L * 1024 * 1024,
false,
Optional.of(new LeaseEndingState.ExitConditionReport(LocalDate.of(2026, 8, 18), true)),
lease.endingHistory());
@@ -22,6 +22,8 @@ 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.LeaseEndPhase;
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;
@@ -118,7 +120,7 @@ void noticeWithoutEvidenceRequiresTheExplicitAttestation() {
storage);
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
service.recordNotice(
LANDLORD_ID,
@@ -189,7 +191,7 @@ void civilTodayIsComputedInTheLandlordSuppliedTimeZoneWithoutConvertingTheEntere
boundaryClock);
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
lasVegasService.recordNotice(
LANDLORD_ID,
@@ -459,7 +461,7 @@ void closingRejectsAnUnfinalizedExitReport() {
reports);
assertThrows(
LeaseRuleViolation.class,
LeaseEndingConflict.class,
() ->
service.close(
LANDLORD_ID,
@@ -56,7 +56,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
Lease lease = activeLease(LeaseType.STANDARD, List.of(TENANT_ID));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -69,7 +69,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false),
AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -82,7 +82,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false),
AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -95,7 +95,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
false),
AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -108,7 +108,7 @@ void noticeHonorsTheRequestedLaterDepartureAndValidatesCivilDateConfirmations()
true),
AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() ->
lease.recordTenantNotice(
notice(
@@ -161,7 +161,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
assertFalse(shared.endingSupported());
assertEquals(fixedEnd, shared.contractEndOn().orElseThrow());
assertThrows(
LeaseRuleViolation.class,
LeaseEndingConflict.class,
() ->
shared.recordTenantNotice(
notice(
@@ -174,7 +174,7 @@ void studentFixedEndCapsAnEarlierNoticeAndAllEndingCommandsRejectSharedLeases()
false),
AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingConflict.class,
() -> shared.recordAgreedEarlyEnd(TODAY.plusMonths(1), "Accord", AUDIT));
}
@@ -290,7 +290,7 @@ void withdrawalAndInvalidationAppendAuditFactsWithoutErasingNotices() {
assertEquals(1, lease.endingHistory().withdrawals().size());
assertEquals(1, lease.endingHistory().invalidations().size());
assertThrows(
LeaseRuleViolation.class,
LeaseEndingConflict.class,
() ->
lease.acceptNoticeWithdrawal(
new TenantNoticeId("invalidated"),
@@ -317,11 +317,14 @@ void agreedEarlyEndWorksWithoutNoticeAndCanShortenAnExistingNotice() {
false,
false),
AUDIT);
LeaseEndingValidationFailure noChange =
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());
assertThrows(
LeaseRuleViolation.class,
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 9, 30), "Sans effet", AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() -> lease.recordAgreedEarlyEnd(LocalDate.of(2026, 10, 1), "Repousse la fin", AUDIT));
lease.recordAgreedEarlyEnd(LocalDate.of(2026, 8, 31), "Accord postérieur au congé", AUDIT);
@@ -347,10 +350,10 @@ void evidenceIsVersionedAndEveryReplacementRequiresAReason() {
NoticeEvidenceVersion first = lease.appendNoticeEvidence(noticeId, firstFile, null, AUDIT);
assertEquals(1, first.version());
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() -> lease.appendNoticeEvidence(noticeId, replacement, " ", AUDIT));
assertThrows(
LeaseRuleViolation.class,
LeaseEndingValidationFailure.class,
() -> lease.appendNoticeEvidence(noticeId, replacement, "x".repeat(501), AUDIT));
NoticeEvidenceVersion second =
lease.appendNoticeEvidence(noticeId, replacement, "Document corrigé", AUDIT);