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;
|
package fr.nessar.lmnporganizer.rental.adapter.in.web.apartment;
|
||||||
|
|
||||||
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
|
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocument;
|
||||||
|
import fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentDocumentType;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeParseException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
final class ApartmentDocumentApi {
|
final class ApartmentDocumentApi {
|
||||||
private 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(
|
record Response(
|
||||||
String id,
|
String id,
|
||||||
String type,
|
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.adapter.in.web.common.AuthenticatedLandlord;
|
||||||
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
|
import fr.nessar.lmnporganizer.rental.application.apartment.ApartmentDocumentService;
|
||||||
import fr.nessar.lmnporganizer.rental.application.shared.AttachmentStorageFailure;
|
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 fr.nessar.lmnporganizer.rental.domain.apartment.ApartmentId;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.http.ContentDisposition;
|
import org.springframework.http.ContentDisposition;
|
||||||
import org.springframework.http.HttpHeaders;
|
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.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -51,17 +51,27 @@ List<ApartmentDocumentApi.Response> list(
|
|||||||
ResponseEntity<ApartmentDocumentApi.Response> upload(
|
ResponseEntity<ApartmentDocumentApi.Response> upload(
|
||||||
@AuthenticationPrincipal OidcUser principal,
|
@AuthenticationPrincipal OidcUser principal,
|
||||||
@PathVariable String apartmentId,
|
@PathVariable String apartmentId,
|
||||||
@RequestParam String type,
|
@RequestParam(required = false) String type,
|
||||||
@RequestParam LocalDate establishedOn,
|
@RequestParam(required = false) String establishedOn,
|
||||||
@RequestParam(required = false) LocalDate expiresOn,
|
@RequestParam(required = false) String expiresOn,
|
||||||
@RequestPart("file") MultipartFile file) {
|
@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 =
|
var stored =
|
||||||
documents.upload(
|
documents.upload(
|
||||||
authenticatedLandlord.id(principal),
|
authenticatedLandlord.id(principal),
|
||||||
new ApartmentId(apartmentId),
|
new ApartmentId(apartmentId),
|
||||||
ApartmentDocumentType.fromCode(type),
|
request.documentType(),
|
||||||
establishedOn,
|
request.establishmentDate(),
|
||||||
expiresOn,
|
request.expiryDate(),
|
||||||
upload(file),
|
upload(file),
|
||||||
principal.getSubject());
|
principal.getSubject());
|
||||||
return ResponseEntity.status(HttpStatus.CREATED)
|
return ResponseEntity.status(HttpStatus.CREATED)
|
||||||
@@ -98,6 +108,15 @@ ResponseEntity<Void> delete(
|
|||||||
return ResponseEntity.noContent().build();
|
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) {
|
private static ApartmentDocumentService.PdfUpload upload(MultipartFile file) {
|
||||||
try {
|
try {
|
||||||
return new ApartmentDocumentService.PdfUpload(
|
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);
|
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"));
|
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
|
@Test
|
||||||
void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception {
|
void downloadReturnsPdfBytesWithASafeAttachmentHeader() throws Exception {
|
||||||
when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1"))
|
when(documents.download(LANDLORD_ID, APARTMENT_ID, "document-1"))
|
||||||
|
|||||||
+20
@@ -113,6 +113,26 @@ void uploadReplacesTheCurrentDocumentOfTheSameTypeWithAStagedDeletion() {
|
|||||||
verify(storage, never()).restore(deletion);
|
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
|
@Test
|
||||||
void uploadRestoresTheReplacedFileAndDiscardsTheNewFileWhenSavingMetadataFails() {
|
void uploadRestoresTheReplacedFileAndDiscardsTheNewFileWhenSavingMetadataFails() {
|
||||||
ApartmentDocument previous =
|
ApartmentDocument previous =
|
||||||
|
|||||||
+28
-5
@@ -93,18 +93,28 @@
|
|||||||
<fieldset [disabled]="uploading()">
|
<fieldset [disabled]="uploading()">
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Type de document</mat-label>
|
<mat-label>Type de document</mat-label>
|
||||||
<mat-select [formField]="uploadForm.type">
|
<mat-select
|
||||||
|
[formField]="uploadForm.type"
|
||||||
|
(selectionChange)="clearUploadFieldError('type')"
|
||||||
|
>
|
||||||
<mat-option value="">Choisissez un type</mat-option>
|
<mat-option value="">Choisissez un type</mat-option>
|
||||||
@for (type of documentTypes; track type.value) {
|
@for (type of documentTypes; track type.value) {
|
||||||
<mat-option [value]="type.value">{{ type.label }}</mat-option>
|
<mat-option [value]="type.value">{{ type.label }}</mat-option>
|
||||||
}
|
}
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
@if (fieldError('type'); as error) {
|
||||||
|
<p class="form-error" role="alert">{{ error }}</p>
|
||||||
|
}
|
||||||
<label class="file-picker">
|
<label class="file-picker">
|
||||||
<span>Fichier PDF</span>
|
<span>Fichier PDF</span>
|
||||||
<input type="file" accept=".pdf,application/pdf" (change)="selectFile($event)" />
|
<input type="file" accept="application/pdf" (change)="selectFile($event)" />
|
||||||
<small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
<small>{{ selectedFile()?.name ?? 'Aucun fichier sélectionné' }}</small>
|
||||||
|
<small>Le type et le contenu PDF sont vérifiés avant l’envoi.</small>
|
||||||
</label>
|
</label>
|
||||||
|
@if (fieldError('file'); as error) {
|
||||||
|
<p class="form-error" role="alert">{{ error }}</p>
|
||||||
|
}
|
||||||
<div class="date-fields">
|
<div class="date-fields">
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Date d’établissement</mat-label>
|
<mat-label>Date d’établissement</mat-label>
|
||||||
@@ -112,16 +122,29 @@
|
|||||||
matInput
|
matInput
|
||||||
id="establishedOn"
|
id="establishedOn"
|
||||||
type="date"
|
type="date"
|
||||||
|
(input)="clearUploadFieldError('establishedOn')"
|
||||||
[formField]="uploadForm.establishedOn"
|
[formField]="uploadForm.establishedOn"
|
||||||
/>
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
@if (fieldError('establishedOn'); as error) {
|
||||||
|
<p class="form-error" role="alert">{{ error }}</p>
|
||||||
|
}
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Date d’expiration (facultative)</mat-label>
|
<mat-label>Date d’expiration (facultative)</mat-label>
|
||||||
<input matInput id="expiresOn" type="date" [formField]="uploadForm.expiresOn" />
|
<input
|
||||||
|
matInput
|
||||||
|
id="expiresOn"
|
||||||
|
type="date"
|
||||||
|
(input)="clearUploadFieldError('expiresOn')"
|
||||||
|
[formField]="uploadForm.expiresOn"
|
||||||
|
/>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
@if (fieldError('expiresOn'); as error) {
|
||||||
|
<p class="form-error" role="alert">{{ error }}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (validationError() || uploadError()) {
|
@if (uploadError()) {
|
||||||
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p>
|
<p class="form-error" role="alert">{{ uploadError() }}</p>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
|
|||||||
+167
-4
@@ -83,6 +83,25 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
http.expectNone((request) => request.method === 'POST');
|
http.expectNone((request) => request.method === 'POST');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows every local upload error and focuses the first invalid control', () => {
|
||||||
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('.upload-submit').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Choisissez un type de document.');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF.');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'Renseignez une date d’établissement valide.',
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('mat-select'));
|
||||||
|
http.expectNone((request) => request.method === 'POST');
|
||||||
|
});
|
||||||
|
|
||||||
it('requires a PDF after the document type is selected', async () => {
|
it('requires a PDF after the document type is selected', async () => {
|
||||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
@@ -120,7 +139,7 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
);
|
);
|
||||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||||
(dpeOption as HTMLElement).click();
|
(dpeOption as HTMLElement).click();
|
||||||
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
|
const file = new File(['%PDF-document'], 'dpe.pdf', { type: 'application/pdf' });
|
||||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||||
Object.defineProperty(fileInput, 'files', {
|
Object.defineProperty(fileInput, 'files', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -133,7 +152,7 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'Renseignez la date d’établissement du document.',
|
'Renseignez une date d’établissement valide.',
|
||||||
);
|
);
|
||||||
http.expectNone((request) => request.method === 'POST');
|
http.expectNone((request) => request.method === 'POST');
|
||||||
});
|
});
|
||||||
@@ -152,7 +171,7 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
);
|
);
|
||||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||||
(dpeOption as HTMLElement).click();
|
(dpeOption as HTMLElement).click();
|
||||||
const file = new File(['pdf'], 'dpe.pdf', { type: 'application/pdf' });
|
const file = new File(['%PDF-document'], 'dpe.pdf', { type: 'application/pdf' });
|
||||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||||
Object.defineProperty(fileInput, 'files', {
|
Object.defineProperty(fileInput, 'files', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -192,7 +211,7 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
);
|
);
|
||||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||||
(dpeOption as HTMLElement).click();
|
(dpeOption as HTMLElement).click();
|
||||||
const file = new File(['pdf'], 'dpe-2026.pdf', { type: 'application/pdf' });
|
const file = new File(['%PDF-document'], 'dpe-2026', { type: 'application/pdf' });
|
||||||
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||||
Object.defineProperty(fileInput, 'files', {
|
Object.defineProperty(fileInput, 'files', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -234,6 +253,116 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
expect(submit.disabled).toBe(false);
|
expect(submit.disabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts a PDF MIME type and signature without relying on the file name', async () => {
|
||||||
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const file = new File(['%PDF-document'], 'dpe', { type: 'application/pdf' });
|
||||||
|
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||||
|
Object.defineProperty(fileInput, 'files', {
|
||||||
|
configurable: true,
|
||||||
|
value: { item: () => file },
|
||||||
|
});
|
||||||
|
fileInput.dispatchEvent(new Event('change'));
|
||||||
|
await fixture.whenStable();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.selectedFile()).toBe(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a filename-only PDF before upload', async () => {
|
||||||
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const file = new File(['%PDF-document'], 'dpe.pdf', { type: '' });
|
||||||
|
const fileInput: HTMLInputElement = fixture.nativeElement.querySelector('input[type=file]');
|
||||||
|
Object.defineProperty(fileInput, 'files', {
|
||||||
|
configurable: true,
|
||||||
|
value: { item: () => file },
|
||||||
|
});
|
||||||
|
fileInput.dispatchEvent(new Event('change'));
|
||||||
|
await fixture.whenStable();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.selectedFile()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Sélectionnez un fichier PDF valide.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the replacement form and existing document after a failed upload', () => {
|
||||||
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||||
|
{
|
||||||
|
id: 'document-1',
|
||||||
|
type: 'DPE',
|
||||||
|
establishedOn: '2026-07-19',
|
||||||
|
expiresOn: null,
|
||||||
|
fileName: 'dpe-existant.pdf',
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
authorId: 'owner-1',
|
||||||
|
uploadedAt: '2026-07-19T10:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const file = new File(['%PDF-document'], 'dpe-2026', { type: 'application/pdf' });
|
||||||
|
fixture.componentInstance.uploadModel.set({
|
||||||
|
type: 'DPE',
|
||||||
|
establishedOn: '2026-07-20',
|
||||||
|
expiresOn: '',
|
||||||
|
});
|
||||||
|
fixture.componentInstance.selectedFile.set(file);
|
||||||
|
fixture.componentInstance.submitUpload();
|
||||||
|
|
||||||
|
const upload = http.expectOne('/api/apartments/apartment-1/documents');
|
||||||
|
upload.flush(
|
||||||
|
{
|
||||||
|
code: 'validation_failed',
|
||||||
|
errors: [{ field: 'establishedOn', code: 'invalid' }],
|
||||||
|
},
|
||||||
|
{ status: 400, statusText: 'Bad Request' },
|
||||||
|
);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('dpe-existant.pdf');
|
||||||
|
expect(fixture.componentInstance.uploadModel()).toEqual({
|
||||||
|
type: 'DPE',
|
||||||
|
establishedOn: '2026-07-20',
|
||||||
|
expiresOn: '',
|
||||||
|
});
|
||||||
|
expect(fixture.componentInstance.selectedFile()).toBe(file);
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'Renseignez une date d’établissement valide.',
|
||||||
|
);
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain(
|
||||||
|
'Veuillez corriger les champs signalés.',
|
||||||
|
);
|
||||||
|
expect(document.activeElement).toBe(fixture.nativeElement.querySelector('#establishedOn'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears only the corrected upload field error', () => {
|
||||||
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/apartments/apartment-1/documents').flush([]);
|
||||||
|
fixture.componentInstance.uploadFieldErrors.set([
|
||||||
|
{ field: 'type', code: 'invalid' },
|
||||||
|
{ field: 'establishedOn', code: 'invalid' },
|
||||||
|
{ field: 'expiresOn', code: 'before_establishment' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
fixture.componentInstance.clearUploadFieldError('establishedOn');
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.uploadFieldErrors()).toEqual([
|
||||||
|
{ field: 'type', code: 'invalid' },
|
||||||
|
{ field: 'expiresOn', code: 'before_establishment' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows a load failure and retries without presenting an empty result', () => {
|
it('shows a load failure and retries without presenting an empty result', () => {
|
||||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
@@ -334,4 +463,38 @@ describe('ApartmentDocumentManager', () => {
|
|||||||
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
|
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
|
||||||
expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.');
|
expect(fixture.nativeElement.textContent).toContain('Aucun document enregistré.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a document visible when its deletion fails', async () => {
|
||||||
|
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||||
|
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||||
|
fixture.detectChanges();
|
||||||
|
http.expectOne('/api/apartments/apartment-1/documents').flush([
|
||||||
|
{
|
||||||
|
id: 'document-1',
|
||||||
|
type: 'DPE',
|
||||||
|
establishedOn: '2026-07-19',
|
||||||
|
expiresOn: null,
|
||||||
|
fileName: 'dpe.pdf',
|
||||||
|
sha256: 'a'.repeat(64),
|
||||||
|
authorId: 'owner-1',
|
||||||
|
uploadedAt: '2026-07-19T10:00:00Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
fixture.detectChanges();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('.delete-document').click();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const confirm: HTMLButtonElement | null = document.querySelector('.confirm-action');
|
||||||
|
if (!confirm) throw new Error('Confirmation de suppression introuvable.');
|
||||||
|
confirm.click();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
const request = http.expectOne('/api/apartments/apartment-1/documents/document-1');
|
||||||
|
request.flush({}, { status: 500, statusText: 'Server Error' });
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('dpe.pdf');
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Impossible de supprimer ce document.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+136
-65
@@ -1,4 +1,4 @@
|
|||||||
import { Component, DestroyRef, effect, inject, input, signal } from '@angular/core';
|
import { Component, DestroyRef, effect, ElementRef, inject, input, signal } from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals';
|
import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals';
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
ApartmentDocumentType,
|
ApartmentDocumentType,
|
||||||
ApartmentService,
|
ApartmentService,
|
||||||
} from '../../services/apartment.service';
|
} from '../../services/apartment.service';
|
||||||
import { httpErrorMessage } from '../../services/http-error-message';
|
import { FieldValidationError, saveFailure } from '../../services/http-error-message';
|
||||||
import { ApartmentDocumentActions } from './apartment-document-actions.service';
|
import { ApartmentDocumentActions } from './apartment-document-actions.service';
|
||||||
import {
|
import {
|
||||||
apartmentDocumentTypeLabel,
|
apartmentDocumentTypeLabel,
|
||||||
@@ -27,6 +27,15 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
|
|||||||
timeZone: 'UTC',
|
timeZone: 'UTC',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type UploadField = 'type' | 'file' | 'establishedOn' | 'expiresOn';
|
||||||
|
type UploadFieldError = FieldValidationError & { field: UploadField };
|
||||||
|
|
||||||
|
function isUploadField(field: string): field is UploadField {
|
||||||
|
return (
|
||||||
|
field === 'type' || field === 'file' || field === 'establishedOn' || field === 'expiresOn'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-apartment-document-manager',
|
selector: 'app-apartment-document-manager',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -46,6 +55,8 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
|
|||||||
export class ApartmentDocumentManager {
|
export class ApartmentDocumentManager {
|
||||||
private readonly apartments = inject(ApartmentService);
|
private readonly apartments = inject(ApartmentService);
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly host = inject(ElementRef<HTMLElement>);
|
||||||
|
private fileSelectionId = 0;
|
||||||
private requestId = 0;
|
private requestId = 0;
|
||||||
|
|
||||||
readonly actions = inject(ApartmentDocumentActions);
|
readonly actions = inject(ApartmentDocumentActions);
|
||||||
@@ -74,8 +85,8 @@ export class ApartmentDocumentManager {
|
|||||||
});
|
});
|
||||||
readonly selectedFile = signal<File | null>(null);
|
readonly selectedFile = signal<File | null>(null);
|
||||||
readonly uploading = signal(false);
|
readonly uploading = signal(false);
|
||||||
readonly validationError = signal('');
|
|
||||||
readonly uploadError = signal('');
|
readonly uploadError = signal('');
|
||||||
|
readonly uploadFieldErrors = signal<FieldValidationError[]>([]);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => this.load(this.apartmentId()));
|
effect(() => this.load(this.apartmentId()));
|
||||||
@@ -111,73 +122,133 @@ export class ApartmentDocumentManager {
|
|||||||
this.uploadForm().markAsTouched();
|
this.uploadForm().markAsTouched();
|
||||||
const model = this.uploadModel();
|
const model = this.uploadModel();
|
||||||
const file = this.selectedFile();
|
const file = this.selectedFile();
|
||||||
if (!model.type) {
|
const fieldErrors = this.validationErrors(model, file);
|
||||||
this.validationError.set('Choisissez un type de document.');
|
if (fieldErrors.length) {
|
||||||
} else if (!file) {
|
|
||||||
this.validationError.set('Sélectionnez un fichier PDF.');
|
|
||||||
} else if (!model.establishedOn) {
|
|
||||||
this.validationError.set('Renseignez la date d’établissement du document.');
|
|
||||||
} else if (model.expiresOn && model.expiresOn < model.establishedOn) {
|
|
||||||
this.validationError.set(
|
|
||||||
'La date d’expiration doit être postérieure ou égale à la date d’établissement.',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.validationError.set('');
|
|
||||||
this.uploadError.set('');
|
this.uploadError.set('');
|
||||||
this.uploading.set(true);
|
this.uploadFieldErrors.set(fieldErrors);
|
||||||
this.apartments
|
this.focus(fieldErrors[0].field);
|
||||||
.uploadDocument(this.apartmentId(), {
|
return;
|
||||||
file,
|
}
|
||||||
type: model.type,
|
if (!file || !model.type) return;
|
||||||
establishedOn: model.establishedOn,
|
this.uploadError.set('');
|
||||||
expiresOn: model.expiresOn || null,
|
this.uploadFieldErrors.set([]);
|
||||||
})
|
this.uploading.set(true);
|
||||||
.pipe(
|
this.apartments
|
||||||
takeUntilDestroyed(this.destroyRef),
|
.uploadDocument(this.apartmentId(), {
|
||||||
finalize(() => this.uploading.set(false)),
|
file,
|
||||||
)
|
type: model.type,
|
||||||
.subscribe({
|
establishedOn: model.establishedOn,
|
||||||
next: (uploaded) => {
|
expiresOn: model.expiresOn || null,
|
||||||
this.documents.update((documents) =>
|
})
|
||||||
[
|
.pipe(
|
||||||
...documents.filter((document) => document.type !== uploaded.type),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
uploaded,
|
finalize(() => this.uploading.set(false)),
|
||||||
].sort(
|
)
|
||||||
(left, right) =>
|
.subscribe({
|
||||||
apartmentDocumentTypes.findIndex(
|
next: (uploaded) => {
|
||||||
(type) => type.value === left.type,
|
this.documents.update((documents) =>
|
||||||
) -
|
[
|
||||||
apartmentDocumentTypes.findIndex(
|
...documents.filter((document) => document.type !== uploaded.type),
|
||||||
(type) => type.value === right.type,
|
uploaded,
|
||||||
),
|
].sort(
|
||||||
),
|
(left, right) =>
|
||||||
);
|
apartmentDocumentTypes.findIndex(
|
||||||
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
|
(type) => type.value === left.type,
|
||||||
this.selectedFile.set(null);
|
) -
|
||||||
},
|
apartmentDocumentTypes.findIndex(
|
||||||
error: (error: unknown) =>
|
(type) => type.value === right.type,
|
||||||
this.uploadError.set(
|
),
|
||||||
httpErrorMessage(error, 'Impossible de téléverser ce document.'),
|
|
||||||
),
|
),
|
||||||
});
|
);
|
||||||
|
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
},
|
||||||
|
error: (error: unknown) => {
|
||||||
|
const failure = saveFailure(
|
||||||
|
error,
|
||||||
|
'Impossible de téléverser ce document. Vérifiez que le PDF est valide et respecte la taille maximale du serveur.',
|
||||||
|
);
|
||||||
|
const inlineErrors = failure.fieldErrors.filter(
|
||||||
|
(error): error is UploadFieldError => isUploadField(error.field),
|
||||||
|
);
|
||||||
|
this.uploadFieldErrors.set(inlineErrors);
|
||||||
|
if (inlineErrors.length) this.focus(inlineErrors[0].field);
|
||||||
|
this.uploadError.set(
|
||||||
|
inlineErrors.length > 0 &&
|
||||||
|
inlineErrors.length === failure.fieldErrors.length
|
||||||
|
? ''
|
||||||
|
: failure.message,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFile(event: Event): Promise<void> {
|
||||||
|
const input = event.target;
|
||||||
|
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||||
|
const selectionId = ++this.fileSelectionId;
|
||||||
|
if (input instanceof HTMLInputElement) input.value = '';
|
||||||
|
this.clearUploadFieldError('file');
|
||||||
|
if (!file) {
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
file.type.toLowerCase() === 'application/pdf' &&
|
||||||
|
(await file.slice(0, 5).text()) === '%PDF-'
|
||||||
|
) {
|
||||||
|
if (selectionId !== this.fileSelectionId) return;
|
||||||
|
this.selectedFile.set(file);
|
||||||
|
} else {
|
||||||
|
if (selectionId !== this.fileSelectionId) return;
|
||||||
|
this.selectedFile.set(null);
|
||||||
|
this.uploadFieldErrors.update((errors) => [
|
||||||
|
...errors,
|
||||||
|
{ field: 'file', code: 'invalid' },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
selectFile(event: Event): void {
|
clearUploadFieldError(field: UploadField): void {
|
||||||
const input = event.target;
|
this.uploadFieldErrors.update((errors) => errors.filter((error) => error.field !== field));
|
||||||
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
}
|
||||||
if (
|
|
||||||
file &&
|
fieldError(field: UploadField): string {
|
||||||
file.name.toLowerCase().endsWith('.pdf') &&
|
const code = this.uploadFieldErrors().find((error) => error.field === field)?.code;
|
||||||
(file.type === 'application/pdf' || file.type === '')
|
if (!code) return '';
|
||||||
) {
|
if (field === 'type') return 'Choisissez un type de document.';
|
||||||
this.selectedFile.set(file);
|
if (field === 'establishedOn') return 'Renseignez une date d’établissement valide.';
|
||||||
this.validationError.set('');
|
if (field === 'expiresOn')
|
||||||
} else {
|
return code === 'before_establishment'
|
||||||
this.selectedFile.set(null);
|
? 'La date d’expiration doit être postérieure ou égale à la date d’établissement.'
|
||||||
if (file) this.validationError.set('Sélectionnez un fichier PDF.');
|
: 'Renseignez une date d’expiration valide.';
|
||||||
}
|
return code === 'invalid'
|
||||||
if (input instanceof HTMLInputElement) input.value = '';
|
? 'Sélectionnez un fichier PDF valide.'
|
||||||
|
: 'Sélectionnez un fichier PDF.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private validationErrors(
|
||||||
|
model: { type: ApartmentDocumentType | ''; establishedOn: string; expiresOn: string },
|
||||||
|
file: File | null,
|
||||||
|
): UploadFieldError[] {
|
||||||
|
const errors: UploadFieldError[] = [];
|
||||||
|
if (!model.type) errors.push({ field: 'type', code: 'required' });
|
||||||
|
if (!file) errors.push({ field: 'file', code: 'required' });
|
||||||
|
if (!model.establishedOn) errors.push({ field: 'establishedOn', code: 'required' });
|
||||||
|
if (model.expiresOn && model.establishedOn && model.expiresOn < model.establishedOn)
|
||||||
|
errors.push({ field: 'expiresOn', code: 'before_establishment' });
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
private focus(field: UploadField): void {
|
||||||
|
const selector = {
|
||||||
|
type: 'mat-select',
|
||||||
|
file: 'input[type=file]',
|
||||||
|
establishedOn: '#establishedOn',
|
||||||
|
expiresOn: '#expiresOn',
|
||||||
|
}[field];
|
||||||
|
const control = this.host.nativeElement.querySelector(selector);
|
||||||
|
if (control instanceof HTMLElement) control.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
private load(apartmentId: string): void {
|
private load(apartmentId: string): void {
|
||||||
|
|||||||
Reference in new issue
Block a user