feat: isolate tenants and attachments by landlord (#26)
This commit is contained in:
1 parent
f4acbffc9d
commit
fca199dfdf
15 files changed
+849
-136
No files matched your search
+13
@@ -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()
|
||||
|
||||
+54
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
+58
-6
@@ -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) {
|
||||
}
|
||||
}
|
||||
+77
-25
@@ -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"));
|
||||
}
|
||||
}
|
||||
+256
@@ -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()));
|
||||
}
|
||||
}
|
||||
+35
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user