From fca199dfdf29db980950d4a9d28444b18d005240 Mon Sep 17 00:00:00 2001 From: Nessar Date: Sat, 18 Jul 2026 15:26:51 +0200 Subject: [PATCH] feat: isolate tenants and attachments by landlord (#26) --- .../controller/ApiExceptionHandler.java | 13 + .../backend/controller/TenantController.java | 71 +++-- .../backend/dto/TenantResponse.java | 3 + .../TenantHasAttachmentsException.java | 7 + .../lmnporganizer/backend/model/Tenant.java | 24 +- .../backend/repository/TenantRepository.java | 8 + .../service/TenantAttachmentStorage.java | 64 ++++- .../backend/service/TenantService.java | 162 ++++++----- .../controller/TenantControllerTest.java | 102 +++++-- .../integration/TenantPortfolioApiIT.java | 256 ++++++++++++++++++ .../backend/model/TenantTest.java | 33 +++ .../service/TenantAttachmentStorageTest.java | 35 +++ .../backend/service/TenantServiceTest.java | 127 +++++++++ .../components/tenant-form/tenant-form.html | 56 ++-- .../tenant-form/tenant-form.spec.ts | 24 ++ 15 files changed, 849 insertions(+), 136 deletions(-) create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/TenantHasAttachmentsException.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java index c3539aa..d9b55fd 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java @@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException; import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException; +import fr.nessar.lmnporganizer.backend.exception.TenantHasAttachmentsException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -11,6 +12,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import java.io.IOException; + @RestControllerAdvice public class ApiExceptionHandler { @ExceptionHandler(AttachmentTooLargeException.class) @@ -38,6 +41,16 @@ public class ApiExceptionHandler { return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage()); } + @ExceptionHandler(TenantHasAttachmentsException.class) + public ResponseEntity handleTenantHasAttachments(TenantHasAttachmentsException exception) { + return error(HttpStatus.CONFLICT, exception.getMessage()); + } + + @ExceptionHandler(IOException.class) + public ResponseEntity handleAttachmentIOException() { + return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe."); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleInvalidRequest(MethodArgumentNotValidException exception) { String message = exception.getBindingResult().getFieldErrors().stream() diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java index 0f73294..fc6c42d 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/TenantController.java @@ -3,11 +3,17 @@ package fr.nessar.lmnporganizer.backend.controller; import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse; import fr.nessar.lmnporganizer.backend.dto.TenantRequest; import fr.nessar.lmnporganizer.backend.dto.TenantResponse; +import fr.nessar.lmnporganizer.backend.service.LandlordProfileService; import fr.nessar.lmnporganizer.backend.service.TenantService; import jakarta.validation.Valid; import org.springframework.core.io.Resource; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -18,52 +24,83 @@ import java.util.List; @RequestMapping("/api/tenants") public class TenantController { private final TenantService tenantService; + private final LandlordProfileService landlordProfileService; - public TenantController(TenantService tenantService) { + public TenantController(TenantService tenantService, LandlordProfileService landlordProfileService) { this.tenantService = tenantService; + this.landlordProfileService = landlordProfileService; } @GetMapping - public List getAllTenants() { - return tenantService.getAll(); + public List getAllTenants(@AuthenticationPrincipal OidcUser principal) { + return tenantService.getAll(landlordId(principal)); } @GetMapping("/{id}") - public ResponseEntity getTenant(@PathVariable String id) { - return tenantService.getById(id); + public ResponseEntity getTenant(@AuthenticationPrincipal OidcUser principal, + @PathVariable String id) { + return ResponseEntity.of(tenantService.getById(landlordId(principal), id)); } @PostMapping - public ResponseEntity createTenant(@Valid @RequestBody TenantRequest request) { - return tenantService.create(request); + public ResponseEntity createTenant(@AuthenticationPrincipal OidcUser principal, + @Valid @RequestBody TenantRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(tenantService.create(landlordId(principal), request)); } @PutMapping("/{id}") - public ResponseEntity updateTenant(@PathVariable String id, + public ResponseEntity updateTenant(@AuthenticationPrincipal OidcUser principal, + @PathVariable String id, @Valid @RequestBody TenantRequest request) { - return tenantService.update(id, request); + return ResponseEntity.of(tenantService.update(landlordId(principal), id, request)); } @DeleteMapping("/{id}") - public ResponseEntity deleteTenant(@PathVariable String id) { - return tenantService.delete(id); + public ResponseEntity deleteTenant(@AuthenticationPrincipal OidcUser principal, @PathVariable String id) { + return tenantService.delete(landlordId(principal), id) ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); } @PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ResponseEntity uploadAttachment(@PathVariable String tenantId, + public ResponseEntity uploadAttachment(@AuthenticationPrincipal OidcUser principal, + @PathVariable String tenantId, @RequestPart("file") MultipartFile file) throws IOException { - return tenantService.uploadAttachment(tenantId, file); + return tenantService.uploadAttachment(landlordId(principal), tenantId, file) + .map(attachment -> ResponseEntity.status(HttpStatus.CREATED).body(attachment)) + .orElse(ResponseEntity.notFound().build()); } @GetMapping("/{tenantId}/attachments/{attachmentId}") - public ResponseEntity downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId) + public ResponseEntity downloadAttachment(@AuthenticationPrincipal OidcUser principal, + @PathVariable String tenantId, @PathVariable String attachmentId) throws IOException { - return tenantService.downloadAttachment(tenantId, attachmentId); + return tenantService.downloadAttachment(landlordId(principal), tenantId, attachmentId) + .map(download -> ResponseEntity.ok() + .contentType(mediaTypeFor(download.contentType())) + .header(HttpHeaders.CONTENT_DISPOSITION, + ContentDisposition.attachment().filename(download.fileName()).build().toString()) + .body(download.resource())) + .orElse(ResponseEntity.notFound().build()); } @DeleteMapping("/{tenantId}/attachments/{attachmentId}") - public ResponseEntity deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId) + public ResponseEntity deleteAttachment(@AuthenticationPrincipal OidcUser principal, + @PathVariable String tenantId, @PathVariable String attachmentId) throws IOException { - return tenantService.deleteAttachment(tenantId, attachmentId); + return tenantService.deleteAttachment(landlordId(principal), tenantId, attachmentId) + ? ResponseEntity.noContent().build() + : ResponseEntity.notFound().build(); + } + + private String landlordId(OidcUser principal) { + return landlordProfileService.findOrCreate(principal).getId(); + } + + private MediaType mediaTypeFor(String contentType) { + try { + return MediaType.parseMediaType(contentType); + } catch (IllegalArgumentException exception) { + return MediaType.APPLICATION_OCTET_STREAM; + } } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java index 85998e5..3df41ab 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/dto/TenantResponse.java @@ -11,4 +11,7 @@ public record TenantResponse( String address, List attachments ) { + public TenantResponse { + attachments = attachments == null ? List.of() : List.copyOf(attachments); + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/TenantHasAttachmentsException.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/TenantHasAttachmentsException.java new file mode 100644 index 0000000..9874943 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/TenantHasAttachmentsException.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.exception; + +public class TenantHasAttachmentsException extends RuntimeException { + public TenantHasAttachmentsException() { + super("Supprimez les pièces jointes du locataire avant de le supprimer."); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java index 88262b9..04712e6 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/model/Tenant.java @@ -8,6 +8,8 @@ import org.springframework.data.mongodb.core.mapping.Document; import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; @Document(collection = "tenants") public class Tenant { @@ -101,10 +103,30 @@ public class Tenant { } public List getAttachments() { - return attachments; + return List.copyOf(attachments); } public void setAttachments(List attachments) { this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments); } + + public void addAttachment(TenantAttachment attachment) { + attachments.add(Objects.requireNonNull(attachment)); + } + + public Optional findAttachment(String attachmentId) { + return attachments.stream() + .filter(attachment -> Objects.equals(attachment.getId(), attachmentId)) + .findFirst(); + } + + public Optional removeAttachment(String attachmentId) { + Optional attachment = findAttachment(attachmentId); + attachment.ifPresent(attachments::remove); + return attachment; + } + + public boolean hasAttachments() { + return !attachments.isEmpty(); + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java index bb81d9a..8294dac 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/repository/TenantRepository.java @@ -4,6 +4,14 @@ import fr.nessar.lmnporganizer.backend.model.Tenant; import org.springframework.data.mongodb.repository.MongoRepository; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface TenantRepository extends MongoRepository { + List findByLandlordId(String landlordId); + + Optional findByIdAndLandlordId(String id, String landlordId); + + long deleteByIdAndLandlordId(String id, String landlordId); } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java index d17f2f4..b7ffb29 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorage.java @@ -9,15 +9,21 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.UUID; @Service public class TenantAttachmentStorage { + private static final String STAGING_DIRECTORY = ".staging"; + private final Path root; + private final Path stagingRoot; public TenantAttachmentStorage(@Value("${lmnp.storage.attachments}") String root) { this.root = Path.of(root).toAbsolutePath().normalize(); + this.stagingRoot = this.root.resolve(STAGING_DIRECTORY); } public TenantAttachment store(String tenantId, MultipartFile file) throws IOException { @@ -27,30 +33,76 @@ public class TenantAttachmentStorage { ? "attachment" : Path.of(suppliedName).getFileName().toString(); Path tenantDirectory = root.resolve(tenantId).normalize(); - if (!tenantDirectory.startsWith(root)) { + if (!tenantDirectory.startsWith(root) || tenantDirectory.startsWith(stagingRoot)) { throw new IOException("Invalid tenant attachment path"); } Files.createDirectories(tenantDirectory); Path destination = tenantDirectory.resolve(attachmentId).normalize(); - file.transferTo(destination); + try { + file.transferTo(destination); + } catch (IOException | RuntimeException failure) { + try { + Files.deleteIfExists(destination); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType(); return new TenantAttachment(attachmentId, originalName, contentType, file.getSize(), root.relativize(destination).toString()); } public Resource read(TenantAttachment attachment) throws IOException { - Path path = root.resolve(attachment.getStoragePath()).normalize(); - if (!path.startsWith(root) || !Files.isRegularFile(path)) { + Path path = activePath(attachment); + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { throw new IOException("Tenant attachment not found"); } return new UrlResource(path.toUri()); } public void delete(TenantAttachment attachment) throws IOException { + StagedDeletion deletion = stageDeletion(attachment); + try { + finalizeDeletion(deletion); + } catch (IOException | RuntimeException failure) { + try { + restore(deletion); + } catch (IOException | RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + throw failure; + } + } + + StagedDeletion stageDeletion(TenantAttachment attachment) throws IOException { + Path activePath = activePath(attachment); + if (!Files.isRegularFile(activePath, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Tenant attachment not found"); + } + Files.createDirectories(stagingRoot); + Path stagedPath = stagingRoot.resolve(UUID.randomUUID().toString()); + Files.move(activePath, stagedPath, StandardCopyOption.ATOMIC_MOVE); + return new StagedDeletion(activePath, stagedPath); + } + + void restore(StagedDeletion deletion) throws IOException { + Files.createDirectories(deletion.activePath().getParent()); + Files.move(deletion.stagedPath(), deletion.activePath(), StandardCopyOption.ATOMIC_MOVE); + } + + void finalizeDeletion(StagedDeletion deletion) throws IOException { + Files.deleteIfExists(deletion.stagedPath()); + } + + private Path activePath(TenantAttachment attachment) throws IOException { Path path = root.resolve(attachment.getStoragePath()).normalize(); - if (!path.startsWith(root)) { + if (path.equals(root) || !path.startsWith(root) || path.startsWith(stagingRoot)) { throw new IOException("Invalid tenant attachment path"); } - Files.deleteIfExists(path); + return path; + } + + record StagedDeletion(Path activePath, Path stagedPath) { } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantService.java index e58c0bd..2bcd606 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/TenantService.java @@ -3,21 +3,18 @@ package fr.nessar.lmnporganizer.backend.service; import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse; import fr.nessar.lmnporganizer.backend.dto.TenantRequest; import fr.nessar.lmnporganizer.backend.dto.TenantResponse; +import fr.nessar.lmnporganizer.backend.exception.TenantHasAttachmentsException; import fr.nessar.lmnporganizer.backend.mapper.TenantMapper; import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.TenantAttachment; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import org.springframework.core.io.Resource; -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.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; import java.util.List; +import java.util.Optional; @Service public class TenantService { @@ -37,86 +34,115 @@ public class TenantService { this.tenantMapper = tenantMapper; } - public List getAll() { - return tenantMapper.toResponses(tenantRepository.findAll()); + public List getAll(String landlordId) { + return tenantMapper.toResponses(tenantRepository.findByLandlordId(landlordId)); } - public ResponseEntity getById(String id) { - return tenantRepository.findById(id).map(tenantMapper::toResponse).map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); + public Optional getById(String landlordId, String id) { + return tenantRepository.findByIdAndLandlordId(id, landlordId).map(tenantMapper::toResponse); } - public ResponseEntity create(TenantRequest request) { - Tenant tenant = tenantMapper.toEntity(request); - tenant.setLandlordId("default-landlord"); + public TenantResponse create(String landlordId, TenantRequest request) { + Tenant tenant = tenantMapper.toEntity(normalize(request)); + tenant.setLandlordId(landlordId); tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone())); - return ResponseEntity.status(HttpStatus.CREATED).body(tenantMapper.toResponse(tenantRepository.save(tenant))); + return tenantMapper.toResponse(tenantRepository.save(tenant)); } - public ResponseEntity update(String id, TenantRequest request) { - return tenantRepository.findById(id).map(tenant -> { - tenantMapper.update(request, tenant); - tenant.setPhone(phoneNumberNormalizer.normalize(request.phone())); - tenant.setLandlordId("default-landlord"); - return ResponseEntity.ok(tenantMapper.toResponse(tenantRepository.save(tenant))); - }).orElse(ResponseEntity.notFound().build()); + public Optional update(String landlordId, String id, TenantRequest request) { + return tenantRepository.findByIdAndLandlordId(id, landlordId).map(tenant -> { + TenantRequest normalized = normalize(request); + tenantMapper.update(normalized, tenant); + tenant.setPhone(phoneNumberNormalizer.normalize(normalized.phone())); + return tenantMapper.toResponse(tenantRepository.save(tenant)); + }); } - public ResponseEntity delete(String id) { - if (!tenantRepository.existsById(id)) { - return ResponseEntity.notFound().build(); + public boolean delete(String landlordId, String id) { + Optional tenant = tenantRepository.findByIdAndLandlordId(id, landlordId); + if (tenant.isEmpty()) { + return false; } - tenantRepository.deleteById(id); - return ResponseEntity.noContent().build(); + if (tenant.get().hasAttachments()) { + throw new TenantHasAttachmentsException(); + } + return tenantRepository.deleteByIdAndLandlordId(id, landlordId) == 1; } - public ResponseEntity uploadAttachment(String tenantId, MultipartFile file) throws IOException { - Tenant tenant = tenantRepository.findById(tenantId).orElse(null); + public Optional uploadAttachment(String landlordId, String tenantId, + MultipartFile file) throws IOException { + Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null); if (tenant == null) { - return ResponseEntity.notFound().build(); + return Optional.empty(); } + attachmentUploadPolicy.validate(file); TenantAttachment attachment = attachmentStorage.store(tenantId, file); - tenant.getAttachments().add(attachment); - tenantRepository.save(tenant); - return ResponseEntity.status(HttpStatus.CREATED).body(tenantMapper.toResponse(attachment)); - } - - public ResponseEntity downloadAttachment(String tenantId, String attachmentId) throws IOException { - TenantAttachment attachment = tenantRepository.findById(tenantId) - .flatMap(tenant -> tenant.getAttachments().stream().filter(item -> item.getId().equals(attachmentId)).findFirst()) - .orElse(null); - if (attachment == null) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok() - .contentType(mediaTypeFor(attachment)) - .header(HttpHeaders.CONTENT_DISPOSITION, - ContentDisposition.attachment().filename(attachment.getFileName()).build().toString()) - .body(attachmentStorage.read(attachment)); - } - - public ResponseEntity deleteAttachment(String tenantId, String attachmentId) throws IOException { - Tenant tenant = tenantRepository.findById(tenantId).orElse(null); - if (tenant == null) { - return ResponseEntity.notFound().build(); - } - TenantAttachment attachment = tenant.getAttachments().stream() - .filter(item -> item.getId().equals(attachmentId)).findFirst().orElse(null); - if (attachment == null) { - return ResponseEntity.notFound().build(); - } - attachmentStorage.delete(attachment); - tenant.getAttachments().remove(attachment); - tenantRepository.save(tenant); - return ResponseEntity.noContent().build(); - } - - private MediaType mediaTypeFor(TenantAttachment attachment) { + tenant.addAttachment(attachment); try { - return MediaType.parseMediaType(attachment.getContentType()); - } catch (IllegalArgumentException exception) { - return MediaType.APPLICATION_OCTET_STREAM; + tenantRepository.save(tenant); + } catch (RuntimeException failure) { + tenant.removeAttachment(attachment.getId()); + try { + TenantAttachmentStorage.StagedDeletion deletion = attachmentStorage.stageDeletion(attachment); + attachmentStorage.finalizeDeletion(deletion); + } catch (IOException | RuntimeException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; } + return Optional.of(tenantMapper.toResponse(attachment)); + } + + public Optional downloadAttachment(String landlordId, String tenantId, String attachmentId) + throws IOException { + Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null); + if (tenant == null) { + return Optional.empty(); + } + Optional attachment = tenant.findAttachment(attachmentId); + if (attachment.isEmpty()) { + return Optional.empty(); + } + return Optional.of(new AttachmentDownload(attachmentStorage.read(attachment.get()), attachment.get().getFileName(), + attachment.get().getContentType())); + } + + public boolean deleteAttachment(String landlordId, String tenantId, String attachmentId) throws IOException { + Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null); + if (tenant == null) { + return false; + } + Optional attachment = tenant.findAttachment(attachmentId); + if (attachment.isEmpty()) { + return false; + } + TenantAttachmentStorage.StagedDeletion deletion = attachmentStorage.stageDeletion(attachment.get()); + tenant.removeAttachment(attachmentId); + try { + tenantRepository.save(tenant); + } catch (RuntimeException failure) { + tenant.addAttachment(attachment.get()); + try { + attachmentStorage.restore(deletion); + } catch (IOException | RuntimeException rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + throw failure; + } + attachmentStorage.finalizeDeletion(deletion); + return true; + } + + private TenantRequest normalize(TenantRequest request) { + return new TenantRequest(request.firstName().trim(), request.lastName().trim(), request.email().trim(), + value(request.phone()), value(request.address())); + } + + private String value(String value) { + return value == null ? "" : value.trim(); + } + + public record AttachmentDownload(Resource resource, String fileName, String contentType) { } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java index ddc4603..889de92 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java @@ -1,27 +1,33 @@ package fr.nessar.lmnporganizer.backend.controller; +import fr.nessar.lmnporganizer.backend.model.Landlord; import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.model.TenantAttachment; +import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; -import org.springframework.http.MediaType; import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; -import static org.mockito.ArgumentMatchers.any; import java.util.List; import java.util.Optional; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -35,17 +41,27 @@ class TenantControllerTest { @MockitoBean private TenantRepository tenantRepository; + @MockitoBean + private LandlordRepository landlordRepository; + @MockitoBean private TenantAttachmentStorage attachmentStorage; + @BeforeEach + void resolveAuthenticatedLandlord() { + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of( + new Landlord("landlord-1", "subject-1", "Camille", "Martin", "camille@example.com", + "+33600000000", "1 rue de Paris"))); + } + @Test - void whenCreateTenant_thenCreatedForDefaultLandlord() throws Exception { - Tenant savedTenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + void whenCreateTenant_thenCreatedForAuthenticatedLandlord() throws Exception { + Tenant savedTenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com", "+33612345678", "10 rue des Lilas"); when(tenantRepository.save(any(Tenant.class))).thenReturn(savedTenant); mockMvc.perform(post("/api/tenants") - .with(oidcLogin()) + .with(asLandlord()) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -70,7 +86,7 @@ class TenantControllerTest { }); mockMvc.perform(post("/api/tenants") - .with(oidcLogin()) + .with(asLandlord()) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -88,7 +104,7 @@ class TenantControllerTest { @Test void whenCreateTenantWithInvalidEmail_thenBadRequest() throws Exception { mockMvc.perform(post("/api/tenants") - .with(oidcLogin()) + .with(asLandlord()) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -111,32 +127,32 @@ class TenantControllerTest { @Test void whenGetTenants_thenReturnsPortfolioTenants() throws Exception { - Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com", "+33612345678", "10 rue des Lilas"); - when(tenantRepository.findAll()).thenReturn(List.of(tenant)); + when(tenantRepository.findByLandlordId("landlord-1")).thenReturn(List.of(tenant)); - mockMvc.perform(get("/api/tenants").with(oidcLogin())) + mockMvc.perform(get("/api/tenants").with(asLandlord())) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].firstName").value("Camille")); } @Test void whenGetUnknownTenant_thenNotFound() throws Exception { - when(tenantRepository.findById("missing")).thenReturn(Optional.empty()); + when(tenantRepository.findByIdAndLandlordId("missing", "landlord-1")).thenReturn(Optional.empty()); - mockMvc.perform(get("/api/tenants/missing").with(oidcLogin())) + mockMvc.perform(get("/api/tenants/missing").with(asLandlord())) .andExpect(status().isNotFound()); } @Test void whenUpdateTenant_thenContactDetailsAreUpdated() throws Exception { - Tenant existing = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + Tenant existing = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "old@example.com", "0100000000", "Ancienne adresse"); - when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(existing)); + when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(existing)); when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> invocation.getArgument(0)); mockMvc.perform(put("/api/tenants/tenant-1") - .with(oidcLogin()) + .with(asLandlord()) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -154,28 +170,43 @@ class TenantControllerTest { @Test void whenDeleteTenant_thenNoContent() throws Exception { - when(tenantRepository.existsById("tenant-1")).thenReturn(true); + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + when(tenantRepository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L); - mockMvc.perform(delete("/api/tenants/tenant-1").with(oidcLogin())) + mockMvc.perform(delete("/api/tenants/tenant-1").with(asLandlord())) .andExpect(status().isNoContent()); + } - verify(tenantRepository).deleteById("tenant-1"); + @Test + void whenDeleteTenantWithAttachments_thenConflictExplainsRequiredAction() throws Exception { + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + tenant.setAttachments(List.of(new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "tenant-1/document-1.pdf"))); + when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + + mockMvc.perform(delete("/api/tenants/tenant-1").with(asLandlord())) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message") + .value("Supprimez les pièces jointes du locataire avant de le supprimer.")); } @Test void whenUploadAttachment_thenMetadataIsAddedToTenant() throws Exception { - Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com", "+33612345678", "10 rue des Lilas"); TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, "tenant-1/document-1.pdf"); MockMultipartFile file = new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()); - when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(tenant)); + when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); when(attachmentStorage.store(eq("tenant-1"), any())).thenReturn(attachment); when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> invocation.getArgument(0)); mockMvc.perform(multipart("/api/tenants/tenant-1/attachments") .file(file) - .with(oidcLogin())) + .with(asLandlord())) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value("attachment-1")) .andExpect(jsonPath("$.fileName").value("identite.pdf")) @@ -184,15 +215,15 @@ class TenantControllerTest { @Test void whenDownloadAttachment_thenReturnsAttachment() throws Exception { - Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin", + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com", "+33612345678", "10 rue des Lilas"); TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, "tenant-1/document-1.pdf"); tenant.setAttachments(List.of(attachment)); - when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(tenant)); + when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); when(attachmentStorage.read(attachment)).thenReturn(new ByteArrayResource("PDF!".getBytes())); - mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(oidcLogin())) + mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(asLandlord())) .andExpect(status().isOk()) .andExpect(result -> { if (!"PDF!".equals(result.getResponse().getContentAsString())) { @@ -200,4 +231,25 @@ class TenantControllerTest { } }); } + + @Test + void whenAttachmentStorageReadFails_thenReturnsSafeStructuredError() throws Exception { + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", + "camille@example.com", "+33612345678", "10 rue des Lilas"); + TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "tenant-1/document-1.pdf"); + tenant.setAttachments(List.of(attachment)); + when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + when(attachmentStorage.read(attachment)) + .thenThrow(new java.io.IOException("Storage read failed at /private/tenant-1/document-1.pdf")); + + mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(asLandlord())) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Impossible de traiter la pièce jointe.")) + .andExpect(content().string(not(containsString("/private/tenant-1/document-1.pdf")))); + } + + private static RequestPostProcessor asLandlord() { + return oidcLogin().idToken(token -> token.claim("sub", "subject-1")); + } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java new file mode 100644 index 0000000..2e6e01f --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java @@ -0,0 +1,256 @@ +package fr.nessar.lmnporganizer.backend.integration; + +import fr.nessar.lmnporganizer.backend.BackendApplication; +import fr.nessar.lmnporganizer.backend.model.Landlord; +import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; +import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.util.FileSystemUtils; +import org.testcontainers.mongodb.MongoDBContainer; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static com.jayway.jsonpath.JsonPath.read; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = BackendApplication.class, properties = { + "lmnp.data-initializer.enabled=false", + "lmnp.attachments.max-size=4B" +}) +@AutoConfigureMockMvc +class TenantPortfolioApiIT { + + private static final String VALID_TENANT = """ + {"firstName":"Camille","lastName":"Martin","email":"camille@example.com","phone":"06 12 34 56 78","address":"10 rue des Lilas, Lyon"} + """; + private static final String UPDATED_TENANT = """ + {"firstName":"Camille","lastName":"Bernard","email":"camille.bernard@example.com","phone":"+33612345678","address":"20 rue des Lilas, Lyon"} + """; + private static final String INVALID_TENANT = """ + {"firstName":" ","lastName":"","email":"not-an-email","phone":"","address":"Lyon"} + """; + private static final MongoDBContainer mongoDb = startMongoDb(); + private static final Path attachmentRoot = temporaryAttachmentRoot(); + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TenantRepository tenantRepository; + + @Autowired + private LandlordRepository landlordRepository; + + @DynamicPropertySource + static void configureInfrastructure(DynamicPropertyRegistry registry) { + if (mongoDb != null) { + registry.add("spring.mongodb.uri", () -> mongoDb.getConnectionString() + "/lmnp-tenant-portfolios"); + } + registry.add("lmnp.storage.attachments", attachmentRoot::toString); + } + + @BeforeEach + void setUpBailleurs() throws IOException { + tenantRepository.deleteAll(); + landlordRepository.deleteAll(); + FileSystemUtils.deleteRecursively(attachmentRoot); + Files.createDirectories(attachmentRoot); + landlordRepository.save(new Landlord("landlord-alice", "subject-alice", "Alice", "Martin", + "alice@example.com", "+33600000000", "1 rue de Paris")); + landlordRepository.save(new Landlord("landlord-bob", "subject-bob", "Bob", "Durand", + "bob@example.com", "+33600000001", "2 rue de Lyon")); + } + + @AfterEach + void clearData() { + tenantRepository.deleteAll(); + landlordRepository.deleteAll(); + } + + @AfterAll + static void clearAttachments() throws IOException { + FileSystemUtils.deleteRecursively(attachmentRoot); + } + + @Test + void eachBailleurCanManageOnlyTheirOwnTenants() throws Exception { + String aliceTenantId = createTenant(as("subject-alice"), VALID_TENANT); + String bobTenantId = createTenant(as("subject-bob"), """ + {"firstName":"Louis","lastName":"Durand","email":"louis@example.com","phone":"+33611111111","address":"4 montée de la Boucle, Lyon"} + """); + + mockMvc.perform(get("/api/tenants").with(as("subject-alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id").value(aliceTenantId)) + .andExpect(jsonPath("$[0].landlordId").doesNotExist()); + + mockMvc.perform(get("/api/tenants/{id}", aliceTenantId).with(as("subject-alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.email").value("camille@example.com")) + .andExpect(jsonPath("$.landlordId").doesNotExist()); + + mockMvc.perform(get("/api/tenants/{id}", aliceTenantId).with(as("subject-bob"))) + .andExpect(status().isNotFound()); + + mockMvc.perform(put("/api/tenants/{id}", aliceTenantId) + .with(as("subject-bob")) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATED_TENANT)) + .andExpect(status().isNotFound()); + + mockMvc.perform(delete("/api/tenants/{id}", aliceTenantId).with(as("subject-bob"))) + .andExpect(status().isNotFound()); + + mockMvc.perform(put("/api/tenants/{id}", aliceTenantId) + .with(as("subject-alice")) + .contentType(MediaType.APPLICATION_JSON) + .content(UPDATED_TENANT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.lastName").value("Bernard")) + .andExpect(jsonPath("$.landlordId").doesNotExist()); + + mockMvc.perform(delete("/api/tenants/{id}", bobTenantId).with(as("subject-bob"))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/tenants/{id}", bobTenantId).with(as("subject-bob"))) + .andExpect(status().isNotFound()); + } + + @Test + void invalidTenantRequestIsRejected() throws Exception { + mockMvc.perform(post("/api/tenants") + .with(as("subject-alice")) + .contentType(MediaType.APPLICATION_JSON) + .content(INVALID_TENANT)) + .andExpect(status().isBadRequest()); + } + + @Test + void attachmentsCanBeManagedOnlyThroughTheirOwningTenant() throws Exception { + String tenantId = createTenant(as("subject-alice"), VALID_TENANT); + MockMultipartFile attachment = new MockMultipartFile( + "file", "identite.pdf", MediaType.APPLICATION_PDF_VALUE, "PDF!".getBytes()); + String attachmentId = uploadAttachment(tenantId, attachment, as("subject-alice")); + + mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.attachments", hasSize(1))) + .andExpect(jsonPath("$.attachments[0].id").value(attachmentId)) + .andExpect(jsonPath("$.attachments[0].fileName").value("identite.pdf")) + .andExpect(jsonPath("$.attachments[0].storagePath").doesNotExist()); + + mockMvc.perform(get("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId) + .with(as("subject-alice"))) + .andExpect(status().isOk()) + .andExpect(content().bytes("PDF!".getBytes())) + .andExpect(header().string("Content-Disposition", "attachment; filename=\"identite.pdf\"")); + + MockMultipartFile oversized = new MockMultipartFile( + "file", "secret.pdf", MediaType.APPLICATION_PDF_VALUE, "PDF!!".getBytes()); + mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId) + .file(oversized) + .with(as("subject-bob"))) + .andExpect(status().isNotFound()); + + mockMvc.perform(get("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId) + .with(as("subject-bob"))) + .andExpect(status().isNotFound()); + + mockMvc.perform(delete("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId) + .with(as("subject-bob"))) + .andExpect(status().isNotFound()); + + mockMvc.perform(delete("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId) + .with(as("subject-alice"))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.attachments", hasSize(0))); + } + + @Test + void ownedOversizedAttachmentIsRejectedWithoutMetadata() throws Exception { + String tenantId = createTenant(as("subject-alice"), VALID_TENANT); + MockMultipartFile oversized = new MockMultipartFile( + "file", "identite.pdf", MediaType.APPLICATION_PDF_VALUE, "PDF!!".getBytes()); + + mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId) + .file(oversized) + .with(as("subject-alice"))) + .andExpect(status().isContentTooLarge()); + + mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.attachments", hasSize(0))); + } + + private String createTenant(RequestPostProcessor principal, String request) throws Exception { + String response = mockMvc.perform(post("/api/tenants") + .with(principal) + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.landlordId").doesNotExist()) + .andReturn().getResponse().getContentAsString(); + return read(response, "$.id"); + } + + private String uploadAttachment(String tenantId, MockMultipartFile file, RequestPostProcessor principal) + throws Exception { + String response = mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId) + .file(file) + .with(principal)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.storagePath").doesNotExist()) + .andReturn().getResponse().getContentAsString(); + return read(response, "$.id"); + } + + private static RequestPostProcessor as(String oidcSubject) { + return oidcLogin().idToken(token -> token.claim("sub", oidcSubject)); + } + + private static MongoDBContainer startMongoDb() { + if (System.getenv("SPRING_DATA_MONGODB_URI") != null) { + return null; + } + MongoDBContainer container = new MongoDBContainer("mongo:8.0"); + container.start(); + return container; + } + + private static Path temporaryAttachmentRoot() { + try { + return Files.createTempDirectory("lmnp-tenant-api-it-"); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java new file mode 100644 index 0000000..9f51d03 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java @@ -0,0 +1,33 @@ +package fr.nessar.lmnporganizer.backend.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TenantTest { + + @Test + void ownsAttachmentMembership() { + Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com", + "+33612345678", "10 rue des Lilas"); + TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "tenant-1/attachment-1"); + + assertFalse(tenant.hasAttachments()); + + tenant.addAttachment(attachment); + + assertAll( + () -> assertTrue(tenant.hasAttachments()), + () -> assertSame(attachment, tenant.findAttachment("attachment-1").orElseThrow())); + + tenant.removeAttachment("attachment-1"); + + assertAll( + () -> assertFalse(tenant.hasAttachments()), + () -> assertTrue(tenant.findAttachment("attachment-1").isEmpty())); + } +} diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java index e0a9a20..58fecae 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java @@ -4,8 +4,10 @@ import fr.nessar.lmnporganizer.backend.model.TenantAttachment; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.*; @@ -38,6 +40,39 @@ class TenantAttachmentStorageTest { TenantAttachment unsafe = new TenantAttachment("id", "unsafe", "text/plain", 1, "../unsafe"); assertThrows(IOException.class, () -> storage.read(unsafe)); assertThrows(IOException.class, () -> storage.delete(unsafe)); + TenantAttachment staged = new TenantAttachment("id", "staged", "text/plain", 1, ".staging/id"); + assertThrows(IOException.class, () -> storage.read(staged)); assertThrows(IOException.class, () -> storage.store("../outside", file)); } + + @Test + void stagedDeletionCanBeRestoredBeforeItIsFinalized() throws IOException { + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + TenantAttachment attachment = storage.store("tenant-1", + new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes())); + + TenantAttachmentStorage.StagedDeletion deletion = storage.stageDeletion(attachment); + assertThrows(IOException.class, () -> storage.read(attachment)); + + storage.restore(deletion); + assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())); + } + + @Test + void failedStoreLeavesNoPartialAttachment() throws IOException { + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + MultipartFile file = new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()) { + @Override + public void transferTo(Path destination) throws IOException { + Files.writeString(destination, "partial"); + throw new IOException("interrupted upload"); + } + }; + + assertThrows(IOException.class, () -> storage.store("tenant-1", file)); + + try (var storedPaths = Files.walk(directory)) { + assertEquals(0, storedPaths.filter(Files::isRegularFile).count()); + } + } } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java new file mode 100644 index 0000000..0700ebd --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java @@ -0,0 +1,127 @@ +package fr.nessar.lmnporganizer.backend.service; + +import fr.nessar.lmnporganizer.backend.exception.TenantHasAttachmentsException; +import fr.nessar.lmnporganizer.backend.mapper.TenantMapper; +import fr.nessar.lmnporganizer.backend.model.Tenant; +import fr.nessar.lmnporganizer.backend.model.TenantAttachment; +import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TenantServiceTest { + @TempDir + Path directory; + + @Test + void failedUploadMetadataSaveLeavesNoAttachmentOrActiveFile() throws IOException { + TenantRepository repository = mock(TenantRepository.class); + Tenant tenant = tenant(); + when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + IllegalStateException failure = new IllegalStateException("Mongo unavailable"); + when(repository.save(tenant)).thenThrow(failure); + TenantService service = service(repository, new TenantAttachmentStorage(directory.toString())); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> service.uploadAttachment("landlord-1", "tenant-1", + new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()))); + + assertAll( + () -> assertSame(failure, thrown), + () -> assertTrue(tenant.getAttachments().isEmpty()), + () -> { + try (var storedPaths = Files.walk(directory)) { + assertEquals(0, storedPaths.filter(Files::isRegularFile).count()); + } + }); + } + + @Test + void failedMetadataRemovalRestoresAttachmentAndRethrowsOriginalFailure() throws IOException { + TenantRepository repository = mock(TenantRepository.class); + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + Tenant tenant = tenant(); + TenantAttachment attachment = storage.store("tenant-1", + new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes())); + tenant.setAttachments(List.of(attachment)); + when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + IllegalStateException failure = new IllegalStateException("Mongo unavailable"); + when(repository.save(tenant)).thenThrow(failure); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> service(repository, storage).deleteAttachment("landlord-1", "tenant-1", attachment.getId())); + + assertAll( + () -> assertSame(failure, thrown), + () -> assertEquals(List.of(attachment), tenant.getAttachments()), + () -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes()))); + } + + @Test + void successfulAttachmentRemovalDeletesMetadataAndActiveFile() throws IOException { + TenantRepository repository = mock(TenantRepository.class); + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + Tenant tenant = tenant(); + TenantAttachment attachment = storage.store("tenant-1", + new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes())); + tenant.setAttachments(List.of(attachment)); + when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + + assertTrue(service(repository, storage) + .deleteAttachment("landlord-1", "tenant-1", attachment.getId())); + + assertAll( + () -> assertTrue(tenant.getAttachments().isEmpty()), + () -> assertThrows(IOException.class, () -> storage.read(attachment))); + } + + @Test + void failedStorageDeleteKeepsAttachmentMetadata() throws IOException { + TenantRepository repository = mock(TenantRepository.class); + TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString()); + Tenant tenant = tenant(); + TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "../outside/attachment-1"); + tenant.setAttachments(List.of(attachment)); + when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + + assertThrows(IOException.class, () -> service(repository, storage) + .deleteAttachment("landlord-1", "tenant-1", "attachment-1")); + + assertEquals(List.of(attachment), tenant.getAttachments()); + verify(repository, never()).save(any()); + } + + @Test + void tenantWithAttachmentsMustBeEmptiedBeforeDeletion() { + TenantRepository repository = mock(TenantRepository.class); + Tenant tenant = tenant(); + tenant.setAttachments(List.of(new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4, + "tenant-1/attachment-1"))); + when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant)); + + assertThrows(TenantHasAttachmentsException.class, + () -> service(repository, mock(TenantAttachmentStorage.class)).delete("landlord-1", "tenant-1")); + + verify(repository, never()).deleteByIdAndLandlordId(anyString(), anyString()); + } + + private TenantService service(TenantRepository repository, TenantAttachmentStorage storage) { + return new TenantService(repository, storage, new AttachmentUploadPolicy("250MB"), + mock(PhoneNumberNormalizer.class), mock(TenantMapper.class)); + } + + private Tenant tenant() { + return new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com", + "+33612345678", "10 rue des Lilas"); + } +} diff --git a/frontend/src/app/components/tenant-form/tenant-form.html b/frontend/src/app/components/tenant-form/tenant-form.html index db6acef..50cbd74 100644 --- a/frontend/src/app/components/tenant-form/tenant-form.html +++ b/frontend/src/app/components/tenant-form/tenant-form.html @@ -15,12 +15,7 @@

Renseignez les coordonnées nécessaires à la gestion du bail.

-
+ @if (saveError()) { } @@ -35,14 +30,32 @@ matInput id="firstName" [formField]="tenantForm.firstName" - /> + [attr.aria-describedby]=" + tenantForm.firstName().touched() && tenantForm.firstName().invalid() + ? 'firstName-error' + : null + " + /> + @if (tenantForm.firstName().touched() && tenantForm.firstName().invalid()) { + Le prénom est obligatoire. + } + Nom + [attr.aria-describedby]=" + tenantForm.lastName().touched() && tenantForm.lastName().invalid() + ? 'lastName-error' + : null + " + /> + @if (tenantForm.lastName().touched() && tenantForm.lastName().invalid()) { + Le nom est obligatoire. + } + E-mail + [attr.aria-describedby]=" + tenantForm.email().touched() && tenantForm.email().invalid() ? 'email-error' : null + " + /> + @if (tenantForm.email().touched() && tenantForm.email().invalid()) { + + @if (tenantForm.email().getError('required')) { + L’e-mail est obligatoire. + } @else { + Saisissez une adresse e-mail valide. + } + + } + Téléphone Adresse complète
diff --git a/frontend/src/app/components/tenant-form/tenant-form.spec.ts b/frontend/src/app/components/tenant-form/tenant-form.spec.ts index eeaf301..df759bb 100644 --- a/frontend/src/app/components/tenant-form/tenant-form.spec.ts +++ b/frontend/src/app/components/tenant-form/tenant-form.spec.ts @@ -43,4 +43,28 @@ describe('TenantForm', () => { expect(fixture.componentInstance.tenantForm().touched()).toBe(true); expect(submitted).not.toHaveBeenCalled(); }); + + it('shows actionable validation guidance linked to invalid fields', () => { + const fixture = TestBed.createComponent(TenantForm); + fixture.detectChanges(); + fixture.componentInstance.tenantModel.set({ + firstName: '', + lastName: 'Martin', + email: 'invalid-email', + phone: '', + address: '', + }); + fixture.componentInstance.save(); + fixture.detectChanges(); + + const firstNameInput = fixture.nativeElement.querySelector('#firstName'); + const emailInput = fixture.nativeElement.querySelector('#email'); + const firstNameError = fixture.nativeElement.querySelector('#firstName-error'); + const emailError = fixture.nativeElement.querySelector('#email-error'); + + expect(firstNameError?.textContent ?? '').toContain('Le prénom est obligatoire'); + expect(emailError?.textContent ?? '').toContain('Saisissez une adresse e-mail valide'); + expect(firstNameInput.getAttribute('aria-describedby')).toContain('firstName-error'); + expect(emailInput.getAttribute('aria-describedby')).toContain('email-error'); + }); });