Align optional apartment legal profile validation (#45)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 72d3f0ccfa
commit c1e7964f2e
10 files changed
+477 -56

No files matched your search

@@ -7,8 +7,6 @@ import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentLegalProfile.Con
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
@@ -31,16 +29,46 @@ record LegalProfileRequest(
String hotWater,
String energyRating,
@PositiveOrZero BigDecimal estimatedAnnualEnergyCost,
@Min(1900) @Max(2100) Integer estimatedAnnualEnergyCostReferenceYear,
Integer estimatedAnnualEnergyCostReferenceYear,
String destination,
String privateAccessories,
String commonAreas,
String internetAccess,
String departmentCode) {
ApartmentLegalProfile toDomain() {
if (estimatedAnnualEnergyCost != null && estimatedAnnualEnergyCostReferenceYear == null) {
throw new IllegalArgumentException("L'année de référence du coût énergétique est requise.");
@AssertTrue
boolean isReferenceYearPresentWhenEnergyCostIsProvided() {
return estimatedAnnualEnergyCost == null || estimatedAnnualEnergyCostReferenceYear != null;
}
@AssertTrue
boolean isReferenceYearWithinBoundsWhenEnergyCostIsProvided() {
return estimatedAnnualEnergyCost == null
|| estimatedAnnualEnergyCostReferenceYear == null
|| (estimatedAnnualEnergyCostReferenceYear >= 1900
&& estimatedAnnualEnergyCostReferenceYear <= 2100);
}
@AssertTrue
boolean isEnergyRatingValid() {
return ApartmentLegalProfile.isValidEnergyRating(energyRating);
}
@AssertTrue
boolean isDepartmentCodeValid() {
return ApartmentLegalProfile.isValidDepartmentCode(departmentCode);
}
@AssertTrue
boolean isConstructionPeriodValid() {
try {
ConstructionPeriod.fromCode(constructionPeriod);
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
ApartmentLegalProfile toDomain() {
return new ApartmentLegalProfile(
taxIdentifier,
buildingType,
@@ -37,25 +37,33 @@ public ApartmentLegalProfile {
departmentCode = text(departmentCode).toUpperCase(Locale.ROOT);
nonNegative(habitableSurface, "Habitable surface");
nonNegative(estimatedAnnualEnergyCost, "Estimated annual energy cost");
if (estimatedAnnualEnergyCost == null) {
estimatedAnnualEnergyCostReferenceYear = null;
}
if (estimatedAnnualEnergyCostReferenceYear != null
&& (estimatedAnnualEnergyCostReferenceYear < 1900
|| estimatedAnnualEnergyCostReferenceYear > 2100)) {
throw new IllegalArgumentException("Estimated annual energy cost reference year is invalid.");
}
if (estimatedAnnualEnergyCost == null) {
estimatedAnnualEnergyCostReferenceYear = null;
}
if (!energyRating.isEmpty()
&& (energyRating.length() != 1
|| energyRating.charAt(0) < 'A'
|| energyRating.charAt(0) > 'G')) {
if (!isValidEnergyRating(energyRating)) {
throw new IllegalArgumentException("Energy rating must be between A and G.");
}
if (!departmentCode.isEmpty() && !isDepartmentCode(departmentCode)) {
if (!isValidDepartmentCode(departmentCode)) {
throw new IllegalArgumentException("Department code is invalid.");
}
}
public static boolean isValidEnergyRating(String value) {
String rating = text(value).toUpperCase(Locale.ROOT);
return rating.isEmpty()
|| (rating.length() == 1 && rating.charAt(0) >= 'A' && rating.charAt(0) <= 'G');
}
public static boolean isValidDepartmentCode(String value) {
String code = text(value).toUpperCase(Locale.ROOT);
return code.isEmpty() || isDepartmentCode(code);
}
public ApartmentLegalProfile(
String taxIdentifier,
BuildingType buildingType,
@@ -813,6 +813,93 @@ void apartmentRequestsExposeEveryCoreValidationFailureWithStableFieldsAndCodes()
jsonPath("$.errors[*].code", containsInAnyOrder("not_blank", "not_blank", "positive")));
}
@Test
void apartmentRequestsKeepAnEmptyLegalProfileOptional() throws Exception {
when(apartments.create(eq(LANDLORD_ID), any()))
.thenAnswer(
invocation -> {
ApartmentCatalogService.Details details = invocation.getArgument(1);
return new Apartment(
APARTMENT_ID,
LANDLORD_ID,
details.name(),
details.address(),
details.purchasePrice(),
details.description(),
details.legalProfile(),
details.privateAreas());
});
mockMvc
.perform(
post("/api/apartments")
.with(asLandlord())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"name\":\"Studio\",\"address\":\"1 rue du Test\",\"purchasePrice\":1,\"legalProfile\":{},\"privateAreas\":[]}"))
.andExpect(status().isCreated());
}
@Test
void apartmentRequestsExposeEveryInvalidLegalProfileFieldWithStablePathsAndCodes()
throws Exception {
mockMvc
.perform(
post("/api/apartments")
.with(asLandlord())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"name":"Studio","address":"1 rue du Test","purchasePrice":1,"legalProfile":{
"constructionPeriod":"invalid","habitableSurface":-1,"energyRating":"Z",
"estimatedAnnualEnergyCost":-1,"estimatedAnnualEnergyCostReferenceYear":1800,
"departmentCode":"20"},"privateAreas":[]}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(
jsonPath(
"$.errors[*].field",
containsInAnyOrder(
"legalProfile.habitableSurface",
"legalProfile.estimatedAnnualEnergyCost",
"legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided",
"legalProfile.energyRatingValid",
"legalProfile.departmentCodeValid",
"legalProfile.constructionPeriodValid")))
.andExpect(
jsonPath(
"$.errors[*].code",
containsInAnyOrder(
"positive_or_zero",
"positive_or_zero",
"assert_true",
"assert_true",
"assert_true",
"assert_true")));
}
@Test
void apartmentRequestsRequireAnEnergyReferenceYearOnlyWhenACostIsProvided() throws Exception {
mockMvc
.perform(
post("/api/apartments")
.with(asLandlord())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"{\"name\":\"Studio\",\"address\":\"1 rue du Test\",\"purchasePrice\":1,\"legalProfile\":{\"estimatedAnnualEnergyCost\":0},\"privateAreas\":[]}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(
jsonPath(
"$.errors[*].field",
containsInAnyOrder("legalProfile.referenceYearPresentWhenEnergyCostIsProvided")))
.andExpect(jsonPath("$.errors[*].code", containsInAnyOrder("assert_true")));
}
@Test
void apartmentRequestsExposeEveryInvalidPrivateAreaWithIndexedStableFieldsAndCodes()
throws Exception {
@@ -94,6 +94,47 @@ void legalProfileRejectsNegativeAndStructurallyInvalidValues() {
() -> ApartmentLegalProfile.ConstructionPeriod.fromCode("1900_2000"));
}
@Test
void legalProfileAcceptsFrenchDepartmentCodesAndDropsAnOrphanedEnergyReferenceYear() {
assertEquals(true, ApartmentLegalProfile.isValidDepartmentCode("01"));
assertEquals(true, ApartmentLegalProfile.isValidDepartmentCode("2a"));
assertEquals(true, ApartmentLegalProfile.isValidDepartmentCode("95"));
assertEquals(true, ApartmentLegalProfile.isValidDepartmentCode("971"));
assertEquals(true, ApartmentLegalProfile.isValidDepartmentCode("976"));
assertEquals(false, ApartmentLegalProfile.isValidDepartmentCode("20"));
assertEquals(false, ApartmentLegalProfile.isValidDepartmentCode("96"));
assertEquals(false, ApartmentLegalProfile.isValidDepartmentCode("977"));
ApartmentLegalProfile profile =
new ApartmentLegalProfile(
"",
null,
"",
null,
BigDecimal.ZERO,
"",
"",
"",
"",
"",
BigDecimal.ZERO,
1900,
"",
"",
"",
"",
"");
assertEquals(BigDecimal.ZERO, profile.habitableSurface());
assertEquals(BigDecimal.ZERO, profile.estimatedAnnualEnergyCost());
assertEquals(1900, profile.estimatedAnnualEnergyCostReferenceYear());
assertEquals(
null,
new ApartmentLegalProfile(
"", null, "", null, null, "", "", "", "", "", null, 1800, "", "", "", "", "")
.estimatedAnnualEnergyCostReferenceYear());
}
@Test
void keepsOneCurrentDocumentPerType() {
Apartment apartment = apartment();