feat: protect referenced rental resources (#27)
This commit is contained in:
1 parent
fca199dfdf
commit
971ffd6feb
23 files changed
+1453
-151
No files matched your search
+3
-3
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+22
-12
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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();
|
||||
}
|
||||
|
||||
+17
@@ -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.");
|
||||
}
|
||||
}
|
||||
-7
@@ -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.");
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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);
|
||||
}
|
||||
+21
-2
@@ -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()));
|
||||
}
|
||||
}
|
||||
+17
@@ -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;
|
||||
}
|
||||
}
|
||||
+92
-4
@@ -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) {
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -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() {
|
||||
|
||||
+49
-27
@@ -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);
|
||||
|
||||
-1
@@ -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)
|
||||
|
||||
+24
-3
@@ -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
|
||||
|
||||
+364
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+244
-10
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user