Harden validation error recovery
This commit is contained in:
1 parent
d16239881a
commit
ce53bab698
11 files changed
+130
-7
No files matched your search
+32
@@ -19,10 +19,14 @@ import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
|
||||
@RestControllerAdvice(basePackages = "fr.nessar.lmnporganizer.rental.adapter.in.web")
|
||||
public class RentalApiExceptionHandler {
|
||||
@@ -93,6 +97,27 @@ ResponseEntity<ValidationApiError> invalidRequest(MethodArgumentNotValidExceptio
|
||||
.toList()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
ResponseEntity<ValidationApiError> unreadableRequest() {
|
||||
return validation("request", "malformed");
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||
ResponseEntity<ValidationApiError> invalidParameter(MethodArgumentTypeMismatchException exception) {
|
||||
return validation(exception.getName(), "invalid");
|
||||
}
|
||||
|
||||
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||
ResponseEntity<ValidationApiError> missingParameter(
|
||||
MissingServletRequestParameterException exception) {
|
||||
return validation(exception.getParameterName(), "required");
|
||||
}
|
||||
|
||||
@ExceptionHandler(MissingServletRequestPartException.class)
|
||||
ResponseEntity<ValidationApiError> missingPart(MissingServletRequestPartException exception) {
|
||||
return validation(exception.getRequestPartName(), "required");
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
ResponseEntity<ApiError> unexpected(Exception exception) {
|
||||
LOGGER.error("Unhandled rental API exception", exception);
|
||||
@@ -107,6 +132,13 @@ private static ResponseEntity<ApiError> error(HttpStatus status, String message)
|
||||
return ResponseEntity.status(status).body(new ApiError(message));
|
||||
}
|
||||
|
||||
private static ResponseEntity<ValidationApiError> validation(String field, String code) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
new ValidationApiError(
|
||||
"validation_failed", java.util.List.of(new FieldValidationError(field, code))));
|
||||
}
|
||||
|
||||
record ApiError(String message) {}
|
||||
|
||||
record CodedApiError(String code, String message) {}
|
||||
|
||||
+36
-2
@@ -2,6 +2,7 @@ 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.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -31,6 +32,7 @@ import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentCatalogServ
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.AllocateRentPaymentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.GeneratePaymentReceiptService;
|
||||
@@ -126,6 +128,7 @@ private static final RentPaymentRefundId REFUND_ID = new RentPaymentRefundId("re
|
||||
@MockitoBean private LeaseDraftService leaseDrafts;
|
||||
@MockitoBean private AmendLeaseService amendments;
|
||||
@MockitoBean private GenerateLeaseDocumentService leaseDocuments;
|
||||
@MockitoBean private LeaseClauseCatalog leaseClauseCatalog;
|
||||
@MockitoBean private RentPaymentHistoryService paymentHistory;
|
||||
@MockitoBean private RecordRentPaymentService paymentRecording;
|
||||
@MockitoBean private AllocateRentPaymentService paymentAllocation;
|
||||
@@ -136,9 +139,17 @@ private static final RentPaymentRefundId REFUND_ID = new RentPaymentRefundId("re
|
||||
|
||||
@BeforeEach
|
||||
void authenticateLandlordAndFixTime() {
|
||||
Instant now = Instant.parse("2026-03-01T12:00:00Z");
|
||||
when(accounts.identify(any())).thenReturn(landlord());
|
||||
when(clock.instant()).thenReturn(Instant.parse("2026-03-01T12:00:00Z"));
|
||||
when(clock.instant()).thenReturn(now);
|
||||
when(clock.getZone()).thenReturn(ZoneOffset.UTC);
|
||||
when(clock.withZone(any())).thenReturn(Clock.fixed(now, ZoneOffset.UTC));
|
||||
LeaseClauseCatalog clauses =
|
||||
LeaseClauseCatalog.load(getClass().getResourceAsStream("/lease-clauses.yml"));
|
||||
when(leaseClauseCatalog.available(anyString(), any()))
|
||||
.thenAnswer(
|
||||
invocation ->
|
||||
clauses.available(invocation.getArgument(0), invocation.getArgument(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -161,6 +172,25 @@ void protectedRentalMutationsRejectMissingCsrfTokens() throws Exception {
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedJsonIsAStableClientValidationFailure() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/apartments")
|
||||
.with(asLandlord())
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{"name":"Studio","address":"12 rue A","purchasePrice":"not-a-number",
|
||||
"description":"","privateAreas":[]}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("request"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("malformed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oidcClaimsScopeApartmentCreationAndLookupAndResponsesAreMapped() throws Exception {
|
||||
Apartment apartment =
|
||||
@@ -413,7 +443,11 @@ void leaseDraftRequestsRejectAnImpossibleAnnualRevisionDay() throws Exception {
|
||||
"referenceYear":2026,"seriesId":"001515333"}}}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("Le jour annuel de révision IRL est invalide."));
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(
|
||||
jsonPath("$.errors[0].field")
|
||||
.value("legalTerms.rentRevision.annualRevisionDay"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("invalid_date"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHa
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.AmendLeaseService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.GenerateLeaseDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseClauseCatalog;
|
||||
import fr.nessar.lmnporganizer.rental.application.lease.LeaseDraftService;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
@@ -43,6 +44,7 @@ class LeaseAmendmentWebTest {
|
||||
@MockitoBean LeaseDraftService drafts;
|
||||
@MockitoBean AmendLeaseService amendments;
|
||||
@MockitoBean GenerateLeaseDocumentService documents;
|
||||
@MockitoBean LeaseClauseCatalog clauseCatalog;
|
||||
@MockitoBean Clock clock;
|
||||
|
||||
@BeforeEach
|
||||
|
||||
Reference in new issue
Block a user