Align apartment document upload validation (#51)
This commit is contained in:
1 parent
82293a322d
commit
cd32d05466
7 files changed
+492
-83
No files matched your search
+64
@@ -1,12 +1,76 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class ApartmentDocumentApi {
|
||||
private ApartmentDocumentApi() {}
|
||||
|
||||
record UploadRequest(String type, String establishedOn, String expiresOn) {
|
||||
List<FieldValidationError> validationErrors() {
|
||||
List<FieldValidationError> errors = new ArrayList<>();
|
||||
if (blank(type)) {
|
||||
errors.add(new FieldValidationError("type", "required"));
|
||||
} else if (documentType() == null) {
|
||||
errors.add(new FieldValidationError("type", "invalid"));
|
||||
}
|
||||
if (blank(establishedOn)) {
|
||||
errors.add(new FieldValidationError("establishedOn", "required"));
|
||||
} else if (establishmentDate() == null) {
|
||||
errors.add(new FieldValidationError("establishedOn", "invalid"));
|
||||
}
|
||||
if (expiresOn != null && expiryDate() == null) {
|
||||
errors.add(new FieldValidationError("expiresOn", "invalid"));
|
||||
}
|
||||
if (establishmentDate() != null
|
||||
&& expiryDate() != null
|
||||
&& expiryDate().isBefore(establishmentDate())) {
|
||||
errors.add(new FieldValidationError("expiresOn", "before_establishment"));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
ApartmentDocumentType documentType() {
|
||||
try {
|
||||
return ApartmentDocumentType.fromCode(type);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
LocalDate establishmentDate() {
|
||||
return date(establishedOn);
|
||||
}
|
||||
|
||||
LocalDate expiryDate() {
|
||||
return expiresOn == null || expiresOn.isBlank() ? null : date(expiresOn);
|
||||
}
|
||||
|
||||
private static LocalDate date(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(value);
|
||||
} catch (DateTimeParseException exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record ValidationErrorResponse(String code, List<FieldValidationError> errors) {}
|
||||
|
||||
record FieldValidationError(String field, String code) {}
|
||||
|
||||
record Response(
|
||||
String id,
|
||||
String type,
|
||||
|
||||
+40
-9
@@ -3,10 +3,9 @@ package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
|
||||
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
|
||||
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
|
||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -16,6 +15,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
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;
|
||||
@@ -51,17 +51,27 @@ List<ApartmentDocumentApi.Response> list(
|
||||
ResponseEntity<ApartmentDocumentApi.Response> upload(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String apartmentId,
|
||||
@RequestParam String type,
|
||||
@RequestParam LocalDate establishedOn,
|
||||
@RequestParam(required = false) LocalDate expiresOn,
|
||||
@RequestPart("file") MultipartFile file) {
|
||||
@RequestParam(required = false) String type,
|
||||
@RequestParam(required = false) String establishedOn,
|
||||
@RequestParam(required = false) String expiresOn,
|
||||
@RequestPart(value = "file", required = false) MultipartFile file) {
|
||||
ApartmentDocumentApi.UploadRequest request =
|
||||
new ApartmentDocumentApi.UploadRequest(type, establishedOn, expiresOn);
|
||||
List<ApartmentDocumentApi.FieldValidationError> errors =
|
||||
new ArrayList<>(request.validationErrors());
|
||||
if (file == null || file.isEmpty()) {
|
||||
errors.add(new ApartmentDocumentApi.FieldValidationError("file", "required"));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new UploadValidationFailure(errors);
|
||||
}
|
||||
var stored =
|
||||
documents.upload(
|
||||
authenticatedLandlord.id(principal),
|
||||
new ApartmentId(apartmentId),
|
||||
ApartmentDocumentType.fromCode(type),
|
||||
establishedOn,
|
||||
expiresOn,
|
||||
request.documentType(),
|
||||
request.establishmentDate(),
|
||||
request.expiryDate(),
|
||||
upload(file),
|
||||
principal.getSubject());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
@@ -98,6 +108,15 @@ ResponseEntity<Void> delete(
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@ExceptionHandler(UploadValidationFailure.class)
|
||||
ResponseEntity<ApartmentDocumentApi.ValidationErrorResponse> invalidUpload(
|
||||
UploadValidationFailure failure) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
new ApartmentDocumentApi.ValidationErrorResponse(
|
||||
"validation_failed", failure.errors()));
|
||||
}
|
||||
|
||||
private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) {
|
||||
try {
|
||||
return new ApartmentDocumentService.PdfUpload(
|
||||
@@ -106,4 +125,16 @@ private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) {
|
||||
throw new AttachmentStorageFailure("Apartment document could not be opened.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UploadValidationFailure extends RuntimeException {
|
||||
private final List<ApartmentDocumentApi.FieldValidationError> errors;
|
||||
|
||||
private UploadValidationFailure(List<ApartmentDocumentApi.FieldValidationError> errors) {
|
||||
this.errors = List.copyOf(errors);
|
||||
}
|
||||
|
||||
private List<ApartmentDocumentApi.FieldValidationError> errors() {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -112,6 +112,43 @@ void uploadUsesTheAuthenticatedScopeAndNativeMultipartFields() throws Exception
|
||||
org.mockito.ArgumentMatchers.eq("subject-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadReturnsStableFieldErrorsForInvalidMetadata() throws Exception {
|
||||
MockMultipartFile file =
|
||||
new MockMultipartFile("file", "dpe.pdf", MediaType.APPLICATION_PDF_VALUE, PDF);
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/apartments/apartment-1/documents")
|
||||
.file(file)
|
||||
.param("type", "UNKNOWN")
|
||||
.param("establishedOn", "2026-07-02")
|
||||
.param("expiresOn", "2026-07-01")
|
||||
.with(owner())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("type"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("invalid"))
|
||||
.andExpect(jsonPath("$.errors[1].field").value("expiresOn"))
|
||||
.andExpect(jsonPath("$.errors[1].code").value("before_establishment"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadReportsAMissingMultipartFileAsAFieldError() throws Exception {
|
||||
mockMvc
|
||||
.perform(
|
||||
multipart("/api/apartments/apartment-1/documents")
|
||||
.param("type", "DPE")
|
||||
.param("establishedOn", "2026-07-01")
|
||||
.with(owner())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors[0].field").value("file"))
|
||||
.andExpect(jsonPath("$.errors[0].code").value("required"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception {
|
||||
when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1"))
|
||||
|
||||
+20
@@ -113,6 +113,26 @@ void uploadReplacesTheCurrentDocumentOfTheSameTypeWithAStagedDeletion() {
|
||||
verify(storage, never()).restore(deletion);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAcceptsPdfMimeAndSignatureWithoutAPdfFileExtension() {
|
||||
when(identifiers.next()).thenReturn("document-1");
|
||||
when(storage.store(PDF)).thenReturn("storage-1");
|
||||
when(apartments.save(apartment)).thenReturn(apartment);
|
||||
|
||||
ApartmentDocument document =
|
||||
service(10_000)
|
||||
.upload(
|
||||
LANDLORD_ID,
|
||||
APARTMENT_ID,
|
||||
ApartmentDocumentType.ENERGY_PERFORMANCE_DIAGNOSIS,
|
||||
LocalDate.of(2026, 7, 19),
|
||||
null,
|
||||
upload("dpe", "application/pdf", PDF),
|
||||
"subject-1");
|
||||
|
||||
assertEquals("dpe.pdf", document.fileName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadRestoresTheReplacedFileAndDiscardsTheNewFileWhenSavingMetadataFails() {
|
||||
ApartmentDocument previous =
|
||||
|
||||
Reference in new issue
Block a user