Align lease ending validation flows (#54)
This commit is contained in:
1 parent
ca3e28d3a2
commit
a0f86bb511
25 files changed
+847
-130
No files matched your search
+10
@@ -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),
|
||||
|
||||
+48
-5
@@ -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) {}
|
||||
}
|
||||
+7
-3
@@ -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);
|
||||
|
||||
+6
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.domain.lease;
|
||||
|
||||
public final class LeaseEndingConflict extends RuntimeException {
|
||||
public LeaseEndingConflict(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -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;
|
||||
}
|
||||
|
||||
+18
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+50
@@ -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());
|
||||
|
||||
+5
-3
@@ -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,
|
||||
|
||||
+17
-14
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user