Align core lease and amendment validation (#49)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 96bd02e3a6
commit ca3e28d3a2
20 files changed
+1361 -56

No files matched your search

@@ -19,6 +19,7 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseSpecialClause;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseStatus;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
@@ -33,6 +34,7 @@ import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.MonthDay;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -155,7 +157,12 @@ record DraftRequest(
ChargeMethod chargeMethod,
Boolean indexFlatRate,
@Size(max = 20) List<@Valid SpecialClauseRequest> specialClauses,
@Valid LegalTermsRequest legalTerms) {}
@Valid LegalTermsRequest legalTerms) {
@AssertTrue
boolean isTenantIdsDistinct() {
return tenantIds == null || new HashSet<>(tenantIds).size() == tenantIds.size();
}
}
record AmendmentRequest(
@NotNull LocalDate effectiveDate,
@@ -167,7 +174,17 @@ record AmendmentRequest(
@NotNull @PositiveOrZero BigDecimal monthlyCharges,
@Min(1) @Max(28) Integer dueDay,
ChargeMethod chargeMethod,
Boolean indexFlatRate) {}
Boolean indexFlatRate) {
@AssertTrue
boolean isTenantIdsDistinct() {
return tenantIds == null || new HashSet<>(tenantIds).size() == tenantIds.size();
}
@AssertTrue
boolean isEndDateOnOrAfterEffectiveDate() {
return effectiveDate == null || endDate == null || !endDate.isBefore(effectiveDate);
}
}
record GuarantorResponse(
String id, String firstName, String lastName, String email, String phone, String address) {
@@ -28,6 +28,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.DeleteMapping;
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;
@@ -97,6 +98,10 @@ ResponseEntity<LeaseApi.Response> amend(
@AuthenticationPrincipal OidcUser principal,
@PathVariable String leaseId,
@Valid @RequestBody LeaseApi.AmendmentRequest request) {
ZoneId timeZone = zone(request.timeZone());
if (request.effectiveDate().isBefore(LocalDate.now(clock.withZone(timeZone)))) {
throw new LeaseRequestValidationFailure("effectiveDate", "not_past");
}
var change =
new AmendLeaseService.Change(
tenantIds(request.tenantIds()),
@@ -112,11 +117,21 @@ ResponseEntity<LeaseApi.Response> amend(
authenticatedLandlord.id(principal),
new LeaseId(leaseId),
request.effectiveDate(),
zone(request.timeZone()),
timeZone,
change);
return ResponseEntity.status(HttpStatus.CREATED).body(response(amended));
}
@ExceptionHandler(LeaseRequestValidationFailure.class)
ResponseEntity<LeaseRequestValidationError> invalidAmendment(
LeaseRequestValidationFailure failure) {
return ResponseEntity.badRequest()
.body(
new LeaseRequestValidationError(
"validation_failed",
List.of(new LeaseRequestFieldError(failure.field(), failure.code()))));
}
@PostMapping("/{leaseId}/revisions")
ResponseEntity<LeaseApi.RevisionResponse> generateRevision(
@AuthenticationPrincipal OidcUser principal, @PathVariable String leaseId) {
@@ -233,4 +248,26 @@ private static ResponseEntity<byte[]> document(GeneratedDocument document) {
.header(HttpHeaders.CONTENT_TYPE, document.contentType())
.body(document.content());
}
private record LeaseRequestValidationError(String code, List<LeaseRequestFieldError> errors) {}
private record LeaseRequestFieldError(String field, String code) {}
private static final class LeaseRequestValidationFailure extends RuntimeException {
private final String field;
private final String code;
LeaseRequestValidationFailure(String field, String code) {
this.field = field;
this.code = code;
}
String field() {
return field;
}
String code() {
return code;
}
}
}
@@ -0,0 +1,83 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
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.AmendLeaseService;
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(controllers = LeaseController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class LeaseAmendmentWebTest {
@Autowired MockMvc mockMvc;
@MockitoBean LandlordAccountService accounts;
@MockitoBean LeaseDraftService drafts;
@MockitoBean AmendLeaseService amendments;
@MockitoBean GenerateLeaseDocumentService documents;
@MockitoBean Clock clock;
@BeforeEach
void authenticate() {
when(accounts.identify(any()))
.thenReturn(
Landlord.register(
new LandlordId("landlord-1"),
new ExternalIdentity("subject-1"),
"Camille",
"Martin",
"camille@example.com"));
when(clock.withZone(ZoneId.of("Europe/Paris")))
.thenReturn(Clock.fixed(Instant.parse("2026-07-24T10:00:00Z"), ZoneId.of("Europe/Paris")));
}
@Test
void rejectsAPastEffectiveDateAsAStableFieldErrorBeforeAmending() throws Exception {
mockMvc
.perform(
post("/api/leases/lease-1/amendments")
.with(oidcLogin())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"effectiveDate":"2026-07-23","timeZone":"Europe/Paris",
"tenantIds":["tenant-1"],"guarantors":[],"endDate":"2027-06-30",
"monthlyRent":1000,"monthlyCharges":75,"dueDay":5,"chargeMethod":"FLAT_RATE"}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(jsonPath("$.errors[0].field").value("effectiveDate"))
.andExpect(jsonPath("$.errors[0].code").value("not_past"));
verifyNoInteractions(amendments);
}
}
@@ -0,0 +1,80 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
import static org.junit.jupiter.api.Assertions.assertTrue;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.Test;
class LeaseApiValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void draftCoreFailuresHaveStableFieldPathsBeforeTheUseCase() {
Set<String> failures =
validator
.validate(
new LeaseApi.DraftRequest(
"",
List.of("tenant-1", "tenant-1"),
List.of(),
null,
null,
null,
BigDecimal.ZERO,
new BigDecimal("-0.01"),
new BigDecimal("-0.01"),
29,
null,
false,
List.of(),
null))
.stream()
.map(
failure ->
failure.getPropertyPath()
+ ":"
+ failure
.getConstraintDescriptor()
.getAnnotation()
.annotationType()
.getSimpleName())
.collect(java.util.stream.Collectors.toSet());
assertTrue(failures.contains("apartmentId:NotBlank"));
assertTrue(failures.contains("tenantIdsDistinct:AssertTrue"), failures::toString);
assertTrue(failures.contains("startDate:NotNull"));
assertTrue(failures.contains("monthlyRent:Positive"));
assertTrue(failures.contains("monthlyCharges:PositiveOrZero"));
assertTrue(failures.contains("securityDeposit:PositiveOrZero"));
assertTrue(failures.contains("dueDay:Max"));
}
@Test
void amendmentReportsTheEndDateRelationBeforeTheUseCase() {
Set<String> paths =
validator
.validate(
new LeaseApi.AmendmentRequest(
LocalDate.of(2026, 10, 2),
"Europe/Paris",
List.of("tenant-1", "tenant-1"),
List.of(),
LocalDate.of(2026, 10, 1),
BigDecimal.ONE,
BigDecimal.ZERO,
5,
null,
false))
.stream()
.map(failure -> failure.getPropertyPath().toString())
.collect(java.util.stream.Collectors.toSet());
assertTrue(paths.contains("tenantIdsDistinct"), paths::toString);
assertTrue(paths.contains("endDateOnOrAfterEffectiveDate"));
}
}
@@ -0,0 +1,53 @@
package fr.nessar.lmnporganizer.rental.domain.lease;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class LeaseInputRulesTest {
@Test
void acceptsCentimeRentAndZeroChargesOrDeposit() {
assertDoesNotThrow(
() ->
new RentTerms(
new BigDecimal("0.01"),
BigDecimal.ZERO,
BigDecimal.ZERO,
28,
ChargeMethod.FLAT_RATE,
false));
}
@Test
void rejectsNegativeAmountsAndNonCivilDueDays() {
assertThrows(
LeaseRuleViolation.class,
() -> new RentTerms(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO));
assertThrows(
LeaseRuleViolation.class,
() -> new RentTerms(BigDecimal.ONE, new BigDecimal("-0.01"), BigDecimal.ZERO));
assertThrows(
LeaseRuleViolation.class,
() -> new RentTerms(BigDecimal.ONE, BigDecimal.ZERO, new BigDecimal("-0.01")));
assertThrows(
LeaseRuleViolation.class,
() ->
new RentTerms(
BigDecimal.ONE,
BigDecimal.ZERO,
BigDecimal.ZERO,
29,
ChargeMethod.FLAT_RATE,
false));
}
@Test
void rejectsAnEndDateBeforeTheLeaseStart() {
assertThrows(
LeaseRuleViolation.class,
() -> new RentalPeriod(LocalDate.of(2026, 10, 2), LocalDate.of(2026, 10, 1)));
}
}