feat: protect referenced rental resources (#27)

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent fca199dfdf
commit 971ffd6feb
23 files changed
+1453 -151

No files matched your search

@@ -4,7 +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 fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -41,8 +41,8 @@ public class ApiExceptionHandler {
return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage());
}
@ExceptionHandler(TenantHasAttachmentsException.class)
public ResponseEntity<ApiError> handleTenantHasAttachments(TenantHasAttachmentsException exception) {
@ExceptionHandler(ReferencedByLeaseException.class)
public ResponseEntity<ApiError> handleReferencedByLease(ReferencedByLeaseException exception) {
return error(HttpStatus.CONFLICT, exception.getMessage());
}
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.service.LandlordProfileService;
import fr.nessar.lmnporganizer.backend.service.LeaseService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
@@ -15,39 +16,48 @@ import java.util.List;
@RequestMapping("/api/leases")
public class LeaseController {
private final LeaseService leaseService;
private final LandlordProfileService landlordProfileService;
public LeaseController(LeaseService leaseService) {
public LeaseController(LeaseService leaseService, LandlordProfileService landlordProfileService) {
this.leaseService = leaseService;
this.landlordProfileService = landlordProfileService;
}
@GetMapping
public List<LeaseResponse> getAllLeases() {
return leaseService.getAll();
public List<LeaseResponse> getAllLeases(@AuthenticationPrincipal OidcUser principal) {
return leaseService.getAll(landlordId(principal));
}
@GetMapping("/{id}")
public ResponseEntity<LeaseResponse> getLease(@PathVariable String id) {
return leaseService.getById(id);
public ResponseEntity<LeaseResponse> getLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id) {
return leaseService.getById(landlordId(principal), id);
}
@PostMapping
public ResponseEntity<LeaseResponse> createLease(@Valid @RequestBody LeaseRequest request) {
return leaseService.create(request);
public ResponseEntity<LeaseResponse> createLease(@AuthenticationPrincipal OidcUser principal,
@Valid @RequestBody LeaseRequest request) {
return leaseService.create(landlordId(principal), request);
}
@PutMapping("/{id}")
public ResponseEntity<LeaseResponse> updateLease(@PathVariable String id,
public ResponseEntity<LeaseResponse> updateLease(@AuthenticationPrincipal OidcUser principal,
@PathVariable String id,
@Valid @RequestBody LeaseRequest request) {
return leaseService.update(id, request);
return leaseService.update(landlordId(principal), id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteLease(@PathVariable String id) {
return leaseService.delete(id);
public ResponseEntity<Void> deleteLease(@AuthenticationPrincipal OidcUser principal, @PathVariable String id) {
return leaseService.delete(landlordId(principal), id);
}
@GetMapping("/{id}/pdf")
public ResponseEntity<byte[]> downloadPdf(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) {
return leaseService.downloadPdf(id, principal.getSubject());
return leaseService.downloadPdf(landlordId(principal), id, principal.getSubject());
}
private String landlordId(OidcUser principal) {
return landlordProfileService.findOrCreate(principal).getId();
}
}
@@ -56,7 +56,8 @@ public class TenantController {
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTenant(@AuthenticationPrincipal OidcUser principal, @PathVariable String id) {
public ResponseEntity<Void> deleteTenant(@AuthenticationPrincipal OidcUser principal, @PathVariable String id)
throws IOException {
return tenantService.delete(landlordId(principal), id) ? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
@@ -0,0 +1,17 @@
package fr.nessar.lmnporganizer.backend.exception;
public class ReferencedByLeaseException extends RuntimeException {
private ReferencedByLeaseException(String message) {
super(message);
}
public static ReferencedByLeaseException apartment() {
return new ReferencedByLeaseException("Cet appartement ne peut pas être supprimé car il est référencé par un bail. "
+ "Conservez-le pour préserver l'historique du bail.");
}
public static ReferencedByLeaseException tenant() {
return new ReferencedByLeaseException("Ce locataire ne peut pas être supprimé car il est référencé par un bail. "
+ "Conservez-le pour préserver l'historique du bail.");
}
}
@@ -1,7 +0,0 @@
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.");
}
}
@@ -1,15 +1,25 @@
package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface LeaseRepository extends MongoRepository<Lease, String> {
List<Lease> findAllByStatus(LeaseStatus status);
List<Lease> findAllByApartmentId(String apartmentId);
List<Lease> findAllByApartmentIdAndIdNot(String apartmentId, String id);
List<Lease> findByLandlordId(String landlordId);
Optional<Lease> findByIdAndLandlordId(String id, String landlordId);
long deleteByIdAndLandlordId(String id, String landlordId);
List<Lease> findAllByLandlordIdAndApartmentId(String landlordId, String apartmentId);
List<Lease> findAllByLandlordIdAndApartmentIdAndIdNot(String landlordId, String apartmentId, String id);
boolean existsByLandlordIdAndApartmentId(String landlordId, String apartmentId);
boolean existsByLandlordIdAndTenantIdsContaining(String landlordId, String tenantId);
}
@@ -13,5 +13,7 @@ public interface TenantRepository extends MongoRepository<Tenant, String> {
Optional<Tenant> findByIdAndLandlordId(String id, String landlordId);
List<Tenant> findAllByIdInAndLandlordId(List<String> ids, String landlordId);
long deleteByIdAndLandlordId(String id, String landlordId);
}
@@ -2,9 +2,11 @@ package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import fr.nessar.lmnporganizer.backend.mapper.ApartmentMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -13,11 +15,16 @@ import java.util.Optional;
@Service
public class ApartmentService {
private final ApartmentRepository apartmentRepository;
private final LeaseRepository leaseRepository;
private final ApartmentMapper apartmentMapper;
private final RentalIntegrityLock rentalIntegrityLock;
public ApartmentService(ApartmentRepository apartmentRepository, ApartmentMapper apartmentMapper) {
public ApartmentService(ApartmentRepository apartmentRepository, LeaseRepository leaseRepository,
ApartmentMapper apartmentMapper, RentalIntegrityLock rentalIntegrityLock) {
this.apartmentRepository = apartmentRepository;
this.leaseRepository = leaseRepository;
this.apartmentMapper = apartmentMapper;
this.rentalIntegrityLock = rentalIntegrityLock;
}
public List<ApartmentResponse> getAll(String landlordId) {
@@ -36,6 +43,10 @@ public class ApartmentService {
}
public Optional<ApartmentResponse> update(String landlordId, String id, ApartmentRequest request) {
return rentalIntegrityLock.execute(() -> updateApartment(landlordId, id, request));
}
private Optional<ApartmentResponse> updateApartment(String landlordId, String id, ApartmentRequest request) {
return apartmentRepository.findByIdAndBailleurId(id, landlordId).map(apartment -> {
apartmentMapper.update(trim(request), apartment);
return apartmentMapper.toResponse(apartmentRepository.save(apartment));
@@ -43,7 +54,15 @@ public class ApartmentService {
}
public boolean delete(String landlordId, String id) {
return apartmentRepository.deleteByIdAndBailleurId(id, landlordId) == 1;
return rentalIntegrityLock.execute(() -> {
if (apartmentRepository.findByIdAndBailleurId(id, landlordId).isEmpty()) {
return false;
}
if (leaseRepository.existsByLandlordIdAndApartmentId(landlordId, id)) {
throw ReferencedByLeaseException.apartment();
}
return apartmentRepository.deleteByIdAndBailleurId(id, landlordId) == 1;
});
}
private ApartmentRequest trim(ApartmentRequest request) {
@@ -17,7 +17,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -31,11 +30,13 @@ public class LeaseService {
private final LeaseMapper leaseMapper;
private final RentPaymentService rentPaymentService;
private final LandlordProfileService landlordProfileService;
private final RentalIntegrityLock rentalIntegrityLock;
public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper,
RentPaymentService rentPaymentService, LandlordProfileService landlordProfileService) {
RentPaymentService rentPaymentService, LandlordProfileService landlordProfileService,
RentalIntegrityLock rentalIntegrityLock) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository;
@@ -44,64 +45,83 @@ public class LeaseService {
this.leaseMapper = leaseMapper;
this.rentPaymentService = rentPaymentService;
this.landlordProfileService = landlordProfileService;
this.rentalIntegrityLock = rentalIntegrityLock;
}
public List<LeaseResponse> getAll() {
return leaseMapper.toResponses(leaseRepository.findAll());
public List<LeaseResponse> getAll(String landlordId) {
return leaseMapper.toResponses(leaseRepository.findByLandlordId(landlordId));
}
public ResponseEntity<LeaseResponse> getById(String id) {
return leaseRepository.findById(id).map(leaseMapper::toResponse).map(ResponseEntity::ok)
public ResponseEntity<LeaseResponse> getById(String landlordId, String id) {
return leaseRepository.findByIdAndLandlordId(id, landlordId)
.map(leaseMapper::toResponse)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<LeaseResponse> create(LeaseRequest request) {
public ResponseEntity<LeaseResponse> create(String landlordId, LeaseRequest request) {
return rentalIntegrityLock.execute(() -> createLease(landlordId, request));
}
private ResponseEntity<LeaseResponse> createLease(String landlordId, LeaseRequest request) {
Lease lease = leaseMapper.toEntity(request);
if (!referencesExist(lease)) {
if (!referencesExist(landlordId, lease)) {
return ResponseEntity.badRequest().build();
}
if (hasOverlappingLease(lease, null)) {
if (hasOverlappingLease(landlordId, lease, null)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
lease.setLandlordId("default-landlord");
lease.setLandlordId(landlordId);
normalizeGuarantorPhones(lease);
lease = leaseRepository.save(lease);
rentPaymentService.generateForLease(lease);
return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(lease));
}
public ResponseEntity<LeaseResponse> update(String id, LeaseRequest request) {
Lease details = leaseMapper.toEntity(request);
if (!referencesExist(details)) {
return ResponseEntity.badRequest().build();
}
if (hasOverlappingLease(details, id)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
return leaseRepository.findById(id).map(lease -> {
leaseMapper.update(request, lease);
lease.setLandlordId("default-landlord");
normalizeGuarantorPhones(lease);
return ResponseEntity.ok(leaseMapper.toResponse(leaseRepository.save(lease)));
}).orElse(ResponseEntity.notFound().build());
public ResponseEntity<LeaseResponse> update(String landlordId, String id, LeaseRequest request) {
return rentalIntegrityLock.execute(() -> updateLease(landlordId, id, request));
}
public ResponseEntity<Void> delete(String id) {
if (!leaseRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
rentPaymentService.deleteForLease(id);
leaseRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
public ResponseEntity<byte[]> downloadPdf(String id, String oidcSubject) {
Lease lease = leaseRepository.findById(id).orElse(null);
private ResponseEntity<LeaseResponse> updateLease(String landlordId, String id, LeaseRequest request) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null);
List<Tenant> tenants = tenantsFor(lease.getTenantIds());
Lease details = leaseMapper.toEntity(request);
if (!referencesExist(landlordId, details)) {
return ResponseEntity.badRequest().build();
}
if (hasOverlappingLease(landlordId, details, id)) {
return ResponseEntity.status(HttpStatus.CONFLICT).build();
}
leaseMapper.update(request, lease);
lease.setLandlordId(landlordId);
normalizeGuarantorPhones(lease);
return ResponseEntity.ok(leaseMapper.toResponse(leaseRepository.save(lease)));
}
public ResponseEntity<Void> delete(String landlordId, String id) {
return rentalIntegrityLock.execute(() -> deleteLease(landlordId, id));
}
private ResponseEntity<Void> deleteLease(String landlordId, String id) {
if (leaseRepository.findByIdAndLandlordId(id, landlordId).isEmpty()) {
return ResponseEntity.notFound().build();
}
rentPaymentService.deleteForLease(id);
return leaseRepository.deleteByIdAndLandlordId(id, landlordId) == 1
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
public ResponseEntity<byte[]> downloadPdf(String landlordId, String id, String oidcSubject) {
Lease lease = leaseRepository.findByIdAndLandlordId(id, landlordId).orElse(null);
if (lease == null) {
return ResponseEntity.notFound().build();
}
Apartment apartment = apartmentRepository.findByIdAndBailleurId(lease.getApartmentId(), landlordId)
.orElse(null);
List<Tenant> tenants = tenantsFor(landlordId, lease.getTenantIds());
if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
return ResponseEntity.notFound().build();
}
@@ -113,34 +133,36 @@ public class LeaseService {
.body(leasePdfService.generate(lease, apartment, landlord, tenants));
}
private boolean referencesExist(Lease lease) {
if (lease.getApartmentId() == null || !apartmentRepository.existsById(lease.getApartmentId())
private boolean referencesExist(String landlordId, Lease lease) {
if (lease.getApartmentId() == null
|| apartmentRepository.findByIdAndBailleurId(lease.getApartmentId(), landlordId).isEmpty()
|| lease.getTenantIds().isEmpty()) {
return false;
}
return tenantsFor(lease.getTenantIds()).size() == new HashSet<>(lease.getTenantIds()).size();
return tenantsFor(landlordId, lease.getTenantIds()).size() == new HashSet<>(lease.getTenantIds()).size();
}
private List<Tenant> tenantsFor(List<String> ids) {
List<Tenant> tenants = new ArrayList<>();
tenantRepository.findAllById(ids).forEach(tenants::add);
return tenants;
private List<Tenant> tenantsFor(String landlordId, List<String> ids) {
return tenantRepository.findAllByIdInAndLandlordId(ids, landlordId);
}
private void normalizeGuarantorPhones(Lease lease) {
lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
lease.getGuarantors().forEach(guarantor ->
guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
}
private boolean hasOverlappingLease(Lease newLease, String excludeId) {
if (newLease.getStartDate() == null || newLease.getEndDate() == null) return false;
List<Lease> existingLeases = excludeId == null
? leaseRepository.findAllByApartmentId(newLease.getApartmentId())
: leaseRepository.findAllByApartmentIdAndIdNot(newLease.getApartmentId(), excludeId);
return existingLeases.stream().anyMatch(existing ->
existing.getStartDate() != null && existing.getEndDate() != null &&
!newLease.getStartDate().isAfter(existing.getEndDate()) &&
!newLease.getEndDate().isBefore(existing.getStartDate())
);
private boolean hasOverlappingLease(String landlordId, Lease newLease, String excludeId) {
if (newLease.getStartDate() == null || newLease.getEndDate() == null) {
return false;
}
List<Lease> existingLeases = excludeId == null
? leaseRepository.findAllByLandlordIdAndApartmentId(landlordId, newLease.getApartmentId())
: leaseRepository.findAllByLandlordIdAndApartmentIdAndIdNot(landlordId,
newLease.getApartmentId(), excludeId);
return existingLeases.stream().anyMatch(existing ->
existing.getStartDate() != null && existing.getEndDate() != null
&& !newLease.getStartDate().isAfter(existing.getEndDate())
&& !newLease.getEndDate().isBefore(existing.getStartDate()));
}
}
@@ -0,0 +1,17 @@
package fr.nessar.lmnporganizer.backend.service;
import org.springframework.stereotype.Component;
@Component
public class RentalIntegrityLock {
// ponytail: single-instance consistency boundary; use Mongo transactions or distributed coordination
// before horizontal scaling.
public synchronized <T, E extends Exception> T execute(CheckedOperation<T, E> operation) throws E {
return operation.run();
}
@FunctionalInterface
public interface CheckedOperation<T, E extends Exception> {
T run() throws E;
}
}
@@ -6,6 +6,8 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.nio.file.Files;
@@ -17,6 +19,8 @@ import java.util.UUID;
@Service
public class TenantAttachmentStorage {
private static final String STAGING_DIRECTORY = ".staging";
private static final String COMMITTED_SUFFIX = ".committed";
private static final Logger LOGGER = LoggerFactory.getLogger(TenantAttachmentStorage.class);
private final Path root;
private final Path stagingRoot;
@@ -24,9 +28,11 @@ public class TenantAttachmentStorage {
public TenantAttachmentStorage(@Value("${lmnp.storage.attachments}") String root) {
this.root = Path.of(root).toAbsolutePath().normalize();
this.stagingRoot = this.root.resolve(STAGING_DIRECTORY);
sweepCommittedTombstones();
}
public TenantAttachment store(String tenantId, MultipartFile file) throws IOException {
sweepCommittedTombstones();
String attachmentId = UUID.randomUUID().toString();
String suppliedName = file.getOriginalFilename();
String originalName = suppliedName == null || suppliedName.isBlank()
@@ -65,6 +71,7 @@ public class TenantAttachmentStorage {
StagedDeletion deletion = stageDeletion(attachment);
try {
finalizeDeletion(deletion);
markCommitted(deletion);
} catch (IOException | RuntimeException failure) {
try {
restore(deletion);
@@ -73,28 +80,109 @@ public class TenantAttachmentStorage {
}
throw failure;
}
discardCommittedBestEffort(deletion);
}
StagedDeletion stageDeletion(TenantAttachment attachment) throws IOException {
sweepCommittedTombstones();
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());
String stagingId = UUID.randomUUID().toString();
Path stagedPath = stagingRoot.resolve(stagingId + ".staged");
Path backupPath = stagingRoot.resolve(stagingId + ".backup");
Path committedPath = stagingRoot.resolve(stagingId + COMMITTED_SUFFIX);
Files.move(activePath, stagedPath, StandardCopyOption.ATOMIC_MOVE);
return new StagedDeletion(activePath, stagedPath);
try {
createBackup(stagedPath, backupPath);
} catch (IOException | RuntimeException failure) {
try {
Files.deleteIfExists(backupPath);
Files.move(stagedPath, activePath, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException | RuntimeException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
throw failure;
}
return new StagedDeletion(activePath, stagedPath, backupPath, committedPath);
}
void restore(StagedDeletion deletion) throws IOException {
Path source = deletion.stagedPath();
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
source = deletion.backupPath();
}
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
source = deletion.committedPath();
}
if (!Files.isRegularFile(source, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Tenant attachment backup not found");
}
Files.createDirectories(deletion.activePath().getParent());
Files.move(deletion.stagedPath(), deletion.activePath(), StandardCopyOption.ATOMIC_MOVE);
Files.move(source, deletion.activePath(), StandardCopyOption.ATOMIC_MOVE);
Files.deleteIfExists(deletion.stagedPath());
Files.deleteIfExists(deletion.backupPath());
Files.deleteIfExists(deletion.committedPath());
}
void finalizeDeletion(StagedDeletion deletion) throws IOException {
Files.deleteIfExists(deletion.stagedPath());
}
void markCommitted(StagedDeletion deletion) throws IOException {
Files.move(deletion.backupPath(), deletion.committedPath(), StandardCopyOption.ATOMIC_MOVE);
}
void discardCommitted(StagedDeletion deletion) throws IOException {
Files.deleteIfExists(deletion.committedPath());
}
void discardBackup(StagedDeletion deletion) throws IOException {
Files.deleteIfExists(deletion.backupPath());
}
private void discardCommittedBestEffort(StagedDeletion deletion) {
try {
discardCommitted(deletion);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred", failure);
}
}
private void sweepCommittedTombstones() {
if (!Files.isDirectory(stagingRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (var paths = Files.list(stagingRoot)) {
for (Path path : paths.filter(candidate -> candidate.getFileName().toString().endsWith(COMMITTED_SUFFIX))
.toList()) {
try {
Files.deleteIfExists(path);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred: {}", path.getFileName(),
failure);
}
}
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstones could not be scanned", failure);
}
}
private void createBackup(Path stagedPath, Path backupPath) throws IOException {
try {
Files.createLink(backupPath, stagedPath);
} catch (IOException | UnsupportedOperationException linkFailure) {
try {
Files.copy(stagedPath, backupPath);
} catch (IOException | RuntimeException copyFailure) {
copyFailure.addSuppressed(linkFailure);
throw copyFailure;
}
}
}
private Path activePath(TenantAttachment attachment) throws IOException {
Path path = root.resolve(attachment.getStoragePath()).normalize();
if (path.equals(root) || !path.startsWith(root) || path.startsWith(stagingRoot)) {
@@ -103,6 +191,6 @@ public class TenantAttachmentStorage {
return path;
}
record StagedDeletion(Path activePath, Path stagedPath) {
record StagedDeletion(Path activePath, Path stagedPath, Path backupPath, Path committedPath) {
}
}
@@ -3,35 +3,46 @@ 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.exception.ReferencedByLeaseException;
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.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class TenantService {
private static final Logger LOGGER = LoggerFactory.getLogger(TenantService.class);
private final TenantRepository tenantRepository;
private final LeaseRepository leaseRepository;
private final TenantAttachmentStorage attachmentStorage;
private final AttachmentUploadPolicy attachmentUploadPolicy;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final TenantMapper tenantMapper;
private final RentalIntegrityLock rentalIntegrityLock;
public TenantService(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage,
public TenantService(TenantRepository tenantRepository, LeaseRepository leaseRepository,
TenantAttachmentStorage attachmentStorage,
AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer,
TenantMapper tenantMapper) {
TenantMapper tenantMapper, RentalIntegrityLock rentalIntegrityLock) {
this.tenantRepository = tenantRepository;
this.leaseRepository = leaseRepository;
this.attachmentStorage = attachmentStorage;
this.attachmentUploadPolicy = attachmentUploadPolicy;
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.tenantMapper = tenantMapper;
this.rentalIntegrityLock = rentalIntegrityLock;
}
public List<TenantResponse> getAll(String landlordId) {
@@ -50,6 +61,10 @@ public class TenantService {
}
public Optional<TenantResponse> update(String landlordId, String id, TenantRequest request) {
return rentalIntegrityLock.execute(() -> updateTenant(landlordId, id, request));
}
private Optional<TenantResponse> updateTenant(String landlordId, String id, TenantRequest request) {
return tenantRepository.findByIdAndLandlordId(id, landlordId).map(tenant -> {
TenantRequest normalized = normalize(request);
tenantMapper.update(normalized, tenant);
@@ -58,19 +73,50 @@ public class TenantService {
});
}
public boolean delete(String landlordId, String id) {
public boolean delete(String landlordId, String id) throws IOException {
return rentalIntegrityLock.execute(() -> deletePermanently(landlordId, id));
}
private boolean deletePermanently(String landlordId, String id) throws IOException {
Optional<Tenant> tenant = tenantRepository.findByIdAndLandlordId(id, landlordId);
if (tenant.isEmpty()) {
return false;
}
if (tenant.get().hasAttachments()) {
throw new TenantHasAttachmentsException();
if (leaseRepository.existsByLandlordIdAndTenantIdsContaining(landlordId, id)) {
throw ReferencedByLeaseException.tenant();
}
return tenantRepository.deleteByIdAndLandlordId(id, landlordId) == 1;
List<TenantAttachmentStorage.StagedDeletion> staged = stage(tenant.get().getAttachments());
long deleted;
try {
deleted = tenantRepository.deleteByIdAndLandlordId(id, landlordId);
} catch (RuntimeException failure) {
restore(staged, failure);
throw failure;
}
if (deleted != 1) {
finalizeDeletions(staged);
discardAlreadyDeletedAttachments(staged);
return false;
}
try {
finalizeDeletions(staged);
promoteDeletions(staged);
} catch (IOException | RuntimeException failure) {
compensateDeletion(tenant.get(), staged, failure);
throw failure;
}
discardCommittedDeletions(staged);
return true;
}
public Optional<TenantAttachmentResponse> uploadAttachment(String landlordId, String tenantId,
MultipartFile file) throws IOException {
return rentalIntegrityLock.execute(() -> uploadTenantAttachment(landlordId, tenantId, file));
}
private Optional<TenantAttachmentResponse> uploadTenantAttachment(String landlordId, String tenantId,
MultipartFile file) throws IOException {
Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null);
if (tenant == null) {
return Optional.empty();
@@ -84,8 +130,7 @@ public class TenantService {
} catch (RuntimeException failure) {
tenant.removeAttachment(attachment.getId());
try {
TenantAttachmentStorage.StagedDeletion deletion = attachmentStorage.stageDeletion(attachment);
attachmentStorage.finalizeDeletion(deletion);
attachmentStorage.delete(attachment);
} catch (IOException | RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
@@ -109,6 +154,11 @@ public class TenantService {
}
public boolean deleteAttachment(String landlordId, String tenantId, String attachmentId) throws IOException {
return rentalIntegrityLock.execute(() -> deleteTenantAttachment(landlordId, tenantId, attachmentId));
}
private boolean deleteTenantAttachment(String landlordId, String tenantId, String attachmentId)
throws IOException {
Tenant tenant = tenantRepository.findByIdAndLandlordId(tenantId, landlordId).orElse(null);
if (tenant == null) {
return false;
@@ -130,7 +180,14 @@ public class TenantService {
}
throw failure;
}
attachmentStorage.finalizeDeletion(deletion);
try {
attachmentStorage.finalizeDeletion(deletion);
attachmentStorage.markCommitted(deletion);
} catch (IOException | RuntimeException failure) {
compensateAttachmentDeletion(tenant, attachment.get(), deletion, failure);
throw failure;
}
discardCommittedDeletions(List.of(deletion));
return true;
}
@@ -143,6 +200,111 @@ public class TenantService {
return value == null ? "" : value.trim();
}
private List<TenantAttachmentStorage.StagedDeletion> stage(List<TenantAttachment> attachments) throws IOException {
List<TenantAttachmentStorage.StagedDeletion> staged = new ArrayList<>();
try {
for (TenantAttachment attachment : attachments) {
staged.add(attachmentStorage.stageDeletion(attachment));
}
return staged;
} catch (IOException | RuntimeException failure) {
restore(staged, failure);
throw failure;
}
}
private void restore(List<TenantAttachmentStorage.StagedDeletion> staged, Throwable original) throws IOException {
IOException restoreFailure = null;
for (int index = staged.size() - 1; index >= 0; index--) {
try {
attachmentStorage.restore(staged.get(index));
} catch (IOException | RuntimeException failure) {
if (original != null) {
original.addSuppressed(failure);
} else if (restoreFailure == null) {
restoreFailure = failure instanceof IOException ioFailure
? ioFailure
: new IOException("Impossible de restaurer une pièce jointe.", failure);
} else {
restoreFailure.addSuppressed(failure);
}
}
}
if (restoreFailure != null) {
throw restoreFailure;
}
}
private void finalizeDeletions(List<TenantAttachmentStorage.StagedDeletion> staged) throws IOException {
for (TenantAttachmentStorage.StagedDeletion deletion : staged) {
attachmentStorage.finalizeDeletion(deletion);
}
}
private void promoteDeletions(List<TenantAttachmentStorage.StagedDeletion> staged) throws IOException {
for (TenantAttachmentStorage.StagedDeletion deletion : staged) {
attachmentStorage.markCommitted(deletion);
}
}
private void discardCommittedDeletions(List<TenantAttachmentStorage.StagedDeletion> committed) {
for (TenantAttachmentStorage.StagedDeletion deletion : committed) {
try {
attachmentStorage.discardCommitted(deletion);
} catch (IOException | RuntimeException failure) {
LOGGER.warn("Committed tenant attachment tombstone cleanup deferred", failure);
}
}
}
private void discardAlreadyDeletedAttachments(List<TenantAttachmentStorage.StagedDeletion> staged) {
for (TenantAttachmentStorage.StagedDeletion deletion : staged) {
try {
attachmentStorage.markCommitted(deletion);
attachmentStorage.discardCommitted(deletion);
} catch (IOException | RuntimeException promotionFailure) {
LOGGER.warn("Already-deleted tenant attachment could not be promoted to a tombstone",
promotionFailure);
try {
attachmentStorage.discardBackup(deletion);
} catch (IOException | RuntimeException cleanupFailure) {
LOGGER.warn("Already-deleted tenant attachment cleanup deferred", cleanupFailure);
}
}
}
}
private void compensateDeletion(Tenant tenant, List<TenantAttachmentStorage.StagedDeletion> staged,
Throwable original) {
try {
tenantRepository.save(tenant);
} catch (RuntimeException failure) {
original.addSuppressed(failure);
return;
}
try {
restore(staged, original);
} catch (IOException failure) {
original.addSuppressed(failure);
}
}
private void compensateAttachmentDeletion(Tenant tenant, TenantAttachment attachment,
TenantAttachmentStorage.StagedDeletion deletion, Throwable original) {
tenant.addAttachment(attachment);
try {
tenantRepository.save(tenant);
} catch (RuntimeException failure) {
original.addSuppressed(failure);
return;
}
try {
attachmentStorage.restore(deletion);
} catch (IOException | RuntimeException failure) {
original.addSuppressed(failure);
}
}
public record AttachmentDownload(Resource resource, String fileName, String contentType) {
}
}
@@ -4,6 +4,7 @@ import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -37,6 +38,9 @@ class ApartmentControllerTest {
@MockitoBean
private LandlordRepository landlordRepository;
@MockitoBean
private LeaseRepository leaseRepository;
@BeforeEach
void resolveAuthenticatedLandlord() {
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(
@@ -126,7 +130,7 @@ class ApartmentControllerTest {
@Test
void whenDeleteApartmentNotFound_thenNotFound() throws Exception {
when(apartmentRepository.deleteByIdAndBailleurId("999", "landlord-1")).thenReturn(0L);
when(apartmentRepository.findByIdAndBailleurId("999", "landlord-1")).thenReturn(Optional.empty());
mockMvc.perform(delete("/api/apartments/999").with(asLandlord()))
.andExpect(status().isNotFound());
@@ -134,12 +138,29 @@ class ApartmentControllerTest {
@Test
void whenDeleteApartmentFound_thenNoContent() throws Exception {
Apartment apartment = new Apartment("1", "landlord-1", "Appartement 1", "Adresse 1", 150000.0, "Desc");
when(apartmentRepository.findByIdAndBailleurId("1", "landlord-1")).thenReturn(Optional.of(apartment));
when(apartmentRepository.deleteByIdAndBailleurId("1", "landlord-1")).thenReturn(1L);
mockMvc.perform(delete("/api/apartments/1").with(asLandlord()))
.andExpect(status().isNoContent());
}
verify(apartmentRepository).deleteByIdAndBailleurId("1", "landlord-1");
@Test
void whenDeleteReferencedApartment_thenConflictAndApartmentRemainsAvailable() throws Exception {
Apartment apartment = new Apartment("1", "landlord-1", "Appartement 1", "Adresse 1", 150000.0, "Desc");
when(apartmentRepository.findByIdAndBailleurId("1", "landlord-1")).thenReturn(Optional.of(apartment));
when(leaseRepository.existsByLandlordIdAndApartmentId("landlord-1", "1")).thenReturn(true);
mockMvc.perform(delete("/api/apartments/1").with(asLandlord()))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message").value(
"Cet appartement ne peut pas être supprimé car il est référencé par un bail. "
+ "Conservez-le pour préserver l'historique du bail."));
mockMvc.perform(get("/api/apartments/1").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("1"));
}
private static RequestPostProcessor asLandlord() {
@@ -14,6 +14,7 @@ import fr.nessar.lmnporganizer.backend.service.RentPaymentService;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
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;
@@ -55,10 +56,17 @@ class LeaseControllerTest {
@MockitoBean
private RentPaymentService rentPaymentService;
@BeforeEach
void authenticateLandlord() {
when(landlordRepository.findByOidcSubject(anyString())).thenReturn(Optional.of(landlord()));
}
@Test
void whenCreateLease_thenLinksApartmentTenantsAndGuarantors() throws Exception {
when(apartmentRepository.existsById("apartment-1")).thenReturn(true);
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
when(apartmentRepository.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.of(apartment()));
when(tenantRepository.findAllByIdInAndLandlordId(List.of("tenant-1"), "landlord-1"))
.thenReturn(List.of(tenant()));
when(leaseRepository.save(any(Lease.class))).thenAnswer(invocation -> {
Lease lease = invocation.getArgument(0);
lease.setId("lease-1");
@@ -90,7 +98,8 @@ class LeaseControllerTest {
@Test
void whenLeaseReferencesUnknownApartment_thenBadRequest() throws Exception {
when(apartmentRepository.existsById("apartment-1")).thenReturn(false);
when(apartmentRepository.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.empty());
mockMvc.perform(post("/api/leases")
.with(oidcLogin())
@@ -107,7 +116,7 @@ class LeaseControllerTest {
@Test
void whenGetLeases_thenReturnsPortfolioLeases() throws Exception {
when(leaseRepository.findAll()).thenReturn(List.of(lease()));
when(leaseRepository.findByLandlordId("landlord-1")).thenReturn(List.of(lease()));
mockMvc.perform(get("/api/leases").with(oidcLogin()))
.andExpect(status().isOk())
@@ -116,9 +125,11 @@ class LeaseControllerTest {
@Test
void whenUpdateLease_thenTermsAreUpdated() throws Exception {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.existsById("apartment-1")).thenReturn(true);
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
when(leaseRepository.findByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.of(apartment()));
when(tenantRepository.findAllByIdInAndLandlordId(List.of("tenant-1"), "landlord-1"))
.thenReturn(List.of(tenant()));
when(leaseRepository.save(any(Lease.class))).thenAnswer(invocation -> invocation.getArgument(0));
mockMvc.perform(put("/api/leases/lease-1")
@@ -131,23 +142,25 @@ class LeaseControllerTest {
@Test
void whenDeleteLease_thenNoContent() throws Exception {
when(leaseRepository.existsById("lease-1")).thenReturn(true);
when(leaseRepository.findByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(Optional.of(lease()));
when(leaseRepository.deleteByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(1L);
mockMvc.perform(delete("/api/leases/lease-1").with(oidcLogin()))
.andExpect(status().isNoContent());
verify(rentPaymentService).deleteForLease("lease-1");
verify(leaseRepository).deleteById("lease-1");
verify(leaseRepository).deleteByIdAndLandlordId("lease-1", "landlord-1");
}
@Test
void whenDownloadLeasePdf_thenReturnsPdfAttachment() throws Exception {
Lease lease = lease();
Apartment apartment = new Apartment("apartment-1", "default-landlord", "Studio République",
"12 rue de la République", 150000, "");
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
Apartment apartment = apartment();
when(leaseRepository.findByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(Optional.of(lease));
when(apartmentRepository.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.of(apartment));
when(tenantRepository.findAllByIdInAndLandlordId(List.of("tenant-1"), "landlord-1"))
.thenReturn(List.of(tenant()));
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(landlord()));
mockMvc.perform(get("/api/leases/lease-1/pdf")
@@ -175,11 +188,12 @@ class LeaseControllerTest {
@Test
void whenLandlordProfileIsIncomplete_thenLeasePdfDownloadIsRejectedWithSettingsGuidance() throws Exception {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(new Apartment("apartment-1",
"default-landlord", "Studio République", "12 rue de la République", 150000, "")));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(new Landlord("profile-1",
when(leaseRepository.findByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.of(apartment()));
when(tenantRepository.findAllByIdInAndLandlordId(List.of("tenant-1"), "landlord-1"))
.thenReturn(List.of(tenant()));
when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(new Landlord("landlord-1",
"subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678", "")));
mockMvc.perform(get("/api/leases/lease-1/pdf")
@@ -191,14 +205,17 @@ class LeaseControllerTest {
@Test
void whenLandlordProfileChanges_thenTheNextLeasePdfUsesItsUpdatedContactDetails() throws Exception {
when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(new Apartment("apartment-1",
"default-landlord", "Studio République", "12 rue de la République", 150000, "")));
when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant()));
Landlord updatedLandlord = new Landlord("profile-1", "subject-1", "Alice", "Renard",
when(leaseRepository.findByIdAndLandlordId("lease-1", "landlord-1")).thenReturn(Optional.of(lease()));
when(apartmentRepository.findByIdAndBailleurId("apartment-1", "landlord-1"))
.thenReturn(Optional.of(apartment()));
when(tenantRepository.findAllByIdInAndLandlordId(List.of("tenant-1"), "landlord-1"))
.thenReturn(List.of(tenant()));
Landlord updatedLandlord = new Landlord("landlord-1", "subject-1", "Alice", "Renard",
"alice@example.com", "+33698765432", "2 rue de Lyon");
when(landlordRepository.findByOidcSubject("subject-1"))
.thenReturn(Optional.of(landlord()))
.thenReturn(Optional.of(landlord()))
.thenReturn(Optional.of(updatedLandlord))
.thenReturn(Optional.of(updatedLandlord));
String firstPdfText = downloadedLeasePdfText();
@@ -212,15 +229,20 @@ class LeaseControllerTest {
}
private Tenant tenant() {
return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com",
return new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com",
"+33612345678", "10 rue des Lilas");
}
private Landlord landlord() {
return new Landlord("profile-1", "subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678",
return new Landlord("landlord-1", "subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678",
"1 rue de Paris");
}
private Apartment apartment() {
return new Apartment("apartment-1", "landlord-1", "Studio République",
"12 rue de la République", 150000, "");
}
private String downloadedLeasePdfText() throws Exception {
byte[] content = mockMvc.perform(get("/api/leases/lease-1/pdf")
.with(oidcLogin().idToken(token -> token.claim("sub", "subject-1"))))
@@ -234,7 +256,7 @@ class LeaseControllerTest {
}
private Lease lease() {
return new Lease("lease-1", "default-landlord", "apartment-1", List.of("tenant-1"),
return new Lease("lease-1", "landlord-1", "apartment-1", List.of("tenant-1"),
List.of(new Guarantor("guarantor-1", "Alex", "Durand", "alex@example.com", "+33600000000",
"5 rue Victor Hugo")), LocalDate.of(2026, 9, 1), LocalDate.of(2027, 8, 31),
new BigDecimal("850.00"), new BigDecimal("50.00"), new BigDecimal("850.00"), LeaseStatus.ACTIVE);
@@ -64,7 +64,6 @@ class RentPaymentControllerTest {
@Test
void whenRentScheduleIsRequested_thenReturnsPaymentsInChronologicalOrder() throws Exception {
when(leaseRepository.findAllByStatus(LeaseStatus.ACTIVE)).thenReturn(List.of());
when(rentPaymentRepository.findAllByOrderByDueDateAsc()).thenReturn(List.of(
payment("payment-1", "2026-06", LocalDate.of(2026, 6, 1), null),
payment("payment-2", "2026-07", LocalDate.of(2026, 7, 1), null)
@@ -4,6 +4,7 @@ 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.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage;
import org.junit.jupiter.api.BeforeEach;
@@ -44,6 +45,9 @@ class TenantControllerTest {
@MockitoBean
private LandlordRepository landlordRepository;
@MockitoBean
private LeaseRepository leaseRepository;
@MockitoBean
private TenantAttachmentStorage attachmentStorage;
@@ -180,17 +184,34 @@ class TenantControllerTest {
}
@Test
void whenDeleteTenantWithAttachments_thenConflictExplainsRequiredAction() throws Exception {
void whenDeleteUnreferencedTenantWithAttachments_thenNoContent() 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));
when(tenantRepository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L);
mockMvc.perform(delete("/api/tenants/tenant-1").with(asLandlord()))
.andExpect(status().isNoContent());
}
@Test
void whenDeleteReferencedTenant_thenConflictAndTenantRemainsAvailable() throws Exception {
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(leaseRepository.existsByLandlordIdAndTenantIdsContaining("landlord-1", "tenant-1")).thenReturn(true);
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."));
.andExpect(jsonPath("$.message").value(
"Ce locataire ne peut pas être supprimé car il est référencé par un bail. "
+ "Conservez-le pour préserver l'historique du bail."));
mockMvc.perform(get("/api/tenants/tenant-1").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("tenant-1"));
}
@Test
@@ -0,0 +1,364 @@
package fr.nessar.lmnporganizer.backend.integration;
import fr.nessar.lmnporganizer.backend.BackendApplication;
import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
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.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
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.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.List;
import static com.jayway.jsonpath.JsonPath.read;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(classes = BackendApplication.class, properties = "lmnp.data-initializer.enabled=false")
@AutoConfigureMockMvc
class ReferencedRentalResourcesApiIT {
private static final MongoDBContainer mongoDb = startMongoDb();
private static final Path attachmentRoot = temporaryAttachmentRoot();
@Autowired
private MockMvc mockMvc;
@Autowired
private ApartmentRepository apartmentRepository;
@Autowired
private TenantRepository tenantRepository;
@Autowired
private LeaseRepository leaseRepository;
@Autowired
private LandlordRepository landlordRepository;
@DynamicPropertySource
static void configureInfrastructure(DynamicPropertyRegistry registry) {
if (mongoDb != null) {
registry.add("spring.mongodb.uri", () -> mongoDb.getConnectionString() + "/lmnp-reference-integrity");
}
registry.add("lmnp.storage.attachments", attachmentRoot::toString);
}
@BeforeEach
void setUpBailleurs() throws IOException {
leaseRepository.deleteAll();
apartmentRepository.deleteAll();
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() {
leaseRepository.deleteAll();
apartmentRepository.deleteAll();
tenantRepository.deleteAll();
landlordRepository.deleteAll();
}
@AfterAll
static void clearAttachments() throws IOException {
FileSystemUtils.deleteRecursively(attachmentRoot);
}
@ParameterizedTest
@EnumSource(LeaseStatus.class)
void referencedAppartementAndLocataireStayIntactWhenDeletionIsRejected(LeaseStatus status) throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String attachmentId = uploadAttachment(tenantId, as("subject-alice"));
createLease(apartmentId, tenantId, status, as("subject-alice"));
mockMvc.perform(delete("/api/apartments/{id}", apartmentId).with(as("subject-alice")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message", containsString("bail")));
mockMvc.perform(delete("/api/tenants/{id}", tenantId).with(as("subject-alice")))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.message", containsString("bail")));
mockMvc.perform(get("/api/apartments/{id}", apartmentId).with(as("subject-alice")))
.andExpect(status().isOk());
mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.attachments[0].id").value(attachmentId));
mockMvc.perform(get("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId)
.with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(content().bytes("lease-proof".getBytes()));
assertThat(storedFileCount()).isOne();
}
@Test
void anotherBailleurCannotDiscoverThatResourcesAreReferenced() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String attachmentId = uploadAttachment(tenantId, as("subject-alice"));
String leaseId = createLease(apartmentId, tenantId, LeaseStatus.ACTIVE, as("subject-alice"));
mockMvc.perform(get("/api/leases").with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(leaseId));
mockMvc.perform(get("/api/leases/{id}", leaseId).with(as("subject-alice")))
.andExpect(status().isOk());
mockMvc.perform(get("/api/leases").with(as("subject-bob")))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
mockMvc.perform(get("/api/leases/{id}", leaseId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(delete("/api/apartments/{id}", apartmentId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(delete("/api/tenants/{id}", tenantId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(get("/api/apartments/{id}", apartmentId).with(as("subject-alice")))
.andExpect(status().isOk());
mockMvc.perform(get("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId)
.with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(content().bytes("lease-proof".getBytes()));
}
@Test
void leaseCrudAndPdfAreScopedToAuthenticatedBailleur() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
String leaseId = createLease(apartmentId, tenantId, LeaseStatus.DRAFT, as("subject-alice"));
String updatedLease = leaseJson(apartmentId, tenantId, LeaseStatus.DRAFT)
.replace("\"monthlyRent\":900.00", "\"monthlyRent\":925.00");
mockMvc.perform(put("/api/leases/{id}", leaseId)
.with(as("subject-bob"))
.contentType(MediaType.APPLICATION_JSON)
.content(updatedLease))
.andExpect(status().isNotFound());
mockMvc.perform(delete("/api/leases/{id}", leaseId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(get("/api/leases/{id}/pdf", leaseId).with(as("subject-bob")))
.andExpect(status().isNotFound());
mockMvc.perform(get("/api/leases/{id}", leaseId).with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.monthlyRent").value(900.00));
mockMvc.perform(put("/api/leases/{id}", leaseId)
.with(as("subject-alice"))
.contentType(MediaType.APPLICATION_JSON)
.content(updatedLease))
.andExpect(status().isOk())
.andExpect(jsonPath("$.monthlyRent").value(925.00));
mockMvc.perform(get("/api/leases/{id}/pdf", leaseId).with(as("subject-alice")))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF));
mockMvc.perform(delete("/api/leases/{id}", leaseId).with(as("subject-alice")))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/leases/{id}", leaseId).with(as("subject-alice")))
.andExpect(status().isNotFound());
}
@Test
void leaseReferencesMustBelongToAuthenticatedBailleurWithoutDisclosingForeignIds() throws Exception {
String aliceApartmentId = createApartment(as("subject-alice"));
String aliceTenantId = createTenant(as("subject-alice"));
String bobApartmentId = createApartment(as("subject-bob"));
String bobTenantId = createTenant(as("subject-bob"));
String foreignApartmentCreate = rejectedLeaseCreate(aliceApartmentId, bobTenantId, as("subject-bob"));
String unknownApartmentCreate = rejectedLeaseCreate("unknown-apartment", bobTenantId, as("subject-bob"));
String foreignTenantCreate = rejectedLeaseCreate(bobApartmentId, aliceTenantId, as("subject-bob"));
String unknownTenantCreate = rejectedLeaseCreate(bobApartmentId, "unknown-tenant", as("subject-bob"));
assertThat(foreignApartmentCreate).isEqualTo(unknownApartmentCreate);
assertThat(foreignTenantCreate).isEqualTo(unknownTenantCreate);
String leaseId = createLease(bobApartmentId, bobTenantId, LeaseStatus.DRAFT, as("subject-bob"));
String foreignApartmentUpdate = rejectedLeaseUpdate(leaseId, aliceApartmentId, bobTenantId);
String unknownApartmentUpdate = rejectedLeaseUpdate(leaseId, "unknown-apartment", bobTenantId);
String foreignTenantUpdate = rejectedLeaseUpdate(leaseId, bobApartmentId, aliceTenantId);
String unknownTenantUpdate = rejectedLeaseUpdate(leaseId, bobApartmentId, "unknown-tenant");
assertThat(foreignApartmentUpdate).isEqualTo(unknownApartmentUpdate);
assertThat(foreignTenantUpdate).isEqualTo(unknownTenantUpdate);
mockMvc.perform(get("/api/leases/{id}", leaseId).with(as("subject-bob")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.apartmentId").value(bobApartmentId))
.andExpect(jsonPath("$.tenantIds[0]").value(bobTenantId));
}
@Test
void anotherBailleursLeaseDoesNotBlockDeletionAndLeavesNoAttachmentFiles() throws Exception {
String apartmentId = createApartment(as("subject-alice"));
String tenantId = createTenant(as("subject-alice"));
uploadAttachment(tenantId, as("subject-alice"));
saveInconsistentLease("landlord-bob", apartmentId, tenantId, LeaseStatus.ARCHIVED);
mockMvc.perform(delete("/api/apartments/{id}", apartmentId).with(as("subject-alice")))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/tenants/{id}", tenantId).with(as("subject-alice")))
.andExpect(status().isNoContent());
mockMvc.perform(get("/api/apartments/{id}", apartmentId).with(as("subject-alice")))
.andExpect(status().isNotFound());
mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice")))
.andExpect(status().isNotFound());
assertThat(storedFileCount()).isZero();
}
private String createApartment(RequestPostProcessor principal) throws Exception {
String response = mockMvc.perform(post("/api/apartments")
.with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Studio République","address":"12 rue de la République, Lyon","purchasePrice":150000,"description":"Deuxième étage"}
"""))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String createTenant(RequestPostProcessor principal) throws Exception {
String response = mockMvc.perform(post("/api/tenants")
.with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"firstName":"Camille","lastName":"Martin","email":"camille@example.com","phone":"0612345678","address":"10 rue des Lilas, Lyon"}
"""))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String uploadAttachment(String tenantId, RequestPostProcessor principal) throws Exception {
MockMultipartFile attachment = new MockMultipartFile(
"file", "bail.pdf", MediaType.APPLICATION_PDF_VALUE, "lease-proof".getBytes());
String response = mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId)
.file(attachment)
.with(principal))
.andExpect(status().isCreated())
.andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String createLease(String apartmentId, String tenantId, LeaseStatus status,
RequestPostProcessor principal) throws Exception {
String response = mockMvc.perform(post("/api/leases")
.with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantId, status)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value(status.name()))
.andReturn().getResponse().getContentAsString();
return read(response, "$.id");
}
private String rejectedLeaseCreate(String apartmentId, String tenantId, RequestPostProcessor principal)
throws Exception {
return mockMvc.perform(post("/api/leases")
.with(principal)
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantId, LeaseStatus.DRAFT)))
.andExpect(status().isBadRequest())
.andReturn().getResponse().getContentAsString();
}
private String rejectedLeaseUpdate(String leaseId, String apartmentId, String tenantId) throws Exception {
return mockMvc.perform(put("/api/leases/{id}", leaseId)
.with(as("subject-bob"))
.contentType(MediaType.APPLICATION_JSON)
.content(leaseJson(apartmentId, tenantId, LeaseStatus.DRAFT)))
.andExpect(status().isBadRequest())
.andReturn().getResponse().getContentAsString();
}
private String leaseJson(String apartmentId, String tenantId, LeaseStatus status) {
return """
{
"apartmentId":"%s",
"tenantIds":["%s"],
"guarantors":[],
"startDate":"2026-01-01",
"endDate":"2026-12-31",
"monthlyRent":900.00,
"monthlyCharges":50.00,
"securityDeposit":900.00,
"status":"%s"
}
""".formatted(apartmentId, tenantId, status);
}
private void saveInconsistentLease(String landlordId, String apartmentId, String tenantId, LeaseStatus status) {
leaseRepository.save(new Lease(null, landlordId, apartmentId, List.of(tenantId), List.of(),
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31), new BigDecimal("900.00"),
new BigDecimal("50.00"), new BigDecimal("900.00"), status));
}
private long storedFileCount() throws IOException {
try (var files = Files.walk(attachmentRoot)) {
return files.filter(Files::isRegularFile).count();
}
}
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-reference-integrity-it-");
} catch (IOException exception) {
throw new UncheckedIOException(exception);
}
}
}
@@ -0,0 +1,177 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
import fr.nessar.lmnporganizer.backend.mapper.ApartmentMapper;
import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
import fr.nessar.lmnporganizer.backend.mapper.TenantMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.model.Lease;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
class RentalIntegrityLockTest {
@TempDir
Path directory;
@Test
void creatingLeaseAndDeletingItsApartmentCannotBreakTheReference() throws Exception {
String landlordId = "landlord-1";
String apartmentId = "apartment-1";
ApartmentRepository apartmentRepository = mock(ApartmentRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantRepository tenantRepository = mock(TenantRepository.class);
LeaseMapper leaseMapper = mock(LeaseMapper.class);
RentPaymentService rentPaymentService = mock(RentPaymentService.class);
RentalIntegrityLock integrityLock = new RentalIntegrityLock();
Apartment apartment = new Apartment(apartmentId, landlordId, "Studio", "1 rue de Paris", 100_000, "");
Tenant tenant = new Tenant("tenant-1", landlordId, "Camille", "Martin", "camille@example.com", "", "");
Lease lease = new Lease();
lease.setApartmentId(apartmentId);
lease.setTenantIds(List.of(tenant.getId()));
LeaseRequest request = new LeaseRequest(apartmentId, List.of(tenant.getId()), List.of(),
null, null, null, null, null, null);
CountDownLatch leaseReferenceLookupEntered = new CountDownLatch(1);
CountDownLatch releaseLeaseReferenceLookup = new CountDownLatch(1);
CountDownLatch deletionStarted = new CountDownLatch(1);
AtomicInteger apartmentLookupCount = new AtomicInteger();
AtomicBoolean leaseSaved = new AtomicBoolean();
when(leaseMapper.toEntity(request)).thenReturn(lease);
when(apartmentRepository.findByIdAndBailleurId(apartmentId, landlordId)).thenAnswer(invocation -> {
if (apartmentLookupCount.getAndIncrement() == 0) {
leaseReferenceLookupEntered.countDown();
assertTrue(releaseLeaseReferenceLookup.await(5, SECONDS));
}
return Optional.of(apartment);
});
when(tenantRepository.findAllByIdInAndLandlordId(List.of(tenant.getId()), landlordId))
.thenReturn(List.of(tenant));
when(leaseRepository.findAllByLandlordIdAndApartmentId(landlordId, apartmentId)).thenReturn(List.of());
when(leaseRepository.save(lease)).thenAnswer(invocation -> {
leaseSaved.set(true);
return lease;
});
when(leaseRepository.existsByLandlordIdAndApartmentId(landlordId, apartmentId))
.thenAnswer(invocation -> leaseSaved.get());
LeaseService leaseService = new LeaseService(leaseRepository, apartmentRepository, tenantRepository,
mock(LeasePdfService.class), mock(PhoneNumberNormalizer.class), leaseMapper, rentPaymentService,
mock(LandlordProfileService.class), integrityLock);
ApartmentService apartmentService = new ApartmentService(apartmentRepository, leaseRepository,
mock(ApartmentMapper.class), integrityLock);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<Integer> leaseCreation = executor.submit(() -> leaseService.create(landlordId, request)
.getStatusCode().value());
assertTrue(leaseReferenceLookupEntered.await(5, SECONDS));
Future<Boolean> apartmentDeletion = executor.submit(() -> {
deletionStarted.countDown();
return apartmentService.delete(landlordId, apartmentId);
});
assertTrue(deletionStarted.await(5, SECONDS));
assertThrows(TimeoutException.class, () -> apartmentDeletion.get(250, MILLISECONDS));
releaseLeaseReferenceLookup.countDown();
assertEquals(HttpStatus.CREATED.value(), leaseCreation.get(5, SECONDS));
ExecutionException deletionFailure = assertThrows(ExecutionException.class,
() -> apartmentDeletion.get(5, SECONDS));
assertInstanceOf(ReferencedByLeaseException.class, deletionFailure.getCause());
}
}
@Test
void uploadingAnAttachmentAndDeletingItsTenantCannotResurrectTheTenant() throws Exception {
String landlordId = "landlord-1";
String tenantId = "tenant-1";
Tenant tenant = new Tenant(tenantId, landlordId, "Camille", "Martin", "camille@example.com", "", "");
AtomicReference<Tenant> persistedTenant = new AtomicReference<>(tenant);
TenantRepository tenantRepository = mock(TenantRepository.class);
when(tenantRepository.findByIdAndLandlordId(tenantId, landlordId))
.thenAnswer(invocation -> Optional.ofNullable(persistedTenant.get()));
when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> {
Tenant saved = invocation.getArgument(0);
persistedTenant.set(saved);
return saved;
});
when(tenantRepository.deleteByIdAndLandlordId(tenantId, landlordId)).thenAnswer(invocation ->
persistedTenant.getAndSet(null) == null ? 0L : 1L);
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
TenantAttachmentStorage storage = spy(realStorage);
CountDownLatch attachmentStored = new CountDownLatch(1);
CountDownLatch releaseUpload = new CountDownLatch(1);
doAnswer(invocation -> {
TenantAttachment attachment = realStorage.store(invocation.getArgument(0), invocation.getArgument(1));
attachmentStored.countDown();
assertTrue(releaseUpload.await(5, SECONDS));
return attachment;
}).when(storage).store(eq(tenantId), any());
TenantMapper tenantMapper = mock(TenantMapper.class);
when(tenantMapper.toResponse(any(TenantAttachment.class))).thenAnswer(invocation -> {
TenantAttachment attachment = invocation.getArgument(0);
return new TenantAttachmentResponse(attachment.getId(), attachment.getFileName(),
attachment.getContentType(), attachment.getSize());
});
TenantService service = new TenantService(tenantRepository, mock(LeaseRepository.class), storage,
new AttachmentUploadPolicy("250MB"), mock(PhoneNumberNormalizer.class), tenantMapper,
new RentalIntegrityLock());
MockMultipartFile file = new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes());
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<Optional<TenantAttachmentResponse>> upload = executor.submit(() ->
service.uploadAttachment(landlordId, tenantId, file));
assertTrue(attachmentStored.await(5, SECONDS));
Future<Boolean> deletion = executor.submit(() -> service.delete(landlordId, tenantId));
assertThrows(TimeoutException.class, () -> deletion.get(250, MILLISECONDS));
releaseUpload.countDown();
assertTrue(upload.get(5, SECONDS).isPresent());
assertTrue(deletion.get(5, SECONDS));
}
assertFalse(Optional.ofNullable(persistedTenant.get()).isPresent());
try (var storedPaths = Files.walk(directory)) {
assertEquals(0, storedPaths.filter(Files::isRegularFile).count());
}
}
}
@@ -11,6 +11,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.spy;
class TenantAttachmentStorageTest {
@TempDir
@@ -27,7 +30,9 @@ class TenantAttachmentStorageTest {
assertEquals(4, attachment.getSize());
assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes()));
storage.delete(attachment);
assertThrows(IOException.class, () -> storage.read(attachment));
assertAll(
() -> assertThrows(IOException.class, () -> storage.read(attachment)),
() -> assertEquals(0, stagedFileCount()));
}
@Test
@@ -58,6 +63,102 @@ class TenantAttachmentStorageTest {
assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes()));
}
@Test
void committedStagedDeletionCanBeRestoredUntilItsTombstoneIsDiscarded() 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);
storage.finalizeDeletion(deletion);
storage.markCommitted(deletion);
storage.restore(deletion);
assertAll(
() -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())),
() -> assertEquals(0, stagedFileCount()));
}
@Test
void failedTombstonePromotionRestoresAttachment() throws IOException {
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
TenantAttachment attachment = realStorage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
TenantAttachmentStorage storage = spy(realStorage);
IOException promotionFailure = new IOException("Filesystem unavailable");
doThrow(promotionFailure).when(storage).markCommitted(any());
IOException thrown = assertThrows(IOException.class, () -> storage.delete(attachment));
assertAll(
() -> assertSame(promotionFailure, thrown),
() -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())),
() -> assertEquals(0, stagedFileCount()));
}
@Test
void discardingFinalizedDeletionRemovesItsBackup() 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);
storage.finalizeDeletion(deletion);
storage.markCommitted(deletion);
storage.discardCommitted(deletion);
assertAll(
() -> assertThrows(IOException.class, () -> storage.read(attachment)),
() -> assertEquals(0, stagedFileCount()));
}
@Test
void startupSweepsCommittedTombstones() throws IOException {
Path staging = Files.createDirectories(directory.resolve(".staging"));
Files.writeString(staging.resolve("first.committed"), "committed");
new TenantAttachmentStorage(directory.toString());
assertEquals(0, stagedFileCount());
}
@Test
void laterStorageOperationSweepsCommittedTombstones() throws IOException {
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
Path tombstone = Files.createDirectories(directory.resolve(".staging")).resolve("failed.committed");
Files.writeString(tombstone, "committed");
storage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
assertFalse(Files.exists(tombstone));
}
@Test
void readDoesNotSweepTombstonesThatMayStillBeRecoverable() throws IOException {
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
TenantAttachment attachment = storage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
Path tombstone = Files.createDirectories(directory.resolve(".staging")).resolve("pending.committed");
Files.writeString(tombstone, "recoverable");
storage.read(attachment);
assertTrue(Files.exists(tombstone));
}
@Test
void startupPreservesRecoverableBackupsWhileADeletionIsStillStaged() throws IOException {
Path staging = Files.createDirectories(directory.resolve(".staging"));
Path backup = staging.resolve("first.backup");
Files.writeString(backup, "recoverable");
Files.writeString(staging.resolve("second.staged"), "pending");
new TenantAttachmentStorage(directory.toString());
assertTrue(Files.exists(backup));
}
@Test
void failedStoreLeavesNoPartialAttachment() throws IOException {
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
@@ -75,4 +176,10 @@ class TenantAttachmentStorageTest {
assertEquals(0, storedPaths.filter(Files::isRegularFile).count());
}
}
private long stagedFileCount() throws IOException {
try (var storedPaths = Files.walk(directory.resolve(".staging"))) {
return storedPaths.filter(Files::isRegularFile).count();
}
}
}
@@ -1,9 +1,10 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.exception.TenantHasAttachmentsException;
import fr.nessar.lmnporganizer.backend.exception.ReferencedByLeaseException;
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.LeaseRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -66,6 +67,31 @@ class TenantServiceTest {
() -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())));
}
@Test
void failedAttachmentFinalizationRestoresMetadataAndFile() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment attachment = realStorage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
tenant.setAttachments(List.of(attachment));
TenantAttachmentStorage storage = spy(realStorage);
IOException cleanupFailure = new IOException("Filesystem unavailable");
doThrow(cleanupFailure).when(storage).finalizeDeletion(any());
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
when(repository.save(tenant)).thenReturn(tenant);
IOException thrown = assertThrows(IOException.class,
() -> service(repository, storage).deleteAttachment("landlord-1", "tenant-1", attachment.getId()));
assertAll(
() -> assertSame(cleanupFailure, thrown),
() -> verify(repository, times(2)).save(tenant),
() -> assertEquals(List.of(attachment), tenant.getAttachments()),
() -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())),
() -> assertEquals(0, stagedFileCount()));
}
@Test
void successfulAttachmentRemovalDeletesMetadataAndActiveFile() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
@@ -81,7 +107,8 @@ class TenantServiceTest {
assertAll(
() -> assertTrue(tenant.getAttachments().isEmpty()),
() -> assertThrows(IOException.class, () -> storage.read(attachment)));
() -> assertThrows(IOException.class, () -> storage.read(attachment)),
() -> assertEquals(0, stagedFileCount()));
}
@Test
@@ -102,26 +129,233 @@ class TenantServiceTest {
}
@Test
void tenantWithAttachmentsMustBeEmptiedBeforeDeletion() {
void deletingUnreferencedTenantRemovesItsAttachments() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
tenant.setAttachments(List.of(new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
"tenant-1/attachment-1")));
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));
when(repository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L);
assertTrue(service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> verify(repository).deleteByIdAndLandlordId("tenant-1", "landlord-1"),
() -> assertThrows(IOException.class, () -> storage.read(attachment)),
() -> assertEquals(0, stagedFileCount()));
}
@Test
void failedTenantDeletionRestoresEveryAttachmentAndOriginalFailure() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment identity = storage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "ID".getBytes()));
TenantAttachment address = storage.store("tenant-1",
new MockMultipartFile("file", "domicile.pdf", "application/pdf", "HOME".getBytes()));
tenant.setAttachments(List.of(identity, address));
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
IllegalStateException failure = new IllegalStateException("Mongo unavailable");
when(repository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenThrow(failure);
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> assertSame(failure, thrown),
() -> assertEquals(List.of(identity, address), tenant.getAttachments()),
() -> assertEquals("ID", new String(storage.read(identity).getInputStream().readAllBytes())),
() -> assertEquals("HOME", new String(storage.read(address).getInputStream().readAllBytes())));
}
@Test
void tenantDeletionReturningFalseFinalizesStagedAttachments() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.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));
assertThrows(TenantHasAttachmentsException.class,
() -> service(repository, mock(TenantAttachmentStorage.class)).delete("landlord-1", "tenant-1"));
assertFalse(service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
verify(repository, never()).deleteByIdAndLandlordId(anyString(), anyString());
assertAll(
() -> assertThrows(IOException.class, () -> storage.read(attachment)),
() -> {
try (var storedPaths = Files.walk(directory)) {
assertEquals(0, storedPaths.filter(Files::isRegularFile).count());
}
});
}
@Test
void failedFinalizationCompensatesTenantDeletionAndRestoresAttachments() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment identity = realStorage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "ID".getBytes()));
TenantAttachment address = realStorage.store("tenant-1",
new MockMultipartFile("file", "domicile.pdf", "application/pdf", "HOME".getBytes()));
tenant.setAttachments(List.of(identity, address));
TenantAttachmentStorage storage = spy(realStorage);
IOException cleanupFailure = new IOException("Filesystem unavailable");
doCallRealMethod().doThrow(cleanupFailure).when(storage).finalizeDeletion(any());
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
when(repository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L);
IOException thrown = assertThrows(IOException.class,
() -> service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> assertSame(cleanupFailure, thrown),
() -> verify(repository).save(tenant),
() -> assertEquals(List.of(identity, address), tenant.getAttachments()),
() -> assertEquals("ID", new String(storage.read(identity).getInputStream().readAllBytes())),
() -> assertEquals("HOME", new String(storage.read(address).getInputStream().readAllBytes())),
() -> {
try (var storedPaths = Files.walk(directory.resolve(".staging"))) {
assertEquals(0, storedPaths.filter(Files::isRegularFile).count());
}
});
}
@Test
void failedCommittedTombstoneCleanupDoesNotRollBackTenantDeletion() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment identity = realStorage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "ID".getBytes()));
TenantAttachment address = realStorage.store("tenant-1",
new MockMultipartFile("file", "domicile.pdf", "application/pdf", "HOME".getBytes()));
tenant.setAttachments(List.of(identity, address));
TenantAttachmentStorage storage = spy(realStorage);
IOException cleanupFailure = new IOException("Filesystem unavailable");
doCallRealMethod().doThrow(cleanupFailure).when(storage).discardCommitted(any());
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
when(repository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L);
assertTrue(service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> verify(repository, never()).save(tenant),
() -> assertEquals(1, committedTombstoneCount()));
assertThrows(IOException.class, () -> storage.read(identity));
assertThrows(IOException.class, () -> storage.read(address));
assertEquals(1, committedTombstoneCount());
storage.store("tenant-2",
new MockMultipartFile("file", "later.pdf", "application/pdf", "LATER".getBytes()));
assertEquals(0, committedTombstoneCount());
}
@Test
void failedSecondTombstonePromotionRestoresTenantAndEveryAttachment() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment identity = realStorage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "ID".getBytes()));
TenantAttachment address = realStorage.store("tenant-1",
new MockMultipartFile("file", "domicile.pdf", "application/pdf", "HOME".getBytes()));
tenant.setAttachments(List.of(identity, address));
TenantAttachmentStorage storage = spy(realStorage);
IOException promotionFailure = new IOException("Filesystem unavailable");
doCallRealMethod().doThrow(promotionFailure).when(storage).markCommitted(any());
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
when(repository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L);
IOException thrown = assertThrows(IOException.class,
() -> service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> assertSame(promotionFailure, thrown),
() -> verify(repository).save(tenant),
() -> assertEquals("ID", new String(storage.read(identity).getInputStream().readAllBytes())),
() -> assertEquals("HOME", new String(storage.read(address).getInputStream().readAllBytes())),
() -> assertEquals(0, stagedFileCount()));
}
@Test
void failedAttachmentStagingRestoresPreviouslyStagedFilesAndMetadata() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment valid = storage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
TenantAttachment invalid = new TenantAttachment("invalid", "invalide.pdf", "application/pdf", 1,
"../outside/invalid");
tenant.setAttachments(List.of(valid, invalid));
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
assertThrows(IOException.class,
() -> service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> assertEquals(List.of(valid, invalid), tenant.getAttachments()),
() -> assertEquals("PDF!", new String(storage.read(valid).getInputStream().readAllBytes())),
() -> verify(repository, never()).deleteByIdAndLandlordId(anyString(), anyString()));
}
@Test
void referencedTenantConflictsBeforeAttachmentStorageChanges() throws IOException {
TenantRepository repository = mock(TenantRepository.class);
LeaseRepository leaseRepository = mock(LeaseRepository.class);
TenantAttachmentStorage realStorage = new TenantAttachmentStorage(directory.toString());
Tenant tenant = tenant();
TenantAttachment attachment = realStorage.store("tenant-1",
new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
tenant.setAttachments(List.of(attachment));
TenantAttachmentStorage storage = spy(realStorage);
when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
when(leaseRepository.existsByLandlordIdAndTenantIdsContaining("landlord-1", "tenant-1")).thenReturn(true);
assertThrows(ReferencedByLeaseException.class,
() -> service(repository, leaseRepository, storage).delete("landlord-1", "tenant-1"));
assertAll(
() -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())),
() -> verify(storage, never()).stageDeletion(any()),
() -> 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));
return service(repository, mock(LeaseRepository.class), storage);
}
private TenantService service(TenantRepository repository, LeaseRepository leaseRepository,
TenantAttachmentStorage storage) {
return new TenantService(repository, leaseRepository, storage, new AttachmentUploadPolicy("250MB"),
mock(PhoneNumberNormalizer.class), mock(TenantMapper.class), new RentalIntegrityLock());
}
private Tenant tenant() {
return new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com",
"+33612345678", "10 rue des Lilas");
}
private long stagedFileCount() throws IOException {
try (var storedPaths = Files.walk(directory.resolve(".staging"))) {
return storedPaths.filter(Files::isRegularFile).count();
}
}
private long committedTombstoneCount() throws IOException {
try (var storedPaths = Files.walk(directory.resolve(".staging"))) {
return storedPaths.filter(path -> path.getFileName().toString().endsWith(".committed")).count();
}
}
}