From c1e7964f2e30f3334fbbb780a20c3e527f86d11d Mon Sep 17 00:00:00 2001 From: Nessar Date: Fri, 24 Jul 2026 18:34:38 +0200 Subject: [PATCH] Align optional apartment legal profile validation (#45) --- .../in/web/apartment/ApartmentApi.java | 40 +++++- .../apartment/ApartmentLegalProfile.java | 24 ++-- .../adapter/in/web/RentalWebAdaptersTest.java | 87 +++++++++++++ .../domain/apartment/ApartmentTest.java | 41 ++++++ .../apartment-form/apartment-form.html | 6 +- .../apartment-form/apartment-form.spec.ts | 101 ++++++++++++++- .../apartment-form/apartment-form.ts | 33 ++++- .../apartment-legal-profile-form.css | 11 ++ .../apartment-legal-profile-form.html | 119 ++++++++++++------ .../apartment-legal-profile-form.ts | 71 ++++++++++- 10 files changed, 477 insertions(+), 56 deletions(-) diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/apartment/ApartmentApi.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/apartment/ApartmentApi.java index 1c30b54..00b8f41 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/apartment/ApartmentApi.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/apartment/ApartmentApi.java @@ -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, diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentLegalProfile.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentLegalProfile.java index b86aecf..0e7dc63 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentLegalProfile.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentLegalProfile.java @@ -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, diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java index e649269..03ce96b 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/adapter/in/web/RentalWebAdaptersTest.java @@ -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 { diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentTest.java index 5a69c54..d083638 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/rental/domain/apartment/ApartmentTest.java @@ -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(); diff --git a/frontend/src/app/components/apartment-form/apartment-form.html b/frontend/src/app/components/apartment-form/apartment-form.html index 07945e7..691b96b 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.html +++ b/frontend/src/app/components/apartment-form/apartment-form.html @@ -99,7 +99,11 @@ - + { expect(saved).not.toHaveBeenCalled(); }); + it('accepts zero legal-profile numbers with the first valid reference year', () => { + const fixture = TestBed.createComponent(ApartmentForm); + const form = fixture.componentInstance; + const saved = vi.fn(); + form.saved.subscribe(saved); + form.apartmentModel.set({ + name: 'T2', + address: '1 rue de Paris', + purchasePrice: 250000, + description: '', + privateAreas: [], + legalProfile: { + ...emptyApartmentLegalProfile(), + habitableSurface: 0, + estimatedAnnualEnergyCost: 0, + estimatedAnnualEnergyCostReferenceYear: 1900, + }, + }); + + form.save(); + + expect(saved).toHaveBeenCalledWith( + expect.objectContaining({ + legalProfile: expect.objectContaining({ + habitableSurface: 0, + estimatedAnnualEnergyCost: 0, + estimatedAnnualEnergyCostReferenceYear: 1900, + }), + }), + ); + }); + + it('rejects an out-of-range energy reference year when an estimated cost is provided', () => { + const fixture = TestBed.createComponent(ApartmentForm); + const form = fixture.componentInstance; + const saved = vi.fn(); + form.saved.subscribe(saved); + form.apartmentModel.set({ + name: 'T2', + address: '1 rue de Paris', + purchasePrice: 250000, + description: '', + privateAreas: [], + legalProfile: { + ...emptyApartmentLegalProfile(), + estimatedAnnualEnergyCost: 850, + estimatedAnnualEnergyCostReferenceYear: 1800, + }, + }); + + form.save(); + + expect(form.error()).toContain('1900 et 2100'); + expect(saved).not.toHaveBeenCalled(); + }); + + it('rejects an invalid department code inline', () => { + const fixture = TestBed.createComponent(ApartmentForm); + const form = fixture.componentInstance; + const saved = vi.fn(); + form.saved.subscribe(saved); + form.apartmentModel.set({ + name: 'T2', + address: '1 rue de Paris', + purchasePrice: 250000, + description: '', + privateAreas: [], + legalProfile: { ...emptyApartmentLegalProfile(), departmentCode: '20' }, + }); + + form.save(); + + expect(form.error()).toContain('département français'); + expect(saved).not.toHaveBeenCalled(); + }); + + it('shows known legal-profile server errors next to their controls', async () => { + const fixture = TestBed.createComponent(ApartmentForm); + fixture.componentRef.setInput('saveFieldErrors', [ + { field: 'legalProfile.departmentCodeValid', code: 'assert_true' }, + ]); + const changed = vi.fn(); + fixture.componentInstance.fieldChanged.subscribe(changed); + fixture.detectChanges(); + await fixture.whenStable(); + + expect( + fixture.debugElement + .query(By.directive(ApartmentLegalProfileForm)) + .componentInstance.departmentCodeError(), + ).toContain('Saisissez un département français'); + fixture.nativeElement.querySelector('#departmentCode').dispatchEvent(new Event('input')); + expect(changed).toHaveBeenCalledWith('legalProfile.departmentCodeValid'); + }); + it('turns an emptied nullable number field back into null', async () => { const fixture = TestBed.createComponent(ApartmentForm); fixture.detectChanges(); @@ -337,7 +434,7 @@ describe('ApartmentForm', () => { ); }); - it('clears an energy reference year that has no estimated cost', async () => { + it('clears a stale energy reference year that has no estimated cost', async () => { const fixture = TestBed.createComponent(ApartmentForm); fixture.componentRef.setInput('apartment', { id: 'apartment-1', @@ -348,7 +445,7 @@ describe('ApartmentForm', () => { privateAreas: [], legalProfile: { ...emptyApartmentLegalProfile(), - estimatedAnnualEnergyCostReferenceYear: 2021, + estimatedAnnualEnergyCostReferenceYear: 1800, }, }); fixture.detectChanges(); diff --git a/frontend/src/app/components/apartment-form/apartment-form.ts b/frontend/src/app/components/apartment-form/apartment-form.ts index 6df7ff1..ab919a0 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.ts +++ b/frontend/src/app/components/apartment-form/apartment-form.ts @@ -1,6 +1,6 @@ import { Component, effect, input, output, signal, viewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { FormRoot, FormField, form, max, min, required } from '@angular/forms/signals'; +import { FormRoot, FormField, form, max, min, required, validate } from '@angular/forms/signals'; import { MatButtonModule } from '@angular/material/button'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -76,11 +76,22 @@ export class ApartmentForm { message: "Renseignez l'année de référence du coût énergétique.", }); min(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 1900, { + when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null, message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.", }); max(f.legalProfile.estimatedAnnualEnergyCostReferenceYear, 2100, { + when: ({ valueOf }) => valueOf(f.legalProfile.estimatedAnnualEnergyCost) !== null, message: "L'année de référence énergétique doit être comprise entre 1900 et 2100.", }); + validate(f.legalProfile.departmentCode, ({ value }) => + isFrenchDepartmentCode(value()) + ? null + : { + kind: 'department-code', + message: + 'Saisissez un département français : 01 à 95 (sauf 20), 2A, 2B ou 971 à 976.', + }, + ); }); readonly error = signal(''); @@ -201,3 +212,23 @@ export class ApartmentForm { }; } } + +function isFrenchDepartmentCode(value: string): boolean { + const code = value.trim().toUpperCase(); + if (!code) return true; + if (code === '2A' || code === '2B') return true; + if (!digits(code)) return false; + const number = Number(code); + return ( + (code.length === 2 && number >= 1 && number <= 95 && number !== 20) || + (code.length === 3 && number >= 971 && number <= 976) + ); +} + +function digits(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const character = value.charCodeAt(index); + if (character < 48 || character > 57) return false; + } + return true; +} diff --git a/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.css b/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.css index 0930276..7d14f3f 100644 --- a/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.css +++ b/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.css @@ -22,3 +22,14 @@ font-size: 0.8rem; line-height: 1.6; } +.field-error { + margin: -14px 0 0; + color: var(--danger); + font-size: 0.75rem; +} +.legal-profile-field { + min-width: 0; +} +.legal-profile-field mat-form-field { + width: 100%; +} diff --git a/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.html b/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.html index 9df23a1..0bf853b 100644 --- a/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.html +++ b/frontend/src/app/components/apartment-legal-profile-form/apartment-legal-profile-form.html @@ -12,15 +12,23 @@ id="taxIdentifier" [formField]="legalProfileForm().taxIdentifier" /> - Département + Type de bâtiment @@ -34,6 +42,7 @@ > Non renseignée Avant 1949 @@ -62,42 +71,78 @@

Surface, usage et performance énergétique.

- Surface habitablem² + Classe énergétique + > Non renseignée @for (rating of energyRatings; track rating) { {{ rating }} } - Coût annuel énergétique estimé€ - Année de référence du coût énergétique + + Destination des locaux>(); + readonly serverErrors = input([]); + readonly fieldChanged = output(); readonly energyRatings = ['A', 'B', 'C', 'D', 'E', 'F', 'G'] as const; + + clearServerErrors(...fields: string[]): void { + for (const field of fields) this.fieldChanged.emit(field); + } + + habitableSurfaceError(): string { + return this.legalProfileForm().habitableSurface().touched() && + this.legalProfileForm().habitableSurface().invalid() + ? 'La surface habitable doit être positive ou nulle.' + : this.serverError( + 'legalProfile.habitableSurface', + ['positive_or_zero'], + 'La surface habitable doit être positive ou nulle.', + ); + } + + estimatedAnnualEnergyCostError(): string { + return this.legalProfileForm().estimatedAnnualEnergyCost().touched() && + this.legalProfileForm().estimatedAnnualEnergyCost().invalid() + ? 'Le coût annuel énergétique doit être positif ou nul.' + : this.serverError( + 'legalProfile.estimatedAnnualEnergyCost', + ['positive_or_zero'], + 'Le coût annuel énergétique doit être positif ou nul.', + ); + } + + referenceYearError(): string { + return this.legalProfileForm().estimatedAnnualEnergyCostReferenceYear().touched() && + this.legalProfileForm().estimatedAnnualEnergyCostReferenceYear().invalid() + ? this.legalProfileForm().estimatedAnnualEnergyCostReferenceYear().getError('required') + ? "Renseignez l'année de référence du coût énergétique." + : "L'année de référence énergétique doit être comprise entre 1900 et 2100." + : this.serverError( + 'legalProfile.referenceYearPresentWhenEnergyCostIsProvided', + ['assert_true'], + "Renseignez l'année de référence du coût énergétique.", + ) || + this.serverError( + 'legalProfile.referenceYearWithinBoundsWhenEnergyCostIsProvided', + ['assert_true'], + "L'année de référence énergétique doit être comprise entre 1900 et 2100.", + ); + } + + departmentCodeError(): string { + return this.legalProfileForm().departmentCode().touched() && + this.legalProfileForm().departmentCode().invalid() + ? departmentCodeError + : this.serverError( + 'legalProfile.departmentCodeValid', + ['assert_true'], + departmentCodeError, + ); + } + + private serverError(field: string, codes: readonly string[], message: string): string { + return this.serverErrors().some( + (error) => error.field === field && codes.includes(error.code), + ) + ? message + : ''; + } }