Align apartment core validation errors (#43)
This commit is contained in:
1 parent
878da155bd
commit
b9f5540394
13 files changed
+454
-56
No files matched your search
+33
-12
@@ -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) {}
|
||||
}
|
||||
+76
-5
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user