feat: manage tenants and leases (#4)
This commit is contained in:
1 parent
26ab037084
commit
8603b98714
42 files changed
+3089
-18
No files matched your search
@@ -20,6 +20,8 @@ public class SecurityConfig {
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers("/api/hello").authenticated()
|
||||
.requestMatchers("/api/apartments", "/api/apartments/**").authenticated()
|
||||
.requestMatchers("/api/tenants", "/api/tenants/**").authenticated()
|
||||
.requestMatchers("/api/leases", "/api/leases/**").authenticated()
|
||||
.requestMatchers("/api/user").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
)
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package fr.nessar.lmnporganizer.backend.controller;
|
||||
|
||||
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.repository.ApartmentRepository;
|
||||
import fr.nessar.lmnporganizer.backend.repository.LeaseRepository;
|
||||
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
|
||||
import fr.nessar.lmnporganizer.backend.service.LeasePdfService;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/leases")
|
||||
public class LeaseController {
|
||||
private final LeaseRepository leaseRepository;
|
||||
private final ApartmentRepository apartmentRepository;
|
||||
private final TenantRepository tenantRepository;
|
||||
private final LeasePdfService leasePdfService;
|
||||
|
||||
public LeaseController(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
|
||||
TenantRepository tenantRepository, LeasePdfService leasePdfService) {
|
||||
this.leaseRepository = leaseRepository;
|
||||
this.apartmentRepository = apartmentRepository;
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.leasePdfService = leasePdfService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Lease> getAllLeases() {
|
||||
return leaseRepository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Lease> getLease(@PathVariable String id) {
|
||||
return leaseRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Lease> createLease(@RequestBody Lease lease) {
|
||||
if (!referencesExist(lease)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
lease.setId(null);
|
||||
lease.setLandlordId("default-landlord");
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(leaseRepository.save(lease));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Lease> updateLease(@PathVariable String id, @RequestBody Lease details) {
|
||||
if (!referencesExist(details)) {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
return leaseRepository.findById(id).map(lease -> {
|
||||
copyTerms(details, lease);
|
||||
return ResponseEntity.ok(leaseRepository.save(lease));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteLease(@PathVariable String id) {
|
||||
if (!leaseRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
leaseRepository.deleteById(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/pdf")
|
||||
public ResponseEntity<byte[]> downloadPdf(@PathVariable String id) {
|
||||
Lease lease = leaseRepository.findById(id).orElse(null);
|
||||
if (lease == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null);
|
||||
List<Tenant> tenants = tenantsFor(lease.getTenantIds());
|
||||
if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_PDF)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename("bail-" + id + ".pdf").build().toString())
|
||||
.body(leasePdfService.generate(lease, apartment, tenants));
|
||||
}
|
||||
|
||||
private boolean referencesExist(Lease lease) {
|
||||
if (lease.getApartmentId() == null || !apartmentRepository.existsById(lease.getApartmentId())
|
||||
|| lease.getTenantIds().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return tenantsFor(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 void copyTerms(Lease source, Lease target) {
|
||||
target.setLandlordId("default-landlord");
|
||||
target.setApartmentId(source.getApartmentId());
|
||||
target.setTenantIds(source.getTenantIds());
|
||||
target.setGuarantors(source.getGuarantors());
|
||||
target.setStartDate(source.getStartDate());
|
||||
target.setEndDate(source.getEndDate());
|
||||
target.setMonthlyRent(source.getMonthlyRent());
|
||||
target.setMonthlyCharges(source.getMonthlyCharges());
|
||||
target.setSecurityDeposit(source.getSecurityDeposit());
|
||||
target.setStatus(source.getStatus());
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package fr.nessar.lmnporganizer.backend.controller;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.Tenant;
|
||||
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
|
||||
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
|
||||
import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenants")
|
||||
public class TenantController {
|
||||
private final TenantRepository tenantRepository;
|
||||
private final TenantAttachmentStorage attachmentStorage;
|
||||
|
||||
public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage) {
|
||||
this.tenantRepository = tenantRepository;
|
||||
this.attachmentStorage = attachmentStorage;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Tenant> getAllTenants() {
|
||||
return tenantRepository.findAll();
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<Tenant> getTenant(@PathVariable String id) {
|
||||
return tenantRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Tenant> createTenant(@RequestBody Tenant tenant) {
|
||||
tenant.setId(null);
|
||||
tenant.setLandlordId("default-landlord");
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(tenantRepository.save(tenant));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<Tenant> updateTenant(@PathVariable String id, @RequestBody Tenant details) {
|
||||
return tenantRepository.findById(id).map(tenant -> {
|
||||
tenant.setFirstName(details.getFirstName());
|
||||
tenant.setLastName(details.getLastName());
|
||||
tenant.setEmail(details.getEmail());
|
||||
tenant.setPhone(details.getPhone());
|
||||
tenant.setAddress(details.getAddress());
|
||||
tenant.setLandlordId("default-landlord");
|
||||
return ResponseEntity.ok(tenantRepository.save(tenant));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> deleteTenant(@PathVariable String id) {
|
||||
if (!tenantRepository.existsById(id)) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
tenantRepository.deleteById(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<TenantAttachment> uploadAttachment(@PathVariable String tenantId,
|
||||
@RequestPart("file") MultipartFile file) throws IOException {
|
||||
Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
|
||||
if (tenant == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
TenantAttachment attachment = attachmentStorage.store(tenantId, file);
|
||||
tenant.getAttachments().add(attachment);
|
||||
tenantRepository.save(tenant);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(attachment);
|
||||
}
|
||||
|
||||
@GetMapping("/{tenantId}/attachments/{attachmentId}")
|
||||
public ResponseEntity<Resource> downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
|
||||
throws IOException {
|
||||
TenantAttachment attachment = tenantRepository.findById(tenantId)
|
||||
.flatMap(tenant -> tenant.getAttachments().stream().filter(item -> item.getId().equals(attachmentId)).findFirst())
|
||||
.orElse(null);
|
||||
if (attachment == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
MediaType mediaType;
|
||||
try {
|
||||
mediaType = MediaType.parseMediaType(attachment.getContentType());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
mediaType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.contentType(mediaType)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(attachment.getFileName()).build().toString())
|
||||
.body(attachmentStorage.read(attachment));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{tenantId}/attachments/{attachmentId}")
|
||||
public ResponseEntity<Void> deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
|
||||
throws IOException {
|
||||
Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
|
||||
if (tenant == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
TenantAttachment attachment = tenant.getAttachments().stream()
|
||||
.filter(item -> item.getId().equals(attachmentId)).findFirst().orElse(null);
|
||||
if (attachment == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
attachmentStorage.delete(attachment);
|
||||
tenant.getAttachments().remove(attachment);
|
||||
tenantRepository.save(tenant);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package fr.nessar.lmnporganizer.backend.model;
|
||||
|
||||
public class Guarantor {
|
||||
private String id;
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private String phone;
|
||||
private String address;
|
||||
|
||||
public Guarantor() {
|
||||
}
|
||||
|
||||
public Guarantor(String id, String firstName, String lastName, String email, String phone, String address) {
|
||||
this.id = id;
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.email = email;
|
||||
this.phone = phone;
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package fr.nessar.lmnporganizer.backend.model;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Document(collection = "leases")
|
||||
public class Lease {
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Indexed
|
||||
private String landlordId;
|
||||
|
||||
@Indexed
|
||||
private String apartmentId;
|
||||
|
||||
@Indexed
|
||||
private List<String> tenantIds = new ArrayList<>();
|
||||
|
||||
private List<Guarantor> guarantors = new ArrayList<>();
|
||||
private LocalDate startDate;
|
||||
private LocalDate endDate;
|
||||
private BigDecimal monthlyRent;
|
||||
private BigDecimal monthlyCharges;
|
||||
private BigDecimal securityDeposit;
|
||||
private LeaseStatus status;
|
||||
|
||||
public Lease() {
|
||||
}
|
||||
|
||||
public Lease(String id, String landlordId, String apartmentId, List<String> tenantIds,
|
||||
List<Guarantor> guarantors, LocalDate startDate, LocalDate endDate, BigDecimal monthlyRent,
|
||||
BigDecimal monthlyCharges, BigDecimal securityDeposit, LeaseStatus status) {
|
||||
this.id = id;
|
||||
this.landlordId = landlordId;
|
||||
this.apartmentId = apartmentId;
|
||||
setTenantIds(tenantIds);
|
||||
setGuarantors(guarantors);
|
||||
this.startDate = startDate;
|
||||
this.endDate = endDate;
|
||||
this.monthlyRent = monthlyRent;
|
||||
this.monthlyCharges = monthlyCharges;
|
||||
this.securityDeposit = securityDeposit;
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getLandlordId() { return landlordId; }
|
||||
public void setLandlordId(String landlordId) { this.landlordId = landlordId; }
|
||||
public String getApartmentId() { return apartmentId; }
|
||||
public void setApartmentId(String apartmentId) { this.apartmentId = apartmentId; }
|
||||
public List<String> getTenantIds() { return tenantIds; }
|
||||
public void setTenantIds(List<String> tenantIds) {
|
||||
this.tenantIds = tenantIds == null ? new ArrayList<>() : new ArrayList<>(tenantIds);
|
||||
}
|
||||
public List<Guarantor> getGuarantors() { return guarantors; }
|
||||
public void setGuarantors(List<Guarantor> guarantors) {
|
||||
this.guarantors = guarantors == null ? new ArrayList<>() : new ArrayList<>(guarantors);
|
||||
}
|
||||
public LocalDate getStartDate() { return startDate; }
|
||||
public void setStartDate(LocalDate startDate) { this.startDate = startDate; }
|
||||
public LocalDate getEndDate() { return endDate; }
|
||||
public void setEndDate(LocalDate endDate) { this.endDate = endDate; }
|
||||
public BigDecimal getMonthlyRent() { return monthlyRent; }
|
||||
public void setMonthlyRent(BigDecimal monthlyRent) { this.monthlyRent = monthlyRent; }
|
||||
public BigDecimal getMonthlyCharges() { return monthlyCharges; }
|
||||
public void setMonthlyCharges(BigDecimal monthlyCharges) { this.monthlyCharges = monthlyCharges; }
|
||||
public BigDecimal getSecurityDeposit() { return securityDeposit; }
|
||||
public void setSecurityDeposit(BigDecimal securityDeposit) { this.securityDeposit = securityDeposit; }
|
||||
public LeaseStatus getStatus() { return status; }
|
||||
public void setStatus(LeaseStatus status) { this.status = status; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.backend.model;
|
||||
|
||||
public enum LeaseStatus {
|
||||
DRAFT,
|
||||
ACTIVE,
|
||||
ARCHIVED
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package fr.nessar.lmnporganizer.backend.model;
|
||||
|
||||
import org.springframework.data.annotation.Id;
|
||||
import org.springframework.data.mongodb.core.index.Indexed;
|
||||
import org.springframework.data.mongodb.core.mapping.Document;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Document(collection = "tenants")
|
||||
public class Tenant {
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Indexed
|
||||
private String landlordId;
|
||||
|
||||
private String firstName;
|
||||
private String lastName;
|
||||
private String email;
|
||||
private String phone;
|
||||
private String address;
|
||||
private List<TenantAttachment> attachments = new ArrayList<>();
|
||||
|
||||
public Tenant() {
|
||||
}
|
||||
|
||||
public Tenant(String id, String landlordId, String firstName, String lastName, String email, String phone,
|
||||
String address) {
|
||||
this.id = id;
|
||||
this.landlordId = landlordId;
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.email = email;
|
||||
this.phone = phone;
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getLandlordId() {
|
||||
return landlordId;
|
||||
}
|
||||
|
||||
public void setLandlordId(String landlordId) {
|
||||
this.landlordId = landlordId;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public List<TenantAttachment> getAttachments() {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
public void setAttachments(List<TenantAttachment> attachments) {
|
||||
this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package fr.nessar.lmnporganizer.backend.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
public class TenantAttachment {
|
||||
private String id;
|
||||
private String fileName;
|
||||
private String contentType;
|
||||
private long size;
|
||||
|
||||
@JsonIgnore
|
||||
private String storagePath;
|
||||
|
||||
public TenantAttachment() {
|
||||
}
|
||||
|
||||
public TenantAttachment(String id, String fileName, String contentType, long size, String storagePath) {
|
||||
this.id = id;
|
||||
this.fileName = fileName;
|
||||
this.contentType = contentType;
|
||||
this.size = size;
|
||||
this.storagePath = storagePath;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getFileName() { return fileName; }
|
||||
public void setFileName(String fileName) { this.fileName = fileName; }
|
||||
public String getContentType() { return contentType; }
|
||||
public void setContentType(String contentType) { this.contentType = contentType; }
|
||||
public long getSize() { return size; }
|
||||
public void setSize(long size) { this.size = size; }
|
||||
public String getStoragePath() { return storagePath; }
|
||||
public void setStoragePath(String storagePath) { this.storagePath = storagePath; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.backend.repository;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.Lease;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
|
||||
public interface LeaseRepository extends MongoRepository<Lease, String> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package fr.nessar.lmnporganizer.backend.repository;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.Tenant;
|
||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||
|
||||
public interface TenantRepository extends MongoRepository<Tenant, String> {
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package fr.nessar.lmnporganizer.backend.service;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.Apartment;
|
||||
import fr.nessar.lmnporganizer.backend.model.Lease;
|
||||
import fr.nessar.lmnporganizer.backend.model.Tenant;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.Normalizer;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Service
|
||||
public class LeasePdfService {
|
||||
public byte[] generate(Lease lease, Apartment apartment, List<Tenant> tenants) {
|
||||
String tenantNames = tenants.stream().map(tenant -> tenant.getFirstName() + " " + tenant.getLastName())
|
||||
.reduce((first, second) -> first + ", " + second).orElse("");
|
||||
String guarantorNames = lease.getGuarantors().stream()
|
||||
.map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName())
|
||||
.reduce((first, second) -> first + ", " + second).orElse("Aucun");
|
||||
String renderedTemplate = loadTemplate()
|
||||
.replace("{{leaseId}}", lease.getId())
|
||||
.replace("{{apartmentName}}", apartment.getName())
|
||||
.replace("{{apartmentAddress}}", apartment.getAddress())
|
||||
.replace("{{tenantNames}}", tenantNames)
|
||||
.replace("{{startDate}}", String.valueOf(lease.getStartDate()))
|
||||
.replace("{{endDate}}", String.valueOf(lease.getEndDate()))
|
||||
.replace("{{monthlyRent}}", String.valueOf(lease.getMonthlyRent()))
|
||||
.replace("{{monthlyCharges}}", String.valueOf(lease.getMonthlyCharges()))
|
||||
.replace("{{securityDeposit}}", String.valueOf(lease.getSecurityDeposit()))
|
||||
.replace("{{guarantorNames}}", guarantorNames);
|
||||
return buildPdf(List.of(renderedTemplate.split("\\R", -1)));
|
||||
}
|
||||
|
||||
private String loadTemplate() {
|
||||
try {
|
||||
return new ClassPathResource("templates/lease.txt").getContentAsString(StandardCharsets.UTF_8);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Lease PDF template is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] buildPdf(List<String> lines) {
|
||||
StringBuilder stream = new StringBuilder("BT\n/F1 18 Tf\n50 790 Td\n");
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
if (index == 1) stream.append("/F1 11 Tf\n");
|
||||
stream.append('(').append(escape(lines.get(index))).append(") Tj\n0 -28 Td\n");
|
||||
}
|
||||
stream.append("ET\n");
|
||||
byte[] streamBytes = stream.toString().getBytes(StandardCharsets.US_ASCII);
|
||||
List<byte[]> objects = List.of(
|
||||
ascii("<< /Type /Catalog /Pages 2 0 R >>"),
|
||||
ascii("<< /Type /Pages /Kids [3 0 R] /Count 1 >>"),
|
||||
ascii("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>"),
|
||||
ascii("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"),
|
||||
concat(ascii("<< /Length " + streamBytes.length + " >>\nstream\n"), streamBytes, ascii("endstream")));
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
write(output, ascii("%PDF-1.4\n"));
|
||||
List<Integer> offsets = new ArrayList<>();
|
||||
for (int index = 0; index < objects.size(); index++) {
|
||||
offsets.add(output.size());
|
||||
write(output, ascii((index + 1) + " 0 obj\n"));
|
||||
write(output, objects.get(index));
|
||||
write(output, ascii("\nendobj\n"));
|
||||
}
|
||||
int xrefOffset = output.size();
|
||||
write(output, ascii("xref\n0 " + (objects.size() + 1) + "\n0000000000 65535 f \n"));
|
||||
offsets.forEach(offset -> write(output, ascii(String.format(Locale.ROOT, "%010d 00000 n \n", offset))));
|
||||
write(output, ascii("trailer\n<< /Size " + (objects.size() + 1) + " /Root 1 0 R >>\nstartxref\n"
|
||||
+ xrefOffset + "\n%%EOF\n"));
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private String escape(String value) {
|
||||
String ascii = Normalizer.normalize(String.valueOf(value), Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{M}", "").replaceAll("[^\\x20-\\x7E]", "?");
|
||||
return ascii.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)");
|
||||
}
|
||||
|
||||
private byte[] ascii(String value) { return value.getBytes(StandardCharsets.US_ASCII); }
|
||||
private byte[] concat(byte[]... parts) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
for (byte[] part : parts) write(output, part);
|
||||
return output.toByteArray();
|
||||
}
|
||||
private void write(ByteArrayOutputStream output, byte[] bytes) { output.writeBytes(bytes); }
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package fr.nessar.lmnporganizer.backend.service;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.UrlResource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class TenantAttachmentStorage {
|
||||
private final Path root;
|
||||
|
||||
public TenantAttachmentStorage(@Value("${lmnp.storage.attachments}") String root) {
|
||||
this.root = Path.of(root).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
public TenantAttachment store(String tenantId, MultipartFile file) throws IOException {
|
||||
String attachmentId = UUID.randomUUID().toString();
|
||||
String suppliedName = file.getOriginalFilename();
|
||||
String originalName = suppliedName == null || suppliedName.isBlank()
|
||||
? "attachment"
|
||||
: Path.of(suppliedName).getFileName().toString();
|
||||
Path tenantDirectory = root.resolve(tenantId).normalize();
|
||||
if (!tenantDirectory.startsWith(root)) {
|
||||
throw new IOException("Invalid tenant attachment path");
|
||||
}
|
||||
Files.createDirectories(tenantDirectory);
|
||||
Path destination = tenantDirectory.resolve(attachmentId).normalize();
|
||||
file.transferTo(destination);
|
||||
String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType();
|
||||
return new TenantAttachment(attachmentId, originalName, contentType, file.getSize(),
|
||||
root.relativize(destination).toString());
|
||||
}
|
||||
|
||||
public Resource read(TenantAttachment attachment) throws IOException {
|
||||
Path path = root.resolve(attachment.getStoragePath()).normalize();
|
||||
if (!path.startsWith(root) || !Files.isRegularFile(path)) {
|
||||
throw new IOException("Tenant attachment not found");
|
||||
}
|
||||
return new UrlResource(path.toUri());
|
||||
}
|
||||
|
||||
public void delete(TenantAttachment attachment) throws IOException {
|
||||
Path path = root.resolve(attachment.getStoragePath()).normalize();
|
||||
if (!path.startsWith(root)) {
|
||||
throw new IOException("Invalid tenant attachment path");
|
||||
}
|
||||
Files.deleteIfExists(path);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
spring.application.name=backend
|
||||
spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp}
|
||||
lmnp.storage.attachments=${LMNP_ATTACHMENT_STORAGE_PATH:${user.home}/.lmnp-organizer/attachments}
|
||||
|
||||
# OAuth2 Client registration
|
||||
spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
BAIL D'HABITATION MEUBLÉE
|
||||
Référence : {{leaseId}}
|
||||
Logement : {{apartmentName}}
|
||||
Adresse : {{apartmentAddress}}
|
||||
Locataire(s) : {{tenantNames}}
|
||||
Période : {{startDate}} au {{endDate}}
|
||||
Loyer mensuel : {{monthlyRent}} EUR
|
||||
Charges mensuelles : {{monthlyCharges}} EUR
|
||||
Dépôt de garantie : {{securityDeposit}} EUR
|
||||
Garant(s) : {{guarantorNames}}
|
||||
|
||||
Bail généré par LMNP Organizer. À compléter et signer par les parties.
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package fr.nessar.lmnporganizer.backend.controller;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.Apartment;
|
||||
import fr.nessar.lmnporganizer.backend.model.Guarantor;
|
||||
import fr.nessar.lmnporganizer.backend.model.Lease;
|
||||
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
|
||||
import fr.nessar.lmnporganizer.backend.model.Tenant;
|
||||
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.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.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@SpringBootTest(properties = "lmnp.data-initializer.enabled=false")
|
||||
@AutoConfigureMockMvc
|
||||
class LeaseControllerTest {
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private LeaseRepository leaseRepository;
|
||||
|
||||
@MockitoBean
|
||||
private ApartmentRepository apartmentRepository;
|
||||
|
||||
@MockitoBean
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
@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(leaseRepository.save(any(Lease.class))).thenAnswer(invocation -> {
|
||||
Lease lease = invocation.getArgument(0);
|
||||
lease.setId("lease-1");
|
||||
return lease;
|
||||
});
|
||||
|
||||
mockMvc.perform(post("/api/leases")
|
||||
.with(oidcLogin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(leaseJson()))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value("lease-1"))
|
||||
.andExpect(jsonPath("$.landlordId").value("default-landlord"))
|
||||
.andExpect(jsonPath("$.apartmentId").value("apartment-1"))
|
||||
.andExpect(jsonPath("$.tenantIds[0]").value("tenant-1"))
|
||||
.andExpect(jsonPath("$.guarantors[0].lastName").value("Durand"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenLeaseReferencesUnknownApartment_thenBadRequest() throws Exception {
|
||||
when(apartmentRepository.existsById("apartment-1")).thenReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/leases")
|
||||
.with(oidcLogin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(leaseJson()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenGetLeasesWithoutAuthentication_thenUnauthorized() throws Exception {
|
||||
mockMvc.perform(get("/api/leases"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenGetLeases_thenReturnsPortfolioLeases() throws Exception {
|
||||
when(leaseRepository.findAll()).thenReturn(List.of(lease()));
|
||||
|
||||
mockMvc.perform(get("/api/leases").with(oidcLogin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value("lease-1"));
|
||||
}
|
||||
|
||||
@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.save(any(Lease.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
mockMvc.perform(put("/api/leases/lease-1")
|
||||
.with(oidcLogin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(leaseJson().replace("850.00", "900.00")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.monthlyRent").value(900.00));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenDeleteLease_thenNoContent() throws Exception {
|
||||
when(leaseRepository.existsById("lease-1")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(delete("/api/leases/lease-1").with(oidcLogin()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(leaseRepository).deleteById("lease-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()));
|
||||
|
||||
mockMvc.perform(get("/api/leases/lease-1/pdf").with(oidcLogin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Type", "application/pdf"))
|
||||
.andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("bail-lease-1.pdf")))
|
||||
.andExpect(result -> {
|
||||
byte[] content = result.getResponse().getContentAsByteArray();
|
||||
if (content.length < 100 || content[0] != '%' || content[1] != 'P') {
|
||||
throw new AssertionError("Expected a generated lease PDF");
|
||||
}
|
||||
String pdf = new String(content, java.nio.charset.StandardCharsets.US_ASCII);
|
||||
if (!pdf.contains("Studio Republique") || !pdf.contains("Camille Martin")) {
|
||||
throw new AssertionError("Expected rendered lease template values");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Tenant tenant() {
|
||||
return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com",
|
||||
"+33612345678", "10 rue des Lilas");
|
||||
}
|
||||
|
||||
private Lease lease() {
|
||||
return new Lease("lease-1", "default-landlord", "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);
|
||||
}
|
||||
|
||||
private String leaseJson() {
|
||||
return """
|
||||
{
|
||||
"apartmentId": "apartment-1",
|
||||
"tenantIds": ["tenant-1"],
|
||||
"guarantors": [{
|
||||
"firstName": "Alex",
|
||||
"lastName": "Durand",
|
||||
"email": "alex@example.com",
|
||||
"phone": "+33600000000",
|
||||
"address": "5 rue Victor Hugo"
|
||||
}],
|
||||
"startDate": "2026-09-01",
|
||||
"endDate": "2027-08-31",
|
||||
"monthlyRent": 850.00,
|
||||
"monthlyCharges": 50.00,
|
||||
"securityDeposit": 850.00,
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
package fr.nessar.lmnporganizer.backend.controller;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.Tenant;
|
||||
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
|
||||
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
|
||||
import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest(properties = "lmnp.data-initializer.enabled=false")
|
||||
@AutoConfigureMockMvc
|
||||
class TenantControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private TenantRepository tenantRepository;
|
||||
|
||||
@MockitoBean
|
||||
private TenantAttachmentStorage attachmentStorage;
|
||||
|
||||
@Test
|
||||
void whenCreateTenant_thenCreatedForDefaultLandlord() throws Exception {
|
||||
Tenant savedTenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
|
||||
"camille@example.com", "+33612345678", "10 rue des Lilas");
|
||||
when(tenantRepository.save(any(Tenant.class))).thenReturn(savedTenant);
|
||||
|
||||
mockMvc.perform(post("/api/tenants")
|
||||
.with(oidcLogin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"firstName": "Camille",
|
||||
"lastName": "Martin",
|
||||
"email": "camille@example.com",
|
||||
"phone": "+33612345678",
|
||||
"address": "10 rue des Lilas"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value("tenant-1"))
|
||||
.andExpect(jsonPath("$.landlordId").value("default-landlord"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenGetTenantsWithoutAuthentication_thenUnauthorized() throws Exception {
|
||||
mockMvc.perform(get("/api/tenants"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenGetTenants_thenReturnsPortfolioTenants() throws Exception {
|
||||
Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
|
||||
"camille@example.com", "+33612345678", "10 rue des Lilas");
|
||||
when(tenantRepository.findAll()).thenReturn(List.of(tenant));
|
||||
|
||||
mockMvc.perform(get("/api/tenants").with(oidcLogin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].firstName").value("Camille"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenGetUnknownTenant_thenNotFound() throws Exception {
|
||||
when(tenantRepository.findById("missing")).thenReturn(Optional.empty());
|
||||
|
||||
mockMvc.perform(get("/api/tenants/missing").with(oidcLogin()))
|
||||
.andExpect(status().isNotFound());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenUpdateTenant_thenContactDetailsAreUpdated() throws Exception {
|
||||
Tenant existing = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
|
||||
"old@example.com", "0100000000", "Ancienne adresse");
|
||||
when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(existing));
|
||||
when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
mockMvc.perform(put("/api/tenants/tenant-1")
|
||||
.with(oidcLogin())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"firstName": "Camille",
|
||||
"lastName": "Martin",
|
||||
"email": "new@example.com",
|
||||
"phone": "+33612345678",
|
||||
"address": "10 rue des Lilas"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.email").value("new@example.com"))
|
||||
.andExpect(jsonPath("$.address").value("10 rue des Lilas"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenDeleteTenant_thenNoContent() throws Exception {
|
||||
when(tenantRepository.existsById("tenant-1")).thenReturn(true);
|
||||
|
||||
mockMvc.perform(delete("/api/tenants/tenant-1").with(oidcLogin()))
|
||||
.andExpect(status().isNoContent());
|
||||
|
||||
verify(tenantRepository).deleteById("tenant-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenUploadAttachment_thenMetadataIsAddedToTenant() throws Exception {
|
||||
Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
|
||||
"camille@example.com", "+33612345678", "10 rue des Lilas");
|
||||
TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
|
||||
"tenant-1/document-1.pdf");
|
||||
MockMultipartFile file = new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes());
|
||||
when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(tenant));
|
||||
when(attachmentStorage.store(eq("tenant-1"), any())).thenReturn(attachment);
|
||||
when(tenantRepository.save(any(Tenant.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
mockMvc.perform(multipart("/api/tenants/tenant-1/attachments")
|
||||
.file(file)
|
||||
.with(oidcLogin()))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.id").value("attachment-1"))
|
||||
.andExpect(jsonPath("$.fileName").value("identite.pdf"))
|
||||
.andExpect(jsonPath("$.storagePath").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void whenDownloadAttachment_thenReturnsAttachment() throws Exception {
|
||||
Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
|
||||
"camille@example.com", "+33612345678", "10 rue des Lilas");
|
||||
TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
|
||||
"tenant-1/document-1.pdf");
|
||||
tenant.setAttachments(List.of(attachment));
|
||||
when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(tenant));
|
||||
when(attachmentStorage.read(attachment)).thenReturn(new ByteArrayResource("PDF!".getBytes()));
|
||||
|
||||
mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(oidcLogin()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(result -> {
|
||||
if (!"PDF!".equals(result.getResponse().getContentAsString())) {
|
||||
throw new AssertionError("Unexpected document content");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
package fr.nessar.lmnporganizer.backend.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ModelCoverageTest {
|
||||
@@ -70,4 +75,121 @@ class ModelCoverageTest {
|
||||
assertEquals(200.0, fullApt.getPurchasePrice());
|
||||
assertEquals("desc2", fullApt.getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTenantAndDocumentAccessors() {
|
||||
Tenant tenant = new Tenant();
|
||||
assertNull(tenant.getId());
|
||||
assertNull(tenant.getLandlordId());
|
||||
assertNull(tenant.getFirstName());
|
||||
assertNull(tenant.getLastName());
|
||||
assertNull(tenant.getEmail());
|
||||
assertNull(tenant.getPhone());
|
||||
assertNull(tenant.getAddress());
|
||||
assertTrue(tenant.getAttachments().isEmpty());
|
||||
|
||||
TenantAttachment attachment = new TenantAttachment();
|
||||
attachment.setId("attachment-1");
|
||||
attachment.setFileName("identite.pdf");
|
||||
attachment.setContentType("application/pdf");
|
||||
attachment.setSize(42);
|
||||
attachment.setStoragePath("tenant-1/attachment-1");
|
||||
assertEquals("attachment-1", attachment.getId());
|
||||
assertEquals("identite.pdf", attachment.getFileName());
|
||||
assertEquals("application/pdf", attachment.getContentType());
|
||||
assertEquals(42, attachment.getSize());
|
||||
assertEquals("tenant-1/attachment-1", attachment.getStoragePath());
|
||||
|
||||
tenant.setId("tenant-1");
|
||||
tenant.setLandlordId("default-landlord");
|
||||
tenant.setFirstName("Camille");
|
||||
tenant.setLastName("Martin");
|
||||
tenant.setEmail("camille@example.com");
|
||||
tenant.setPhone("0600000000");
|
||||
tenant.setAddress("10 rue des Lilas");
|
||||
tenant.setAttachments(List.of(attachment));
|
||||
assertEquals("tenant-1", tenant.getId());
|
||||
assertEquals("default-landlord", tenant.getLandlordId());
|
||||
assertEquals("Camille", tenant.getFirstName());
|
||||
assertEquals("Martin", tenant.getLastName());
|
||||
assertEquals("camille@example.com", tenant.getEmail());
|
||||
assertEquals("0600000000", tenant.getPhone());
|
||||
assertEquals("10 rue des Lilas", tenant.getAddress());
|
||||
assertEquals(List.of(attachment), tenant.getAttachments());
|
||||
tenant.setAttachments(null);
|
||||
assertTrue(tenant.getAttachments().isEmpty());
|
||||
|
||||
Tenant fullTenant = new Tenant("tenant-2", "landlord", "Alex", "Durand", "alex@example.com",
|
||||
"0700000000", "5 rue Victor Hugo");
|
||||
assertEquals("tenant-2", fullTenant.getId());
|
||||
TenantAttachment fullAttachment = new TenantAttachment("attachment-2", "revenus.pdf", "application/pdf", 10,
|
||||
"tenant-2/attachment-2");
|
||||
assertEquals("attachment-2", fullAttachment.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testLeaseAndGuarantorAccessors() {
|
||||
Guarantor guarantor = new Guarantor();
|
||||
guarantor.setId("guarantor-1");
|
||||
guarantor.setFirstName("Alex");
|
||||
guarantor.setLastName("Durand");
|
||||
guarantor.setEmail("alex@example.com");
|
||||
guarantor.setPhone("0700000000");
|
||||
guarantor.setAddress("5 rue Victor Hugo");
|
||||
assertEquals("guarantor-1", guarantor.getId());
|
||||
assertEquals("Alex", guarantor.getFirstName());
|
||||
assertEquals("Durand", guarantor.getLastName());
|
||||
assertEquals("alex@example.com", guarantor.getEmail());
|
||||
assertEquals("0700000000", guarantor.getPhone());
|
||||
assertEquals("5 rue Victor Hugo", guarantor.getAddress());
|
||||
|
||||
Guarantor fullGuarantor = new Guarantor("guarantor-2", "Jo", "Petit", "jo@example.com",
|
||||
"0600000000", "Paris");
|
||||
assertEquals("guarantor-2", fullGuarantor.getId());
|
||||
|
||||
Lease lease = new Lease();
|
||||
assertNull(lease.getId());
|
||||
assertNull(lease.getLandlordId());
|
||||
assertNull(lease.getApartmentId());
|
||||
assertTrue(lease.getTenantIds().isEmpty());
|
||||
assertTrue(lease.getGuarantors().isEmpty());
|
||||
assertNull(lease.getStartDate());
|
||||
assertNull(lease.getEndDate());
|
||||
assertNull(lease.getMonthlyRent());
|
||||
assertNull(lease.getMonthlyCharges());
|
||||
assertNull(lease.getSecurityDeposit());
|
||||
assertNull(lease.getStatus());
|
||||
|
||||
lease.setId("lease-1");
|
||||
lease.setLandlordId("default-landlord");
|
||||
lease.setApartmentId("apartment-1");
|
||||
lease.setTenantIds(List.of("tenant-1"));
|
||||
lease.setGuarantors(List.of(guarantor));
|
||||
lease.setStartDate(LocalDate.of(2026, 9, 1));
|
||||
lease.setEndDate(LocalDate.of(2027, 8, 31));
|
||||
lease.setMonthlyRent(new BigDecimal("850"));
|
||||
lease.setMonthlyCharges(new BigDecimal("50"));
|
||||
lease.setSecurityDeposit(new BigDecimal("850"));
|
||||
lease.setStatus(LeaseStatus.ACTIVE);
|
||||
assertEquals("lease-1", lease.getId());
|
||||
assertEquals("default-landlord", lease.getLandlordId());
|
||||
assertEquals("apartment-1", lease.getApartmentId());
|
||||
assertEquals(List.of("tenant-1"), lease.getTenantIds());
|
||||
assertEquals(List.of(guarantor), lease.getGuarantors());
|
||||
assertEquals(LocalDate.of(2026, 9, 1), lease.getStartDate());
|
||||
assertEquals(LocalDate.of(2027, 8, 31), lease.getEndDate());
|
||||
assertEquals(new BigDecimal("850"), lease.getMonthlyRent());
|
||||
assertEquals(new BigDecimal("50"), lease.getMonthlyCharges());
|
||||
assertEquals(new BigDecimal("850"), lease.getSecurityDeposit());
|
||||
assertEquals(LeaseStatus.ACTIVE, lease.getStatus());
|
||||
lease.setTenantIds(null);
|
||||
lease.setGuarantors(null);
|
||||
assertTrue(lease.getTenantIds().isEmpty());
|
||||
assertTrue(lease.getGuarantors().isEmpty());
|
||||
|
||||
Lease fullLease = new Lease("lease-2", "landlord", "apartment-2", List.of("tenant-2"),
|
||||
List.of(fullGuarantor), LocalDate.now(), LocalDate.now().plusYears(1), BigDecimal.ONE,
|
||||
BigDecimal.ZERO, BigDecimal.TEN, LeaseStatus.DRAFT);
|
||||
assertEquals("lease-2", fullLease.getId());
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package fr.nessar.lmnporganizer.backend.service;
|
||||
|
||||
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class TenantAttachmentStorageTest {
|
||||
@TempDir
|
||||
Path directory;
|
||||
|
||||
@Test
|
||||
void storesReadsAndDeletesTenantAttachment() throws IOException {
|
||||
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
|
||||
MockMultipartFile file = new MockMultipartFile("file", "../identite.pdf", "application/pdf",
|
||||
"PDF!".getBytes());
|
||||
TenantAttachment attachment = storage.store("tenant-1", file);
|
||||
assertEquals("identite.pdf", attachment.getFileName());
|
||||
assertEquals("application/pdf", attachment.getContentType());
|
||||
assertEquals(4, attachment.getSize());
|
||||
assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes()));
|
||||
storage.delete(attachment);
|
||||
assertThrows(IOException.class, () -> storage.read(attachment));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesSafeDefaultsAndRejectsPathsOutsideRoot() throws IOException {
|
||||
TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
|
||||
MockMultipartFile file = new MockMultipartFile("file", null, null, new byte[]{1});
|
||||
TenantAttachment attachment = storage.store("tenant-1", file);
|
||||
assertEquals("attachment", attachment.getFileName());
|
||||
assertEquals("application/octet-stream", attachment.getContentType());
|
||||
TenantAttachment unsafe = new TenantAttachment("id", "unsafe", "text/plain", 1, "../unsafe");
|
||||
assertThrows(IOException.class, () -> storage.read(unsafe));
|
||||
assertThrows(IOException.class, () -> storage.delete(unsafe));
|
||||
assertThrows(IOException.class, () -> storage.store("../outside", file));
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ spring.security.oauth2.client.provider.oidc.token-uri=http://localhost:8090/real
|
||||
spring.security.oauth2.client.provider.oidc.user-info-uri=http://localhost:8090/realms/test/protocol/openid-connect/userinfo
|
||||
spring.security.oauth2.client.provider.oidc.jwk-set-uri=http://localhost:8090/realms/test/protocol/openid-connect/certs
|
||||
spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username
|
||||
lmnp.storage.attachments=${java.io.tmpdir}/lmnp-organizer-test/attachments
|
||||
Reference in new issue
Block a user