diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java index aef6e1a..af58262 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/rental/adapter/in/web/common/RentalApiExceptionHandler.java @@ -13,6 +13,9 @@ import fr.nessar.lmnporganizer.rental.domain.lease.LeaseFinalizationConflict; import fr.nessar.lmnporganizer.rental.domain.lease.LeaseRuleViolation; import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionConflict; import fr.nessar.lmnporganizer.rental.domain.rent.RentRuleViolation; +import java.util.Locale; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -23,20 +26,23 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException; @RestControllerAdvice(basePackages = "fr.nessar.lmnporganizer.rental.adapter.in.web") public class RentalApiExceptionHandler { +private static final Logger LOGGER = LoggerFactory.getLogger(RentalApiExceptionHandler.class); + @ExceptionHandler(ResourceNotFound.class) ResponseEntity notFound(ResourceNotFound exception) { return error(HttpStatus.NOT_FOUND, exception.getMessage()); } -@ExceptionHandler({ - LeaseRuleViolation.class, - RentRuleViolation.class, - IllegalArgumentException.class -}) +@ExceptionHandler({LeaseRuleViolation.class, RentRuleViolation.class}) ResponseEntity invalid(RuntimeException exception) { return error(HttpStatus.BAD_REQUEST, exception.getMessage()); } +@ExceptionHandler(IllegalArgumentException.class) +ResponseEntity invalidArgument() { + return error(HttpStatus.BAD_REQUEST, "La requête est invalide."); +} + @ExceptionHandler({ ReferencedResource.class, EmailAlreadyUsed.class, @@ -77,13 +83,24 @@ ResponseEntity irlUnavailable(IrlIndexUnavailable exception) { } @ExceptionHandler(MethodArgumentNotValidException.class) -ResponseEntity invalidRequest(MethodArgumentNotValidException exception) { - String message = - exception.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(error -> error.getDefaultMessage()) - .orElse("La requête est invalide."); - return error(HttpStatus.BAD_REQUEST, message); +ResponseEntity invalidRequest(MethodArgumentNotValidException exception) { + return ResponseEntity.badRequest() + .body( + new ValidationApiError( + "validation_failed", + exception.getBindingResult().getFieldErrors().stream() + .map(error -> new FieldValidationError(error.getField(), code(error.getCode()))) + .toList())); +} + +@ExceptionHandler(Exception.class) +ResponseEntity unexpected(Exception exception) { + LOGGER.error("Unhandled rental API exception", exception); + return error(HttpStatus.INTERNAL_SERVER_ERROR, "Une erreur inattendue est survenue."); +} + +private static String code(String constraint) { + return constraint.replaceAll("(?<=[a-z])(?=[A-Z])", "_").toLowerCase(Locale.ROOT); } private static ResponseEntity error(HttpStatus status, String message) { @@ -93,4 +110,8 @@ private static ResponseEntity error(HttpStatus status, String message) record ApiError(String message) {} record CodedApiError(String code, String message) {} + +record ValidationApiError(String code, java.util.List errors) {} + +record FieldValidationError(String field, String code) {} } 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 c550e6d..f1f6dde 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 @@ -1,9 +1,11 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web; +import static org.hamcrest.Matchers.containsInAnyOrder; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -792,6 +794,75 @@ void rentPaymentCommandsDelegateScopedRecordingAllocationRefundAndReversal() thr .reverse(LANDLORD_ID, PAYMENT_ID, 7, "Erreur bancaire", ZoneId.of("Europe/Paris")); } +@Test +void apartmentRequestsExposeEveryCoreValidationFailureWithStableFieldsAndCodes() + throws Exception { + mockMvc + .perform( + post("/api/apartments") + .with(asLandlord()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content( + "{\"name\":\" \",\"address\":\" \",\"purchasePrice\":0,\"privateAreas\":[]}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("validation_failed")) + .andExpect( + jsonPath("$.errors[*].field", containsInAnyOrder("name", "address", "purchasePrice"))) + .andExpect( + jsonPath("$.errors[*].code", containsInAnyOrder("not_blank", "not_blank", "positive"))); +} + +@Test +void apartmentRequestsAcceptSubEuroPricesAndReturnTrimmedCoreFields() 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 centre \",\"address\":\" 1 rue du Test \",\"purchasePrice\":0.50,\"privateAreas\":[]}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Studio centre")) + .andExpect(jsonPath("$.address").value("1 rue du Test")) + .andExpect(jsonPath("$.purchasePrice").value(0.50)); +} + +@Test +void internalExceptionMessagesAreNeverReturned() throws Exception { + when(apartments.get(LANDLORD_ID, APARTMENT_ID)) + .thenThrow(new IllegalArgumentException("internal")); + + mockMvc + .perform(get("/api/apartments/apartment-1").with(asLandlord())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("La requête est invalide.")); + + reset(apartments); + when(apartments.get(LANDLORD_ID, APARTMENT_ID)).thenThrow(new RuntimeException("internal")); + + mockMvc + .perform(get("/api/apartments/apartment-1").with(asLandlord())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Une erreur inattendue est survenue.")); +} + @Test void rentalExceptionsMapNotFoundValidationAndConflictStatuses() throws Exception { when(apartments.get(LANDLORD_ID, new ApartmentId("missing"))) @@ -854,9 +925,9 @@ void rentalExceptionsMapNotFoundValidationAndConflictStatuses() throws Exception """ {"operationId":"missing-version","rentDueId":"due-1", "receivedAt":"2026-02-02","timeZone":"Europe/Paris","amount":1000} - """)) + """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").isNotEmpty()); + .andExpect(jsonPath("$.code").value("validation_failed")); mockMvc .perform( post("/api/rent-payments") @@ -867,9 +938,9 @@ void rentalExceptionsMapNotFoundValidationAndConflictStatuses() throws Exception """ {"operationId":"invalid-zone","rentDueId":"due-1","rentDueVersion":2, "receivedAt":"2026-02-02","timeZone":"Not/AZone","amount":1000} - """)) + """)) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Le fuseau horaire est invalide.")); + .andExpect(jsonPath("$.message").value("La requête est invalide.")); mockMvc .perform( post("/api/rent-payments/payment-1/reversal") @@ -881,7 +952,7 @@ void rentalExceptionsMapNotFoundValidationAndConflictStatuses() throws Exception + "a".repeat(501) + "\",\"timeZone\":\"Europe/Paris\"}")) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").isNotEmpty()); + .andExpect(jsonPath("$.code").value("validation_failed")); } private static RequestPostProcessor asLandlord() { diff --git a/frontend/src/app/components/apartment-form/apartment-form.css b/frontend/src/app/components/apartment-form/apartment-form.css index f5fa0b0..da3b2bb 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.css +++ b/frontend/src/app/components/apartment-form/apartment-form.css @@ -22,6 +22,14 @@ font-size: 0.8rem; line-height: 1.6; } +.core-field mat-form-field { + width: 100%; +} +.field-error { + margin: -14px 0 0; + color: var(--danger); + font-size: 0.75rem; +} @media (min-width: 1180px) { .form-page { max-width: 980px; diff --git a/frontend/src/app/components/apartment-form/apartment-form.html b/frontend/src/app/components/apartment-form/apartment-form.html index c3b5372..f7ae95c 100644 --- a/frontend/src/app/components/apartment-form/apartment-form.html +++ b/frontend/src/app/components/apartment-form/apartment-form.html @@ -37,33 +37,55 @@

Les informations utilisées dans votre portefeuille.

- Nom de l'appartement - Prix d'acquisition€ - Adresse complète +
+ Nom de l'appartement + + @if (nameError()) { + + } +
+
+ Prix d'acquisition + € + @if (purchasePriceError()) { + + } +
+
+ Adresse complète + + @if (addressError()) { + + } +
Description ou notes