Align apartment core validation errors (#43)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 878da155bd
commit b9f5540394
13 files changed
+454 -56

No files matched your search

@@ -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<ApiError> notFound(ResourceNotFound exception) {
return error(HttpStatus.NOT_FOUND, exception.getMessage());
}
@ExceptionHandler({
LeaseRuleViolation.class,
RentRuleViolation.class,
IllegalArgumentException.class
})
@ExceptionHandler({LeaseRuleViolation.class, RentRuleViolation.class})
ResponseEntity<ApiError> invalid(RuntimeException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<ApiError> invalidArgument() {
return error(HttpStatus.BAD_REQUEST, "La requête est invalide.");
}
@ExceptionHandler({
ReferencedResource.class,
EmailAlreadyUsed.class,
@@ -77,13 +83,24 @@ ResponseEntity<CodedApiError> irlUnavailable(IrlIndexUnavailable exception) {
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> 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<ValidationApiError> 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<ApiError> 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<ApiError> error(HttpStatus status, String message) {
@@ -93,4 +110,8 @@ private static ResponseEntity<ApiError> error(HttpStatus status, String message)
record ApiError(String message) {}
record CodedApiError(String code, String message) {}
record ValidationApiError(String code, java.util.List<FieldValidationError> errors) {}
record FieldValidationError(String field, String code) {}
}
@@ -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() {