Refactor backend services and controllers to use DTOs for Tenant, Lease, and Apartment

- Updated TenantController to utilize TenantService and DTOs for tenant operations.
- Refactored UserController to return UserResponse DTO.
- Introduced LeaseService and ApartmentService to handle business logic and data mapping.
- Created DTOs for Apartment, Lease, Tenant, and User to standardize data transfer.
- Implemented mappers for converting between entities and DTOs using MapStruct.
- Removed landlordId from Apartment and Lease entities and adjusted related tests.
- Added validation and normalization logic for tenant and guarantor data.
- Enhanced unit tests to ensure proper mapping and data integrity during updates.
This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 181410e28f
commit c8020c489e
34 files changed
+757 -321

No files matched your search

+13
View File
@@ -28,6 +28,7 @@
</scm>
<properties>
<java.version>25</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<dependency>
@@ -59,6 +60,11 @@
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>1.1.40</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -83,6 +89,13 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
<!-- Deprecated APIs are forbidden in production and test code. -->
<compilerArgs>
<arg>-Xlint:deprecation</arg>
@@ -1,5 +1,6 @@
package fr.nessar.lmnporganizer.backend;
import fr.nessar.lmnporganizer.backend.service.GreetingService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping;
@@ -7,16 +8,14 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
private final GreetingService greetingService;
public HelloController(GreetingService greetingService) {
this.greetingService = greetingService;
}
@GetMapping("/api/hello")
public String hello(@AuthenticationPrincipal OAuth2User user) {
String name = user.getAttribute("preferred_username");
if (name == null) {
name = user.getAttribute("name");
}
if (name == null) {
name = user.getName();
}
return "Hello, " + name + "!";
return greetingService.greetingFor(user);
}
}
@@ -1,8 +1,8 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import org.springframework.http.HttpStatus;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.service.ApartmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -11,56 +11,35 @@ import java.util.List;
@RestController
@RequestMapping("/api/apartments")
public class ApartmentController {
private final ApartmentService apartmentService;
private final ApartmentRepository apartmentRepository;
public ApartmentController(ApartmentService apartmentService) {
this.apartmentService = apartmentService;
}
public ApartmentController(ApartmentRepository apartmentRepository) {
this.apartmentRepository = apartmentRepository;
}
@GetMapping
public List<ApartmentResponse> getAllApartments() {
return apartmentService.getAll();
}
@GetMapping
public List<Apartment> getAllApartments() {
return apartmentRepository.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<ApartmentResponse> getApartmentById(@PathVariable String id) {
return apartmentService.getById(id);
}
@GetMapping("/{id}")
public ResponseEntity<Apartment> getApartmentById(@PathVariable String id) {
return apartmentRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<ApartmentResponse> createApartment(@RequestBody ApartmentRequest request) {
return apartmentService.create(request);
}
@PostMapping
public ResponseEntity<Apartment> createApartment(@RequestBody Apartment apartment) {
apartment.setId(null); // Ensure a new entity is created
apartment.setBailleurId("default-landlord"); // Associate with default landlord profile
Apartment savedApartment = apartmentRepository.save(apartment);
return ResponseEntity.status(HttpStatus.CREATED).body(savedApartment);
}
@PutMapping("/{id}")
public ResponseEntity<ApartmentResponse> updateApartment(@PathVariable String id,
@RequestBody ApartmentRequest request) {
return apartmentService.update(id, request);
}
@PutMapping("/{id}")
public ResponseEntity<Apartment> updateApartment(@PathVariable String id, @RequestBody Apartment apartmentDetails) {
return apartmentRepository.findById(id)
.map(existingApartment -> {
existingApartment.setName(apartmentDetails.getName());
existingApartment.setAddress(apartmentDetails.getAddress());
existingApartment.setPurchasePrice(apartmentDetails.getPurchasePrice());
existingApartment.setDescription(apartmentDetails.getDescription());
// Keep bailleurId fixed to default-landlord in single-portfolio mode
existingApartment.setBailleurId("default-landlord");
Apartment updatedApartment = apartmentRepository.save(existingApartment);
return ResponseEntity.ok(updatedApartment);
})
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteApartment(@PathVariable String id) {
if (apartmentRepository.existsById(id)) {
apartmentRepository.deleteById(id);
return ResponseEntity.noContent().build();
} else {
return ResponseEntity.notFound().build();
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteApartment(@PathVariable String id) {
return apartmentService.delete(id);
}
}
@@ -11,34 +11,34 @@ import org.springframework.web.multipart.MaxUploadSizeExceededException;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(AttachmentTooLargeException.class)
public ResponseEntity<ApiError> handleAttachmentTooLarge(AttachmentTooLargeException exception) {
return error(HttpStatus.CONTENT_TOO_LARGE, exception.getMessage());
}
@ExceptionHandler(AttachmentTooLargeException.class)
public ResponseEntity<ApiError> handleAttachmentTooLarge(AttachmentTooLargeException exception) {
return error(HttpStatus.CONTENT_TOO_LARGE, exception.getMessage());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiError> handleMultipartTooLarge() {
return error(HttpStatus.CONTENT_TOO_LARGE, "Une pièce jointe ne peut pas dépasser 250 Mo.");
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiError> handleMultipartTooLarge() {
return error(HttpStatus.CONTENT_TOO_LARGE, "Une pièce jointe ne peut pas dépasser 250 Mo.");
}
@ExceptionHandler(InvalidPhoneNumberException.class)
public ResponseEntity<ApiError> handleInvalidPhone(InvalidPhoneNumberException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(InvalidPhoneNumberException.class)
public ResponseEntity<ApiError> handleInvalidPhone(InvalidPhoneNumberException exception) {
return error(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("La requête est invalide.");
return error(HttpStatus.BAD_REQUEST, message);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleInvalidRequest(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("La requête est invalide.");
return error(HttpStatus.BAD_REQUEST, message);
}
private ResponseEntity<ApiError> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(new ApiError(message));
}
private ResponseEntity<ApiError> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(new ApiError(message));
}
private record ApiError(String message) {
}
private record ApiError(String message) {
}
}
@@ -1,132 +1,51 @@
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 fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.service.LeaseService;
import jakarta.validation.Valid;
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;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final LeaseService leaseService;
public LeaseController(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository;
this.leasePdfService = leasePdfService;
this.phoneNumberNormalizer = phoneNumberNormalizer;
}
public LeaseController(LeaseService leaseService) {
this.leaseService = leaseService;
}
@GetMapping
public List<Lease> getAllLeases() {
return leaseRepository.findAll();
}
@GetMapping
public List<LeaseResponse> getAllLeases() {
return leaseService.getAll();
}
@GetMapping("/{id}")
public ResponseEntity<Lease> getLease(@PathVariable String id) {
return leaseRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{id}")
public ResponseEntity<LeaseResponse> getLease(@PathVariable String id) {
return leaseService.getById(id);
}
@PostMapping
public ResponseEntity<Lease> createLease(@Valid @RequestBody Lease lease) {
if (!referencesExist(lease)) {
return ResponseEntity.badRequest().build();
}
lease.setId(null);
lease.setLandlordId("default-landlord");
normalizeGuarantorPhones(lease);
return ResponseEntity.status(HttpStatus.CREATED).body(leaseRepository.save(lease));
}
@PostMapping
public ResponseEntity<LeaseResponse> createLease(@Valid @RequestBody LeaseRequest request) {
return leaseService.create(request);
}
@PutMapping("/{id}")
public ResponseEntity<Lease> updateLease(@PathVariable String id, @Valid @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());
}
@PutMapping("/{id}")
public ResponseEntity<LeaseResponse> updateLease(@PathVariable String id,
@Valid @RequestBody LeaseRequest request) {
return leaseService.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteLease(@PathVariable String id) {
if (!leaseRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
leaseRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteLease(@PathVariable String id) {
return leaseService.delete(id);
}
@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());
normalizeGuarantorPhones(target);
target.setStartDate(source.getStartDate());
target.setEndDate(source.getEndDate());
target.setMonthlyRent(source.getMonthlyRent());
target.setMonthlyCharges(source.getMonthlyCharges());
target.setSecurityDeposit(source.getSecurityDeposit());
target.setStatus(source.getStatus());
}
private void normalizeGuarantorPhones(Lease lease) {
lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
}
@GetMapping("/{id}/pdf")
public ResponseEntity<byte[]> downloadPdf(@PathVariable String id) {
return leaseService.downloadPdf(id);
}
}
@@ -1,16 +1,11 @@
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 fr.nessar.lmnporganizer.backend.service.AttachmentUploadPolicy;
import fr.nessar.lmnporganizer.backend.service.PhoneNumberNormalizer;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
import fr.nessar.lmnporganizer.backend.service.TenantService;
import jakarta.validation.Valid;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -22,110 +17,53 @@ import java.util.List;
@RestController
@RequestMapping("/api/tenants")
public class TenantController {
private final TenantRepository tenantRepository;
private final TenantAttachmentStorage attachmentStorage;
private final AttachmentUploadPolicy attachmentUploadPolicy;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final TenantService tenantService;
public TenantController(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage,
AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer) {
this.tenantRepository = tenantRepository;
this.attachmentStorage = attachmentStorage;
this.attachmentUploadPolicy = attachmentUploadPolicy;
this.phoneNumberNormalizer = phoneNumberNormalizer;
}
public TenantController(TenantService tenantService) {
this.tenantService = tenantService;
}
@GetMapping
public List<Tenant> getAllTenants() {
return tenantRepository.findAll();
}
@GetMapping
public List<TenantResponse> getAllTenants() {
return tenantService.getAll();
}
@GetMapping("/{id}")
public ResponseEntity<Tenant> getTenant(@PathVariable String id) {
return tenantRepository.findById(id).map(ResponseEntity::ok).orElse(ResponseEntity.notFound().build());
}
@GetMapping("/{id}")
public ResponseEntity<TenantResponse> getTenant(@PathVariable String id) {
return tenantService.getById(id);
}
@PostMapping
public ResponseEntity<Tenant> createTenant(@Valid @RequestBody Tenant tenant) {
tenant.setId(null);
tenant.setLandlordId("default-landlord");
tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone()));
return ResponseEntity.status(HttpStatus.CREATED).body(tenantRepository.save(tenant));
}
@PostMapping
public ResponseEntity<TenantResponse> createTenant(@Valid @RequestBody TenantRequest request) {
return tenantService.create(request);
}
@PutMapping("/{id}")
public ResponseEntity<Tenant> updateTenant(@PathVariable String id, @Valid @RequestBody Tenant details) {
return tenantRepository.findById(id).map(tenant -> {
tenant.setFirstName(details.getFirstName());
tenant.setLastName(details.getLastName());
tenant.setEmail(details.getEmail());
tenant.setPhone(phoneNumberNormalizer.normalize(details.getPhone()));
tenant.setAddress(details.getAddress());
tenant.setLandlordId("default-landlord");
return ResponseEntity.ok(tenantRepository.save(tenant));
}).orElse(ResponseEntity.notFound().build());
}
@PutMapping("/{id}")
public ResponseEntity<TenantResponse> updateTenant(@PathVariable String id,
@Valid @RequestBody TenantRequest request) {
return tenantService.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTenant(@PathVariable String id) {
if (!tenantRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
tenantRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTenant(@PathVariable String id) {
return tenantService.delete(id);
}
@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();
}
attachmentUploadPolicy.validate(file);
TenantAttachment attachment = attachmentStorage.store(tenantId, file);
tenant.getAttachments().add(attachment);
tenantRepository.save(tenant);
return ResponseEntity.status(HttpStatus.CREATED).body(attachment);
}
@PostMapping(path = "/{tenantId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<TenantAttachmentResponse> uploadAttachment(@PathVariable String tenantId,
@RequestPart("file") MultipartFile file) throws IOException {
return tenantService.uploadAttachment(tenantId, file);
}
@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));
}
@GetMapping("/{tenantId}/attachments/{attachmentId}")
public ResponseEntity<Resource> downloadAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
throws IOException {
return tenantService.downloadAttachment(tenantId, attachmentId);
}
@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();
}
@DeleteMapping("/{tenantId}/attachments/{attachmentId}")
public ResponseEntity<Void> deleteAttachment(@PathVariable String tenantId, @PathVariable String attachmentId)
throws IOException {
return tenantService.deleteAttachment(tenantId, attachmentId);
}
}
@@ -1,26 +1,24 @@
package fr.nessar.lmnporganizer.backend.controller;
import fr.nessar.lmnporganizer.backend.dto.UserResponse;
import fr.nessar.lmnporganizer.backend.service.UserService;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
@GetMapping("/user")
public Map<String, Object> getUser(@AuthenticationPrincipal OidcUser principal) {
if (principal == null) {
return Map.of("authenticated", false);
}
return Map.of(
"authenticated", true,
"username", principal.getPreferredUsername() != null ? principal.getPreferredUsername() : principal.getName(),
"email", principal.getEmail() != null ? principal.getEmail() : ""
);
}
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/user")
public UserResponse getUser(@AuthenticationPrincipal OidcUser principal) {
return userService.getUser(principal);
}
}
@@ -0,0 +1,4 @@
package fr.nessar.lmnporganizer.backend.dto;
public record ApartmentRequest(String name, String address, double purchasePrice, String description) {
}
@@ -0,0 +1,4 @@
package fr.nessar.lmnporganizer.backend.dto;
public record ApartmentResponse(String id, String name, String address, double purchasePrice, String description) {
}
@@ -0,0 +1,14 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record GuarantorDto(
String id,
@NotBlank(message = "Le prénom du garant est obligatoire.") String firstName,
@NotBlank(message = "Le nom du garant est obligatoire.") String lastName,
@NotBlank(message = "L'adresse e-mail du garant est obligatoire.") @Email(message = "L'adresse e-mail du garant est invalide.") String email,
String phone,
String address
) {
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import jakarta.validation.Valid;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public record LeaseRequest(
String apartmentId,
List<String> tenantIds,
List<@Valid GuarantorDto> guarantors,
LocalDate startDate,
LocalDate endDate,
BigDecimal monthlyRent,
BigDecimal monthlyCharges,
BigDecimal securityDeposit,
LeaseStatus status
) {
}
@@ -0,0 +1,21 @@
package fr.nessar.lmnporganizer.backend.dto;
import fr.nessar.lmnporganizer.backend.model.LeaseStatus;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public record LeaseResponse(
String id,
String apartmentId,
List<String> tenantIds,
List<GuarantorDto> guarantors,
LocalDate startDate,
LocalDate endDate,
BigDecimal monthlyRent,
BigDecimal monthlyCharges,
BigDecimal securityDeposit,
LeaseStatus status
) {
}
@@ -0,0 +1,4 @@
package fr.nessar.lmnporganizer.backend.dto;
public record TenantAttachmentResponse(String id, String fileName, String contentType, long size) {
}
@@ -0,0 +1,13 @@
package fr.nessar.lmnporganizer.backend.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record TenantRequest(
@NotBlank(message = "Le prénom est obligatoire.") String firstName,
@NotBlank(message = "Le nom est obligatoire.") String lastName,
@NotBlank(message = "L'adresse e-mail est obligatoire.") @Email(message = "L'adresse e-mail est invalide.") String email,
String phone,
String address
) {
}
@@ -0,0 +1,14 @@
package fr.nessar.lmnporganizer.backend.dto;
import java.util.List;
public record TenantResponse(
String id,
String firstName,
String lastName,
String email,
String phone,
String address,
List<TenantAttachmentResponse> attachments
) {
}
@@ -0,0 +1,4 @@
package fr.nessar.lmnporganizer.backend.dto;
public record UserResponse(boolean authenticated, String username, String email) {
}
@@ -0,0 +1,28 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface ApartmentMapper {
ApartmentResponse toResponse(Apartment apartment);
List<ApartmentResponse> toResponses(List<Apartment> apartments);
@Mapping(target = "id", ignore = true)
@Mapping(target = "bailleurId", ignore = true)
Apartment toEntity(ApartmentRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "bailleurId", ignore = true)
void update(ApartmentRequest request, @MappingTarget Apartment target);
}
@@ -0,0 +1,34 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.GuarantorDto;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.model.Guarantor;
import fr.nessar.lmnporganizer.backend.model.Lease;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface LeaseMapper {
LeaseResponse toResponse(Lease lease);
List<LeaseResponse> toResponses(List<Lease> leases);
GuarantorDto toDto(Guarantor guarantor);
Guarantor toEntity(GuarantorDto guarantor);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
Lease toEntity(LeaseRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
void update(LeaseRequest request, @MappingTarget Lease target);
}
@@ -0,0 +1,34 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingConstants;
import org.mapstruct.MappingTarget;
import org.mapstruct.ReportingPolicy;
import java.util.List;
@Mapper(componentModel = MappingConstants.ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface TenantMapper {
TenantResponse toResponse(Tenant tenant);
List<TenantResponse> toResponses(List<Tenant> tenants);
TenantAttachmentResponse toResponse(TenantAttachment attachment);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "attachments", ignore = true)
Tenant toEntity(TenantRequest request);
@Mapping(target = "id", ignore = true)
@Mapping(target = "landlordId", ignore = true)
@Mapping(target = "attachments", ignore = true)
void update(TenantRequest request, @MappingTarget Tenant target);
}
@@ -2,6 +2,8 @@ package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Lease;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface LeaseRepository extends MongoRepository<Lease, String> {
}
@@ -2,6 +2,8 @@ package fr.nessar.lmnporganizer.backend.repository;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TenantRepository extends MongoRepository<Tenant, String> {
}
@@ -0,0 +1,54 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.ApartmentResponse;
import fr.nessar.lmnporganizer.backend.mapper.ApartmentMapper;
import fr.nessar.lmnporganizer.backend.model.Apartment;
import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ApartmentService {
private final ApartmentRepository apartmentRepository;
private final ApartmentMapper apartmentMapper;
public ApartmentService(ApartmentRepository apartmentRepository, ApartmentMapper apartmentMapper) {
this.apartmentRepository = apartmentRepository;
this.apartmentMapper = apartmentMapper;
}
public List<ApartmentResponse> getAll() {
return apartmentMapper.toResponses(apartmentRepository.findAll());
}
public ResponseEntity<ApartmentResponse> getById(String id) {
return apartmentRepository.findById(id).map(apartmentMapper::toResponse).map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<ApartmentResponse> create(ApartmentRequest request) {
Apartment apartment = apartmentMapper.toEntity(request);
apartment.setBailleurId("default-landlord");
return ResponseEntity.status(HttpStatus.CREATED).body(apartmentMapper.toResponse(apartmentRepository.save(apartment)));
}
public ResponseEntity<ApartmentResponse> update(String id, ApartmentRequest request) {
return apartmentRepository.findById(id).map(apartment -> {
apartmentMapper.update(request, apartment);
apartment.setBailleurId("default-landlord");
return ResponseEntity.ok(apartmentMapper.toResponse(apartmentRepository.save(apartment)));
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Void> delete(String id) {
if (!apartmentRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
apartmentRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,15 @@
package fr.nessar.lmnporganizer.backend.service;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
@Service
public class GreetingService {
public String greetingFor(OAuth2User user) {
String name = user.getAttribute("preferred_username");
if (name == null) {
name = user.getAttribute("name");
}
return "Hello, " + (name == null ? user.getName() : name) + "!";
}
}
@@ -0,0 +1,117 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseResponse;
import fr.nessar.lmnporganizer.backend.mapper.LeaseMapper;
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 org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@Service
public class LeaseService {
private final LeaseRepository leaseRepository;
private final ApartmentRepository apartmentRepository;
private final TenantRepository tenantRepository;
private final LeasePdfService leasePdfService;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final LeaseMapper leaseMapper;
public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository,
TenantRepository tenantRepository, LeasePdfService leasePdfService,
PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper) {
this.leaseRepository = leaseRepository;
this.apartmentRepository = apartmentRepository;
this.tenantRepository = tenantRepository;
this.leasePdfService = leasePdfService;
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.leaseMapper = leaseMapper;
}
public List<LeaseResponse> getAll() {
return leaseMapper.toResponses(leaseRepository.findAll());
}
public ResponseEntity<LeaseResponse> getById(String id) {
return leaseRepository.findById(id).map(leaseMapper::toResponse).map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<LeaseResponse> create(LeaseRequest request) {
Lease lease = leaseMapper.toEntity(request);
if (!referencesExist(lease)) {
return ResponseEntity.badRequest().build();
}
lease.setLandlordId("default-landlord");
normalizeGuarantorPhones(lease);
return ResponseEntity.status(HttpStatus.CREATED).body(leaseMapper.toResponse(leaseRepository.save(lease)));
}
public ResponseEntity<LeaseResponse> update(String id, LeaseRequest request) {
Lease details = leaseMapper.toEntity(request);
if (!referencesExist(details)) {
return ResponseEntity.badRequest().build();
}
return leaseRepository.findById(id).map(lease -> {
leaseMapper.update(request, lease);
lease.setLandlordId("default-landlord");
normalizeGuarantorPhones(lease);
return ResponseEntity.ok(leaseMapper.toResponse(leaseRepository.save(lease)));
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Void> delete(String id) {
if (!leaseRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
leaseRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
public ResponseEntity<byte[]> downloadPdf(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 normalizeGuarantorPhones(Lease lease) {
lease.getGuarantors().forEach(guarantor -> guarantor.setPhone(phoneNumberNormalizer.normalize(guarantor.getPhone())));
}
}
@@ -0,0 +1,122 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.TenantAttachmentResponse;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantResponse;
import fr.nessar.lmnporganizer.backend.mapper.TenantMapper;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@Service
public class TenantService {
private final TenantRepository tenantRepository;
private final TenantAttachmentStorage attachmentStorage;
private final AttachmentUploadPolicy attachmentUploadPolicy;
private final PhoneNumberNormalizer phoneNumberNormalizer;
private final TenantMapper tenantMapper;
public TenantService(TenantRepository tenantRepository, TenantAttachmentStorage attachmentStorage,
AttachmentUploadPolicy attachmentUploadPolicy, PhoneNumberNormalizer phoneNumberNormalizer,
TenantMapper tenantMapper) {
this.tenantRepository = tenantRepository;
this.attachmentStorage = attachmentStorage;
this.attachmentUploadPolicy = attachmentUploadPolicy;
this.phoneNumberNormalizer = phoneNumberNormalizer;
this.tenantMapper = tenantMapper;
}
public List<TenantResponse> getAll() {
return tenantMapper.toResponses(tenantRepository.findAll());
}
public ResponseEntity<TenantResponse> getById(String id) {
return tenantRepository.findById(id).map(tenantMapper::toResponse).map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<TenantResponse> create(TenantRequest request) {
Tenant tenant = tenantMapper.toEntity(request);
tenant.setLandlordId("default-landlord");
tenant.setPhone(phoneNumberNormalizer.normalize(tenant.getPhone()));
return ResponseEntity.status(HttpStatus.CREATED).body(tenantMapper.toResponse(tenantRepository.save(tenant)));
}
public ResponseEntity<TenantResponse> update(String id, TenantRequest request) {
return tenantRepository.findById(id).map(tenant -> {
tenantMapper.update(request, tenant);
tenant.setPhone(phoneNumberNormalizer.normalize(request.phone()));
tenant.setLandlordId("default-landlord");
return ResponseEntity.ok(tenantMapper.toResponse(tenantRepository.save(tenant)));
}).orElse(ResponseEntity.notFound().build());
}
public ResponseEntity<Void> delete(String id) {
if (!tenantRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
tenantRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
public ResponseEntity<TenantAttachmentResponse> uploadAttachment(String tenantId, MultipartFile file) throws IOException {
Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
if (tenant == null) {
return ResponseEntity.notFound().build();
}
attachmentUploadPolicy.validate(file);
TenantAttachment attachment = attachmentStorage.store(tenantId, file);
tenant.getAttachments().add(attachment);
tenantRepository.save(tenant);
return ResponseEntity.status(HttpStatus.CREATED).body(tenantMapper.toResponse(attachment));
}
public ResponseEntity<Resource> downloadAttachment(String tenantId, String attachmentId) throws IOException {
TenantAttachment attachment = tenantRepository.findById(tenantId)
.flatMap(tenant -> tenant.getAttachments().stream().filter(item -> item.getId().equals(attachmentId)).findFirst())
.orElse(null);
if (attachment == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.contentType(mediaTypeFor(attachment))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(attachment.getFileName()).build().toString())
.body(attachmentStorage.read(attachment));
}
public ResponseEntity<Void> deleteAttachment(String tenantId, String attachmentId) throws IOException {
Tenant tenant = tenantRepository.findById(tenantId).orElse(null);
if (tenant == null) {
return ResponseEntity.notFound().build();
}
TenantAttachment attachment = tenant.getAttachments().stream()
.filter(item -> item.getId().equals(attachmentId)).findFirst().orElse(null);
if (attachment == null) {
return ResponseEntity.notFound().build();
}
attachmentStorage.delete(attachment);
tenant.getAttachments().remove(attachment);
tenantRepository.save(tenant);
return ResponseEntity.noContent().build();
}
private MediaType mediaTypeFor(TenantAttachment attachment) {
try {
return MediaType.parseMediaType(attachment.getContentType());
} catch (IllegalArgumentException exception) {
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
@@ -0,0 +1,17 @@
package fr.nessar.lmnporganizer.backend.service;
import fr.nessar.lmnporganizer.backend.dto.UserResponse;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;
@Service
public class UserService {
public UserResponse getUser(OidcUser principal) {
if (principal == null) {
return new UserResponse(false, null, null);
}
String username = principal.getPreferredUsername() != null ? principal.getPreferredUsername() : principal.getName();
String email = principal.getEmail() != null ? principal.getEmail() : "";
return new UserResponse(true, username, email);
}
}
@@ -77,7 +77,7 @@ class ApartmentControllerTest {
"{\"name\":\"New Apartment\",\"address\":\"Address\",\"purchasePrice\":200000.0,\"description\":\"Desc\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("1"))
.andExpect(jsonPath("$.bailleurId").value("default-landlord"));
.andExpect(jsonPath("$.bailleurId").doesNotExist());
}
@Test
@@ -61,7 +61,7 @@ class LeaseControllerTest {
.content(leaseJson()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("lease-1"))
.andExpect(jsonPath("$.landlordId").value("default-landlord"))
.andExpect(jsonPath("$.landlordId").doesNotExist())
.andExpect(jsonPath("$.apartmentId").value("apartment-1"))
.andExpect(jsonPath("$.tenantIds[0]").value("tenant-1"))
.andExpect(jsonPath("$.guarantors[0].lastName").value("Durand"))
@@ -58,7 +58,7 @@ class TenantControllerTest {
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("tenant-1"))
.andExpect(jsonPath("$.landlordId").value("default-landlord"));
.andExpect(jsonPath("$.landlordId").doesNotExist());
}
@Test
@@ -0,0 +1,63 @@
package fr.nessar.lmnporganizer.backend.mapper;
import fr.nessar.lmnporganizer.backend.dto.ApartmentRequest;
import fr.nessar.lmnporganizer.backend.dto.LeaseRequest;
import fr.nessar.lmnporganizer.backend.dto.TenantRequest;
import fr.nessar.lmnporganizer.backend.model.Apartment;
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.model.TenantAttachment;
import org.junit.jupiter.api.Test;
import org.mapstruct.factory.Mappers;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
class UpdateMapperTest {
@Test
void preservesApartmentIdentityWhenUpdatingItsDetails() {
Apartment target = new Apartment("apartment-1", "default-landlord", "Old", "Old", 1, "Old");
ApartmentRequest source = new ApartmentRequest("New", "New", 2, "New");
Mappers.getMapper(ApartmentMapper.class).update(source, target);
assertEquals("apartment-1", target.getId());
assertEquals("default-landlord", target.getBailleurId());
assertEquals("New", target.getName());
}
@Test
void preservesTenantOwnershipAndAttachmentsWhenUpdatingContactDetails() {
Tenant target = new Tenant("tenant-1", "default-landlord", "Old", "Name", "old@example.com", "", "");
target.setAttachments(List.of(new TenantAttachment("attachment-1", "id.pdf", "application/pdf", 1, "id.pdf")));
TenantRequest source = new TenantRequest("New", "Name", "new@example.com", "", "");
Mappers.getMapper(TenantMapper.class).update(source, target);
assertEquals("tenant-1", target.getId());
assertEquals("default-landlord", target.getLandlordId());
assertEquals(1, target.getAttachments().size());
assertEquals("new@example.com", target.getEmail());
}
@Test
void preservesLeaseIdentityAndOwnershipWhenUpdatingTerms() {
Lease target = new Lease("lease-1", "default-landlord", "old-apartment", List.of(), List.of(),
LocalDate.of(2026, 1, 1), LocalDate.of(2027, 1, 1), BigDecimal.ONE, BigDecimal.ZERO,
BigDecimal.ONE, LeaseStatus.DRAFT);
LeaseRequest source = new LeaseRequest("new-apartment", List.of("tenant-1"), List.of(),
LocalDate.of(2026, 2, 1), LocalDate.of(2027, 2, 1), BigDecimal.TEN, BigDecimal.ONE,
BigDecimal.TEN, LeaseStatus.ACTIVE);
Mappers.getMapper(LeaseMapper.class).update(source, target);
assertEquals("lease-1", target.getId());
assertEquals("default-landlord", target.getLandlordId());
assertEquals("new-apartment", target.getApartmentId());
assertEquals(LeaseStatus.ACTIVE, target.getStatus());
}
}