feat: isolate tenants and attachments by landlord (#26)

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent f4acbffc9d
commit fca199dfdf
15 files changed
+849 -136

No files matched your search

@@ -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<ApiError> handleTenantHasAttachments(TenantHasAttachmentsException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@ExceptionHandler(IOException.class)
public ResponseEntity<ApiError> handleAttachmentIOException() {
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Impossible de traiter la pièce jointe.");
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
@@ -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<TenantResponse> getAllTenants() {
return tenantService.getAll();
public List<TenantResponse> getAllTenants(@AuthenticationPrincipal OidcUser principal) {
return tenantService.getAll(landlordId(principal));
}
@GetMapping("/{id}")
public ResponseEntity<TenantResponse> getTenant(@PathVariable String id) {
return tenantService.getById(id);
public ResponseEntity<TenantResponse> getTenant(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return ResponseEntity.of(tenantService.getById(landlordId(principal), id));
}
@PostMapping
public ResponseEntity<TenantResponse> createTenant(@Valid @RequestBody TenantRequest request) {
return tenantService.create(request);
public ResponseEntity<TenantResponse> createTenant(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody TenantRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(tenantService.create(landlordId(principal), request));
}
@PutMapping("/{id}")
public ResponseEntity<TenantResponse> updateTenant(@PathVariable String id,
public ResponseEntity<TenantResponse> 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<Void> deleteTenant(@PathVariable String id) {
return tenantService.delete(id);
public ResponseEntity<Void> 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<TenantAttachmentResponse> uploadAttachment(@PathVariable String tenantId,
public ResponseEntity<TenantAttachmentResponse> 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<Resource> downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
public ResponseEntity<Resource> 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<Void> deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
public ResponseEntity<Void> 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;
}
}
}
@@ -11,4 +11,7 @@ public record TenantResponse(
String address,
List<TenantAttachmentResponse> attachments
) {
public TenantResponse {
attachments = attachments == null ? List.of() : List.copyOf(attachments);
}
}
@@ -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.");
}
}
@@ -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<TenantAttachment> getAttachments() {
return attachments;
return List.copyOf(attachments);
}
public void setAttachments(List<TenantAttachment> attachments) {
this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments);
}
public void addAttachment(TenantAttachment attachment) {
attachments.add(Objects.requireNonNull(attachment));
}
public Optional<TenantAttachment> findAttachment(String attachmentId) {
return attachments.stream()
.filter(attachment -> Objects.equals(attachment.getId(), attachmentId))
.findFirst();
}
public Optional<TenantAttachment> removeAttachment(String attachmentId) {
Optional<TenantAttachment> attachment = findAttachment(attachmentId);
attachment.ifPresent(attachments::remove);
return attachment;
}
public boolean hasAttachments() {
return !attachments.isEmpty();
}
}
@@ -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<Tenant, String> {
List<Tenant> findByLandlordId(String landlordId);
Optional<Tenant> findByIdAndLandlordId(String id, String landlordId);
long deleteByIdAndLandlordId(String id, String landlordId);
}
@@ -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) {
}
}
@@ -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<TenantResponse> getAll() {
return tenantMapper.toResponses(tenantRepository.findAll());
public List<TenantResponse> getAll(String landlordId) {
return tenantMapper.toResponses(tenantRepository.findByLandlordId(landlordId));
}
public ResponseEntity<TenantResponse> getById(String id) {
return tenantRepository.findById(id).map(tenantMapper::toResponse).map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
public Optional<TenantResponse> getById(String landlordId, String id) {
return tenantRepository.findByIdAndLandlordId(id, landlordId).map(tenantMapper::toResponse);
}
public ResponseEntity<TenantResponse> 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<TenantResponse> 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<TenantResponse> 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<Void> delete(String id) {
if (!tenantRepository.existsById(id)) {
return ResponseEntity.notFound().build();
public boolean delete(String landlordId, String id) {
Optional<Tenant> 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<TenantAttachmentResponse> uploadAttachment(String tenantId, MultipartFile file) throws IOException {
Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
public Optional<TenantAttachmentResponse> 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<Resource> 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<Void> 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<AttachmentDownload> downloadAttachment(String landlordId, String tenantId, String attachmentId)
throws IOException {
Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null);
if (tenant == null) {
return Optional.empty();
}
Optional<TenantAttachment> 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<TenantAttachment> 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) {
}
}
@@ -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"));
}
}
@@ -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);
}
}
}
@@ -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()));
}
}
@@ -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());
}
}
}
@@ -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");
}
}