Align rent revision validation flows (#56)
This commit is contained in:
1 parent
41590a6a78
commit
8b37c96671
17 files changed
+519
-59
No files matched your search
+10
-3
@@ -5,6 +5,7 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
@@ -15,6 +16,12 @@ import java.util.Set;
|
||||
final class RentRevisionApi {
|
||||
private RentRevisionApi() {}
|
||||
|
||||
record OperationError(String message) {}
|
||||
|
||||
record FieldError(String field, String code) {}
|
||||
|
||||
record ValidationError(String code, List<FieldError> errors) {}
|
||||
|
||||
record PreviewRequest(
|
||||
@NotBlank String timeZone,
|
||||
@Size(max = 9) String seriesIdOverride,
|
||||
@@ -24,7 +31,7 @@ record PreviewRequest(
|
||||
Boolean manualIndicesConfirmed) {
|
||||
RentRevisionService.PreviewRequest toApplication() {
|
||||
return new RentRevisionService.PreviewRequest(
|
||||
RentPaymentController.timeZone(timeZone),
|
||||
RentRevisionController.timeZone(timeZone),
|
||||
seriesIdOverride,
|
||||
Boolean.TRUE.equals(seriesOverrideConfirmed),
|
||||
previousIndex,
|
||||
@@ -37,7 +44,7 @@ record ProposalRequest(
|
||||
@NotBlank String timeZone,
|
||||
@NotNull LocalDate requestedOn,
|
||||
@NotNull @Positive BigDecimal chosenRent,
|
||||
@Positive BigDecimal chosenCharges,
|
||||
@PositiveOrZero BigDecimal chosenCharges,
|
||||
List<@NotBlank String> confirmedWarningCodes,
|
||||
@Size(max = 9) String seriesIdOverride,
|
||||
Boolean seriesOverrideConfirmed,
|
||||
@@ -47,7 +54,7 @@ record ProposalRequest(
|
||||
RentRevisionService.ProposalRequest toApplication() {
|
||||
var preview =
|
||||
new RentRevisionService.PreviewRequest(
|
||||
RentPaymentController.timeZone(timeZone),
|
||||
RentRevisionController.timeZone(timeZone),
|
||||
seriesIdOverride,
|
||||
Boolean.TRUE.equals(seriesOverrideConfirmed),
|
||||
previousIndex,
|
||||
|
||||
+67
-9
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import fr.nessar.lmnporganizer.rental.domain.rent.RentRevision;
|
||||
@@ -9,16 +10,21 @@ import fr.nessar.lmnporganizer.rental.domain.rent.RentRevisionId;
|
||||
import jakarta.validation.Valid;
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -27,7 +33,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/leases/{leaseId}/rent-revisions")
|
||||
@@ -43,6 +51,28 @@ public RentRevisionController(
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@ExceptionHandler(RentRevisionValidationFailure.class)
|
||||
ResponseEntity<RentRevisionApi.ValidationError> invalid(RentRevisionValidationFailure failure) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
new RentRevisionApi.ValidationError(
|
||||
"validation_failed",
|
||||
failure.errors().stream()
|
||||
.map(error -> new RentRevisionApi.FieldError(error.field(), error.code()))
|
||||
.toList()));
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MissingServletRequestParameterException.class,
|
||||
MissingServletRequestPartException.class,
|
||||
MethodArgumentTypeMismatchException.class,
|
||||
HttpMessageNotReadableException.class
|
||||
})
|
||||
ResponseEntity<RentRevisionApi.OperationError> invalidRequest() {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new RentRevisionApi.OperationError("La requête de révision IRL est invalide."));
|
||||
}
|
||||
|
||||
@PostMapping("/preview")
|
||||
RentRevisionApi.PreviewResponse preview(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@@ -118,18 +148,22 @@ RentRevisionApi.ApplyResponse apply(
|
||||
@PathVariable String leaseId,
|
||||
@PathVariable String revisionId,
|
||||
@RequestParam String timeZone,
|
||||
@RequestParam LocalDate sentOn,
|
||||
@RequestParam String sentOn,
|
||||
@RequestParam String deliveryMethod,
|
||||
@RequestParam boolean deliveryConfirmed,
|
||||
@RequestParam String deliveryConfirmed,
|
||||
@RequestPart(value = "proof", required = false) MultipartFile proof) {
|
||||
ZoneId zone = RentPaymentController.timeZone(timeZone);
|
||||
ZoneId zone = timeZone(timeZone);
|
||||
RentRevisionService.ApplyResult result =
|
||||
revisions.apply(
|
||||
authenticatedLandlord.id(principal),
|
||||
new LeaseId(leaseId),
|
||||
new RentRevisionId(revisionId),
|
||||
new RentRevisionService.ApplyRequest(
|
||||
zone, sentOn, deliveryMethod(deliveryMethod), deliveryConfirmed, upload(proof)),
|
||||
zone,
|
||||
date(sentOn),
|
||||
deliveryMethod(deliveryMethod),
|
||||
deliveryConfirmed(deliveryConfirmed),
|
||||
upload(proof)),
|
||||
principal.getSubject());
|
||||
LocalDate today = LocalDate.now(clock.withZone(zone));
|
||||
return new RentRevisionApi.ApplyResponse(
|
||||
@@ -150,16 +184,40 @@ private static RentRevisionService.PdfUpload upload(MultipartFile file) {
|
||||
}
|
||||
|
||||
private static RentRevision.DeliveryMethod deliveryMethod(String value) {
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("Le mode d'envoi est invalide.");
|
||||
}
|
||||
return switch (value) {
|
||||
if (value == null) throw new RentRevisionValidationFailure("deliveryMethod", "invalid");
|
||||
return switch (value.trim()) {
|
||||
case "REGISTERED_LETTER" -> RentRevision.DeliveryMethod.REGISTERED_LETTER;
|
||||
case "POSTAL_LETTER" -> RentRevision.DeliveryMethod.POSTAL_LETTER;
|
||||
case "HAND_DELIVERY" -> RentRevision.DeliveryMethod.HAND_DELIVERY;
|
||||
case "EMAIL" -> RentRevision.DeliveryMethod.EMAIL;
|
||||
case "OTHER" -> RentRevision.DeliveryMethod.OTHER;
|
||||
default -> throw new IllegalArgumentException("Le mode d'envoi est invalide.");
|
||||
default -> throw new RentRevisionValidationFailure("deliveryMethod", "invalid");
|
||||
};
|
||||
}
|
||||
|
||||
static ZoneId timeZone(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new RentRevisionValidationFailure("timeZone", "iana_time_zone");
|
||||
}
|
||||
try {
|
||||
return ZoneId.of(value);
|
||||
} catch (DateTimeException failure) {
|
||||
throw new RentRevisionValidationFailure("timeZone", "iana_time_zone");
|
||||
}
|
||||
}
|
||||
|
||||
private static LocalDate date(String value) {
|
||||
try {
|
||||
return LocalDate.parse(value);
|
||||
} catch (DateTimeParseException failure) {
|
||||
throw new RentRevisionValidationFailure("sentOn", "invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean deliveryConfirmed(String value) {
|
||||
if ("true".equals(value)) return true;
|
||||
if ("false".equals(value)) return false;
|
||||
throw new RentRevisionValidationFailure("deliveryConfirmed", "invalid");
|
||||
}
|
||||
|
||||
}
|
||||
+4
-5
@@ -213,12 +213,11 @@ public ApplyResult apply(
|
||||
Objects.requireNonNull(request, "Rent revision application request is required.");
|
||||
String author = required(authorId, "Application author");
|
||||
if (!request.deliveryConfirmed()) {
|
||||
throw new RentRuleViolation("L'envoi de la demande doit être confirmé.");
|
||||
throw new RentRevisionValidationFailure("deliveryConfirmed", "required");
|
||||
}
|
||||
if (request.sentOn() == null
|
||||
|| request.sentOn().isAfter(LocalDate.now(clock.withZone(request.timeZone())))) {
|
||||
throw new RentRuleViolation(
|
||||
"La date d'envoi ne peut pas être future dans le fuseau du bailleur.");
|
||||
throw new RentRevisionValidationFailure("sentOn", "future");
|
||||
}
|
||||
RentRevision proposed =
|
||||
revisions
|
||||
@@ -639,7 +638,7 @@ private static String sha256(byte[] content) {
|
||||
private byte[] pdf(PdfUpload upload) {
|
||||
byte[] content = upload.content();
|
||||
if (content == null || content.length == 0) {
|
||||
throw new RentRuleViolation("Le justificatif PDF est vide.");
|
||||
throw new RentRevisionValidationFailure("proof", "invalid");
|
||||
}
|
||||
if (content.length > maximumPdfSize) {
|
||||
throw new AttachmentTooLarge(maximumPdfSize);
|
||||
@@ -647,7 +646,7 @@ private byte[] pdf(PdfUpload upload) {
|
||||
if (!"application/pdf".equalsIgnoreCase(upload.contentType())
|
||||
|| content.length < 5
|
||||
|| !Arrays.equals(Arrays.copyOf(content, 5), "%PDF-".getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw new RentRuleViolation("Le justificatif doit être un PDF lisible.");
|
||||
throw new RentRevisionValidationFailure("proof", "invalid");
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package fr.nessar.lmnporganizer.rental.application.rent;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class RentRevisionValidationFailure extends RuntimeException {
|
||||
private final List<FieldError> errors;
|
||||
|
||||
public RentRevisionValidationFailure(String field, String code) {
|
||||
super("La requête de révision IRL est invalide.");
|
||||
this.errors = List.of(new FieldError(field, code));
|
||||
}
|
||||
|
||||
public List<FieldError> errors() {
|
||||
return errors;
|
||||
}
|
||||
|
||||
public record FieldError(String field, String code) {
|
||||
public FieldError {
|
||||
Objects.requireNonNull(field);
|
||||
Objects.requireNonNull(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
-2
@@ -1,6 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.rent;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
@@ -19,6 +20,7 @@ import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlor
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
|
||||
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionService;
|
||||
import fr.nessar.lmnporganizer.rental.application.rent.RentRevisionValidationFailure;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.IrlIndexUnavailable;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.ExternalIdentity;
|
||||
import fr.nessar.lmnporganizer.rental.domain.landlord.Landlord;
|
||||
@@ -147,7 +149,7 @@ void unavailableOfficialAndCachedIndicesExposeAStableManualFallbackCode() throws
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnUnknownDeliveryMethodWithoutExposingInternalClassNames() throws Exception {
|
||||
void rejectsAnUnknownDeliveryMethodWithAStableFieldCode() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/rent-revisions/revision-1/apply")
|
||||
@@ -158,7 +160,55 @@ void rejectsAnUnknownDeliveryMethodWithoutExposingInternalClassNames() throws Ex
|
||||
.with(owner())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("Le mode d'envoi est invalide."));
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("deliveryMethod"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("invalid"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void translatesInvalidDeliveryFactsAndProofMetadataToFieldErrors() throws Exception {
|
||||
assertApplyViolation(
|
||||
"false",
|
||||
null,
|
||||
"deliveryConfirmed",
|
||||
"required");
|
||||
assertApplyViolation(
|
||||
"true",
|
||||
null,
|
||||
"sentOn",
|
||||
"future");
|
||||
assertApplyViolation(
|
||||
"true",
|
||||
new MockMultipartFile("proof", "preuve.pdf", "application/pdf", "not a PDF".getBytes()),
|
||||
"proof",
|
||||
"invalid");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAnInvalidPreviewTimeZoneWithAStableFieldCode() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
post("/api/leases/lease-1/rent-revisions/preview")
|
||||
.with(owner())
|
||||
.with(csrf())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"timeZone\":\"invalid-zone\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("timeZone"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("iana_time_zone"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingMultipartParametersWithASafeOperationMessage() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/leases/lease-1/rent-revisions/revision-1/apply")
|
||||
.param("timeZone", "Europe/Paris")
|
||||
.with(owner())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.message").value("La requête de révision IRL est invalide."));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -342,4 +392,27 @@ private static RequestPostProcessor owner() {
|
||||
.claim("family_name", "Martin")
|
||||
.claim("email", "camille@example.com"));
|
||||
}
|
||||
|
||||
private void assertApplyViolation(
|
||||
String deliveryConfirmed, MockMultipartFile proof, String field, String code)
|
||||
throws Exception {
|
||||
doThrow(new RentRevisionValidationFailure(field, code))
|
||||
.when(revisions)
|
||||
.apply(any(), any(), any(), any(), any());
|
||||
var request = multipart("/api/leases/lease-1/rent-revisions/revision-1/apply");
|
||||
if (proof != null) request.file(proof);
|
||||
mockMvc
|
||||
.perform(
|
||||
request
|
||||
.param("timeZone", "Europe/Paris")
|
||||
.param("sentOn", "2027-09-02")
|
||||
.param("deliveryMethod", "POSTAL_LETTER")
|
||||
.param("deliveryConfirmed", deliveryConfirmed)
|
||||
.with(owner())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value(field))
|
||||
.andExpect(jsonPath("$.errors[0].code").value(code));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user