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 =
|
||||
|
||||
+28
-5
@@ -93,18 +93,28 @@
|
||||
<fieldset [disabled]="uploading()">
|
||||
<mat-form-field appearance="outline">
|
||||
<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>
|
||||
@for (type of documentTypes; track type.value) {
|
||||
<mat-option [value]="type.value">{{ type.label }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
@if (fieldError('type'); as error) {
|
||||
<p class="form-error" role="alert">{{ error }}</p>
|
||||
}
|
||||
<label class="file-picker">
|
||||
<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>Le type et le contenu PDF sont vérifiés avant l’envoi.</small>
|
||||
</label>
|
||||
@if (fieldError('file'); as error) {
|
||||
<p class="form-error" role="alert">{{ error }}</p>
|
||||
}
|
||||
<div class="date-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Date d’établissement</mat-label>
|
||||
@@ -112,16 +122,29 @@
|
||||
matInput
|
||||
id="establishedOn"
|
||||
type="date"
|
||||
(input)="clearUploadFieldError('establishedOn')"
|
||||
[formField]="uploadForm.establishedOn"
|
||||
/>
|
||||
</mat-form-field>
|
||||
@if (fieldError('establishedOn'); as error) {
|
||||
<p class="form-error" role="alert">{{ error }}</p>
|
||||
}
|
||||
<mat-form-field appearance="outline">
|
||||
<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>
|
||||
@if (fieldError('expiresOn'); as error) {
|
||||
<p class="form-error" role="alert">{{ error }}</p>
|
||||
}
|
||||
</div>
|
||||
@if (validationError() || uploadError()) {
|
||||
<p class="form-error" role="alert">{{ validationError() || uploadError() }}</p>
|
||||
@if (uploadError()) {
|
||||
<p class="form-error" role="alert">{{ uploadError() }}</p>
|
||||
}
|
||||
<button
|
||||
mat-flat-button
|
||||
|
||||
+167
-4
@@ -83,6 +83,25 @@ describe('ApartmentDocumentManager', () => {
|
||||
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 () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
@@ -120,7 +139,7 @@ describe('ApartmentDocumentManager', () => {
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(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]');
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
@@ -133,7 +152,7 @@ describe('ApartmentDocumentManager', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Renseignez la date d’établissement du document.',
|
||||
'Renseignez une date d’établissement valide.',
|
||||
);
|
||||
http.expectNone((request) => request.method === 'POST');
|
||||
});
|
||||
@@ -152,7 +171,7 @@ describe('ApartmentDocumentManager', () => {
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(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]');
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
@@ -192,7 +211,7 @@ describe('ApartmentDocumentManager', () => {
|
||||
);
|
||||
if (!dpeOption) throw new Error('Option DPE introuvable.');
|
||||
(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]');
|
||||
Object.defineProperty(fileInput, 'files', {
|
||||
configurable: true,
|
||||
@@ -234,6 +253,116 @@ describe('ApartmentDocumentManager', () => {
|
||||
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', () => {
|
||||
const fixture = TestBed.createComponent(ApartmentDocumentManager);
|
||||
fixture.componentRef.setInput('apartmentId', 'apartment-1');
|
||||
@@ -334,4 +463,38 @@ describe('ApartmentDocumentManager', () => {
|
||||
expect(fixture.nativeElement.textContent).not.toContain('dpe.pdf');
|
||||
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 { FormsModule } from '@angular/forms';
|
||||
import { FormField, FormRoot, form, required, validate } from '@angular/forms/signals';
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ApartmentDocumentType,
|
||||
ApartmentService,
|
||||
} 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 {
|
||||
apartmentDocumentTypeLabel,
|
||||
@@ -27,6 +27,15 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
|
||||
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({
|
||||
selector: 'app-apartment-document-manager',
|
||||
imports: [
|
||||
@@ -46,6 +55,8 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
|
||||
export class ApartmentDocumentManager {
|
||||
private readonly apartments = inject(ApartmentService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly host = inject(ElementRef<HTMLElement>);
|
||||
private fileSelectionId = 0;
|
||||
private requestId = 0;
|
||||
|
||||
readonly actions = inject(ApartmentDocumentActions);
|
||||
@@ -74,8 +85,8 @@ export class ApartmentDocumentManager {
|
||||
});
|
||||
readonly selectedFile = signal<File | null>(null);
|
||||
readonly uploading = signal(false);
|
||||
readonly validationError = signal('');
|
||||
readonly uploadError = signal('');
|
||||
readonly uploadFieldErrors = signal<FieldValidationError[]>([]);
|
||||
|
||||
constructor() {
|
||||
effect(() => this.load(this.apartmentId()));
|
||||
@@ -111,73 +122,133 @@ export class ApartmentDocumentManager {
|
||||
this.uploadForm().markAsTouched();
|
||||
const model = this.uploadModel();
|
||||
const file = this.selectedFile();
|
||||
if (!model.type) {
|
||||
this.validationError.set('Choisissez un type de document.');
|
||||
} 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('');
|
||||
const fieldErrors = this.validationErrors(model, file);
|
||||
if (fieldErrors.length) {
|
||||
this.uploadError.set('');
|
||||
this.uploading.set(true);
|
||||
this.apartments
|
||||
.uploadDocument(this.apartmentId(), {
|
||||
file,
|
||||
type: model.type,
|
||||
establishedOn: model.establishedOn,
|
||||
expiresOn: model.expiresOn || null,
|
||||
})
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.uploading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (uploaded) => {
|
||||
this.documents.update((documents) =>
|
||||
[
|
||||
...documents.filter((document) => document.type !== uploaded.type),
|
||||
uploaded,
|
||||
].sort(
|
||||
(left, right) =>
|
||||
apartmentDocumentTypes.findIndex(
|
||||
(type) => type.value === left.type,
|
||||
) -
|
||||
apartmentDocumentTypes.findIndex(
|
||||
(type) => type.value === right.type,
|
||||
),
|
||||
),
|
||||
);
|
||||
this.uploadModel.set({ type: '', establishedOn: '', expiresOn: '' });
|
||||
this.selectedFile.set(null);
|
||||
},
|
||||
error: (error: unknown) =>
|
||||
this.uploadError.set(
|
||||
httpErrorMessage(error, 'Impossible de téléverser ce document.'),
|
||||
this.uploadFieldErrors.set(fieldErrors);
|
||||
this.focus(fieldErrors[0].field);
|
||||
return;
|
||||
}
|
||||
if (!file || !model.type) return;
|
||||
this.uploadError.set('');
|
||||
this.uploadFieldErrors.set([]);
|
||||
this.uploading.set(true);
|
||||
this.apartments
|
||||
.uploadDocument(this.apartmentId(), {
|
||||
file,
|
||||
type: model.type,
|
||||
establishedOn: model.establishedOn,
|
||||
expiresOn: model.expiresOn || null,
|
||||
})
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
finalize(() => this.uploading.set(false)),
|
||||
)
|
||||
.subscribe({
|
||||
next: (uploaded) => {
|
||||
this.documents.update((documents) =>
|
||||
[
|
||||
...documents.filter((document) => document.type !== uploaded.type),
|
||||
uploaded,
|
||||
].sort(
|
||||
(left, right) =>
|
||||
apartmentDocumentTypes.findIndex(
|
||||
(type) => type.value === left.type,
|
||||
) -
|
||||
apartmentDocumentTypes.findIndex(
|
||||
(type) => type.value === right.type,
|
||||
),
|
||||
),
|
||||
});
|
||||
);
|
||||
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 {
|
||||
const input = event.target;
|
||||
const file = input instanceof HTMLInputElement ? (input.files?.item(0) ?? null) : null;
|
||||
if (
|
||||
file &&
|
||||
file.name.toLowerCase().endsWith('.pdf') &&
|
||||
(file.type === 'application/pdf' || file.type === '')
|
||||
) {
|
||||
this.selectedFile.set(file);
|
||||
this.validationError.set('');
|
||||
} else {
|
||||
this.selectedFile.set(null);
|
||||
if (file) this.validationError.set('Sélectionnez un fichier PDF.');
|
||||
}
|
||||
if (input instanceof HTMLInputElement) input.value = '';
|
||||
clearUploadFieldError(field: UploadField): void {
|
||||
this.uploadFieldErrors.update((errors) => errors.filter((error) => error.field !== field));
|
||||
}
|
||||
|
||||
fieldError(field: UploadField): string {
|
||||
const code = this.uploadFieldErrors().find((error) => error.field === field)?.code;
|
||||
if (!code) return '';
|
||||
if (field === 'type') return 'Choisissez un type de document.';
|
||||
if (field === 'establishedOn') return 'Renseignez une date d’établissement valide.';
|
||||
if (field === 'expiresOn')
|
||||
return code === 'before_establishment'
|
||||
? 'La date d’expiration doit être postérieure ou égale à la date d’établissement.'
|
||||
: 'Renseignez une date d’expiration valide.';
|
||||
return code === 'invalid'
|
||||
? '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 {
|
||||
|
||||
Reference in new issue
Block a user