From a0f86bb511fcd098118bf56b45e4efa55a42a423 Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 24 Jul 2026 19:10:13 +0200 Subject: [PATCH] Align lease ending validation flows (#54) --- .../adapter/in/web/lease/LeaseEndingApi.java | 10 + .../in/web/lease/LeaseEndingController.java | 53 +++++- .../application/lease/LeaseEndingService.java | 10 +- .../application/lease/LeaseEndingState.java | 6 + .../rental/domain/lease/Lease.java | 30 ++- .../domain/lease/LeaseEndingConflict.java | 7 + .../domain/lease/LeaseEndingHistory.java | 10 +- .../lease/LeaseEndingValidationFailure.java | 18 ++ .../rental/domain/lease/TenantNotice.java | 7 +- .../in/web/lease/LeaseEndingWebTest.java | 50 +++++ .../lease/LeaseEndingServiceTest.java | 8 +- .../rental/domain/lease/LeaseEndingTest.java | 31 +-- .../lease-end-notice-card.html | 86 +++++++-- .../lease-end-notice-card.spec.ts | 2 + .../lease-end-notice-card.ts | 123 +++++++++++- .../lease-end-notice-form.html | 99 +++++++++- .../lease-end-notice-form.spec.ts | 34 +++- .../lease-end-notice-form.ts | 180 +++++++++++++++--- .../lease-end-page/lease-end-page.html | 19 ++ .../lease-end-page/lease-end-page.spec.ts | 32 ++++ .../lease-end-page/lease-end-page.ts | 34 +++- .../lease-end-resolution-forms.html | 46 ++++- .../lease-end-resolution-forms.spec.ts | 1 + .../lease-end-resolution-forms.ts | 79 +++++++- .../src/app/services/lease-end.service.ts | 2 + 25 files changed, 847 insertions(+), 130 deletions(-) create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java index 2e3b1bf..195f5af 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/lease/LeaseEndingApi.java @@ -19,14 +19,22 @@ import java.util.List; final class LeaseEndingApi { private LeaseEndingApi() {} +record OperationError(String message) {} + +record ValidationError(String code, List 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 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), 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 5fb5f5b..99b557a 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 @@ -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 conflict(LeaseEndingConflict failure) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(new LeaseEndingApi.OperationError(failure.getMessage())); +} + +@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())); +} + +@ExceptionHandler({ + MissingServletRequestParameterException.class, + MissingServletRequestPartException.class, + MethodArgumentTypeMismatchException.class, + HttpMessageNotReadableException.class +}) +ResponseEntity 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) {} } 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 3f99767..c03f1a7 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 @@ -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); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java index 1759c1f..a0992b3 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/application/lease/LeaseEndingState.java @@ -14,8 +14,10 @@ public record LeaseEndingState( Optional unsupportedReason, LeaseEndPhase phase, LeaseType leaseType, + LocalDate leaseStartOn, Optional fixedEndOn, Optional effectiveEndOn, + long maxEvidenceBytes, boolean correctionRequired, Optional 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); } 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 f8fe36a..ef8a3b8 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,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 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"); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java new file mode 100644 index 0000000..b4cba7a --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingConflict.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.rental.domain.lease; + +public final class LeaseEndingConflict extends RuntimeException { +public LeaseEndingConflict(String message) { + super(message); +} +} 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 97426d8..0cf2194 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 @@ -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; } 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 new file mode 100644 index 0000000..4a06a83 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/lease/LeaseEndingValidationFailure.java @@ -0,0 +1,18 @@ +package fr.nessar.lmnporganizer.rental.domain.lease; + +import java.util.List; + +public final class LeaseEndingValidationFailure extends RuntimeException { +private final List 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 errors() { + return errors; +} + +public record FieldError(String field, String code) {} +} 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 7cbb884..0c5e153 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,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"); } } 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 c6220bd..840064f 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 @@ -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()); 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 c215ff2..1438a1b 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 @@ -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, 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 11f9338..4cc02d8 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 @@ -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); diff --git a/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html b/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html index 06b3d0c..a53f333 100644 --- a/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html +++ b/frontend/src/app/components/lease-end-notice-card/lease-end-notice-card.html @@ -159,17 +159,45 @@ >
Accepter un retrait - + + @if (withdrawalDateError()) { + + } + @if (serverError(withdrawalFieldErrors(), 'acceptedOn')) { + + } - @if (withdrawalError() || withdrawalOperationError()) { + @if (withdrawalReasonError()) { + } + @if (serverError(withdrawalFieldErrors(), 'reason')) { + + } + @if (withdrawalOperationError()) { + }