Align apartment document upload validation (#51)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 82293a322d
commit cd32d05466
7 files changed
+492 -83

No files matched your search

@@ -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,
@@ -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;
}
}
}
@@ -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"))
@@ -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 =