Align core lease and amendment validation (#49)
This commit is contained in:
1 parent
96bd02e3a6
commit
ca3e28d3a2
20 files changed
+1361
-56
No files matched your search
+19
-2
@@ -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) {
|
||||
|
||||
+38
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
}
|
||||
+80
@@ -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"));
|
||||
}
|
||||
}
|
||||
+53
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user