feat: manage tenants and leases (#4)

This commit is contained in:
nessar committed 2026-08-17 14:18:14 +02:00
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()
)
@@ -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());
}
}
@@ -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); }
}
@@ -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.
@@ -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"
}
""";
}
}
@@ -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());
}
}
@@ -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