diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java index c2c86ec..c3539aa 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/ApiExceptionHandler.java @@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.controller; import fr.nessar.lmnporganizer.backend.exception.AttachmentTooLargeException; import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; import fr.nessar.lmnporganizer.backend.exception.InvalidPhoneNumberException; +import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -32,6 +33,11 @@ public class ApiExceptionHandler { return error(HttpStatus.CONFLICT, exception.getMessage()); } + @ExceptionHandler(IncompleteLandlordProfileException.class) + public ResponseEntity handleIncompleteLandlordProfile(IncompleteLandlordProfileException exception) { + return error(HttpStatus.UNPROCESSABLE_CONTENT, exception.getMessage()); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleInvalidRequest(MethodArgumentNotValidException exception) { String message = exception.getBindingResult().getFieldErrors().stream() diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java index 8f5851a..4d7defa 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/LeaseController.java @@ -5,6 +5,8 @@ import fr.nessar.lmnporganizer.backend.dto.LeaseResponse; import fr.nessar.lmnporganizer.backend.service.LeaseService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -45,7 +47,7 @@ public class LeaseController { } @GetMapping("/{id}/pdf") - public ResponseEntity downloadPdf(@PathVariable String id) { - return leaseService.downloadPdf(id); + public ResponseEntity downloadPdf(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) { + return leaseService.downloadPdf(id, principal.getSubject()); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java index 6c2473c..df6287f 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentController.java @@ -3,6 +3,8 @@ package fr.nessar.lmnporganizer.backend.controller; import fr.nessar.lmnporganizer.backend.dto.RentPaymentResponse; import fr.nessar.lmnporganizer.backend.service.RentPaymentService; import org.springframework.http.ResponseEntity; +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.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -33,7 +35,7 @@ public class RentPaymentController { } @GetMapping("/{id}/receipt") - public ResponseEntity downloadReceipt(@PathVariable String id) { - return rentPaymentService.downloadReceipt(id); + public ResponseEntity downloadReceipt(@PathVariable String id, @AuthenticationPrincipal OidcUser principal) { + return rentPaymentService.downloadReceipt(id, principal.getSubject()); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java new file mode 100644 index 0000000..9b0aac6 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/exception/IncompleteLandlordProfileException.java @@ -0,0 +1,7 @@ +package fr.nessar.lmnporganizer.backend.exception; + +public class IncompleteLandlordProfileException extends RuntimeException { + public IncompleteLandlordProfileException() { + super("Votre profil bailleur est incomplet. Complétez-le dans les réglages avant de générer un PDF."); + } +} diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java index 39a9045..b4f6925 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LandlordProfileService.java @@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.service; import fr.nessar.lmnporganizer.backend.dto.LandlordProfileRequest; import fr.nessar.lmnporganizer.backend.dto.LandlordProfileResponse; import fr.nessar.lmnporganizer.backend.exception.EmailAlreadyUsedException; +import fr.nessar.lmnporganizer.backend.exception.IncompleteLandlordProfileException; import fr.nessar.lmnporganizer.backend.model.Landlord; import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; import org.springframework.dao.DuplicateKeyException; @@ -43,6 +44,15 @@ public class LandlordProfileService { return response(save(landlord)); } + public Landlord requireCompleteProfileFor(String oidcSubject) { + Landlord landlord = landlordRepository.findByOidcSubject(oidcSubject) + .orElseThrow(IncompleteLandlordProfileException::new); + if (isIncomplete(landlord)) { + throw new IncompleteLandlordProfileException(); + } + return landlord; + } + private Landlord save(Landlord landlord) { try { return landlordRepository.save(landlord); @@ -64,4 +74,13 @@ public class LandlordProfileService { private String value(String value) { return value == null ? "" : value; } + + private boolean isIncomplete(Landlord landlord) { + return isBlank(landlord.getFirstName()) || isBlank(landlord.getLastName()) || isBlank(landlord.getEmail()) + || isBlank(landlord.getPhone()) || isBlank(landlord.getAddress()); + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java index 0cb4bab..ce2f50f 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeasePdfService.java @@ -3,6 +3,7 @@ package fr.nessar.lmnporganizer.backend.service; import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; import fr.nessar.lmnporganizer.backend.model.Apartment; import fr.nessar.lmnporganizer.backend.model.Lease; +import fr.nessar.lmnporganizer.backend.model.Landlord; import fr.nessar.lmnporganizer.backend.model.Tenant; import org.springframework.stereotype.Service; import org.thymeleaf.TemplateEngine; @@ -21,10 +22,11 @@ public class LeasePdfService { this.templateEngine = templateEngine; } - public byte[] generate(Lease lease, Apartment apartment, List tenants) { + public byte[] generate(Lease lease, Apartment apartment, Landlord landlord, List tenants) { Context context = new Context(Locale.FRANCE); context.setVariable("lease", lease); context.setVariable("apartment", apartment); + context.setVariable("landlord", landlord); context.setVariable("tenantNames", tenantNamesOf(tenants)); context.setVariable("guarantorNames", namesOf(lease.getGuarantors().stream() .map(guarantor -> guarantor.getFirstName() + " " + guarantor.getLastName()).toList())); diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java index b523c2c..a50995a 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/LeaseService.java @@ -5,6 +5,7 @@ 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.Landlord; import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; @@ -29,11 +30,12 @@ public class LeaseService { private final PhoneNumberNormalizer phoneNumberNormalizer; private final LeaseMapper leaseMapper; private final RentPaymentService rentPaymentService; + private final LandlordProfileService landlordProfileService; public LeaseService(LeaseRepository leaseRepository, ApartmentRepository apartmentRepository, TenantRepository tenantRepository, LeasePdfService leasePdfService, PhoneNumberNormalizer phoneNumberNormalizer, LeaseMapper leaseMapper, - RentPaymentService rentPaymentService) { + RentPaymentService rentPaymentService, LandlordProfileService landlordProfileService) { this.leaseRepository = leaseRepository; this.apartmentRepository = apartmentRepository; this.tenantRepository = tenantRepository; @@ -41,6 +43,7 @@ public class LeaseService { this.phoneNumberNormalizer = phoneNumberNormalizer; this.leaseMapper = leaseMapper; this.rentPaymentService = rentPaymentService; + this.landlordProfileService = landlordProfileService; } public List getAll() { @@ -92,7 +95,7 @@ public class LeaseService { return ResponseEntity.noContent().build(); } - public ResponseEntity downloadPdf(String id) { + public ResponseEntity downloadPdf(String id, String oidcSubject) { Lease lease = leaseRepository.findById(id).orElse(null); if (lease == null) { return ResponseEntity.notFound().build(); @@ -102,11 +105,12 @@ public class LeaseService { if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) { return ResponseEntity.notFound().build(); } + Landlord landlord = landlordProfileService.requireCompleteProfileFor(oidcSubject); 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)); + .body(leasePdfService.generate(lease, apartment, landlord, tenants)); } private boolean referencesExist(Lease lease) { diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java index 2dcffbd..8c4e711 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/service/RentPaymentService.java @@ -8,7 +8,6 @@ import fr.nessar.lmnporganizer.backend.model.RentPayment; 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.LandlordRepository; import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import org.springframework.http.ContentDisposition; @@ -31,19 +30,19 @@ public class RentPaymentService { private final RentPaymentRepository rentPaymentRepository; private final LeaseRepository leaseRepository; private final ApartmentRepository apartmentRepository; - private final LandlordRepository landlordRepository; + private final LandlordProfileService landlordProfileService; private final TenantRepository tenantRepository; private final RentReceiptPdfService rentReceiptPdfService; private final Clock clock; public RentPaymentService(RentPaymentRepository rentPaymentRepository, LeaseRepository leaseRepository, - ApartmentRepository apartmentRepository, LandlordRepository landlordRepository, + ApartmentRepository apartmentRepository, LandlordProfileService landlordProfileService, TenantRepository tenantRepository, RentReceiptPdfService rentReceiptPdfService, Clock clock) { this.rentPaymentRepository = rentPaymentRepository; this.leaseRepository = leaseRepository; this.apartmentRepository = apartmentRepository; - this.landlordRepository = landlordRepository; + this.landlordProfileService = landlordProfileService; this.tenantRepository = tenantRepository; this.rentReceiptPdfService = rentReceiptPdfService; this.clock = clock; @@ -65,7 +64,7 @@ public class RentPaymentService { }).orElse(ResponseEntity.notFound().build()); } - public ResponseEntity downloadReceipt(String id) { + public ResponseEntity downloadReceipt(String id, String oidcSubject) { RentPayment payment = rentPaymentRepository.findById(id).orElse(null); if (payment == null) return ResponseEntity.notFound().build(); @@ -76,9 +75,9 @@ public class RentPaymentService { if (lease == null) return ResponseEntity.notFound().build(); Apartment apartment = apartmentRepository.findById(lease.getApartmentId()).orElse(null); - Landlord landlord = landlordRepository.findById(payment.getLandlordId()).orElse(null); + Landlord landlord = landlordProfileService.requireCompleteProfileFor(oidcSubject); List tenants = tenantsFor(lease.getTenantIds()); - if (apartment == null || landlord == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) { + if (apartment == null || tenants.size() != new HashSet<>(lease.getTenantIds()).size()) { return ResponseEntity.notFound().build(); } diff --git a/backend/src/main/resources/templates/lease.html b/backend/src/main/resources/templates/lease.html index 1140321..ea84b05 100644 --- a/backend/src/main/resources/templates/lease.html +++ b/backend/src/main/resources/templates/lease.html @@ -19,6 +19,10 @@ + + + + diff --git a/backend/src/main/resources/templates/rent-receipt.html b/backend/src/main/resources/templates/rent-receipt.html index 7f55424..72981e5 100644 --- a/backend/src/main/resources/templates/rent-receipt.html +++ b/backend/src/main/resources/templates/rent-receipt.html @@ -17,6 +17,8 @@

Le bailleur reconnaît avoir reçu le paiement du loyer et des charges pour la période indiquée.

LogementStudio République
Adresse12 rue de la République
BailleurLéa Dubois
E-mail du bailleurlea@example.com
Téléphone du bailleur+33612345678
Adresse du bailleur1 rue de Paris
Locataire(s)Camille Martin
Période2026-09-01 au 2027-08-31
Loyer mensuel850.00 EUR
+ + diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java index 43e8591..dcf364d 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/LeaseControllerTest.java @@ -4,8 +4,10 @@ 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.Landlord; import fr.nessar.lmnporganizer.backend.model.Tenant; import fr.nessar.lmnporganizer.backend.repository.ApartmentRepository; +import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import fr.nessar.lmnporganizer.backend.service.RentPaymentService; @@ -27,6 +29,7 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertTrue; 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.*; @@ -46,6 +49,9 @@ class LeaseControllerTest { @MockitoBean private TenantRepository tenantRepository; + @MockitoBean + private LandlordRepository landlordRepository; + @MockitoBean private RentPaymentService rentPaymentService; @@ -142,8 +148,10 @@ class LeaseControllerTest { 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())); + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(landlord())); - mockMvc.perform(get("/api/leases/lease-1/pdf").with(oidcLogin())) + mockMvc.perform(get("/api/leases/lease-1/pdf") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))) .andExpect(status().isOk()) .andExpect(header().string("Content-Type", "application/pdf")) .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("bail-lease-1.pdf"))) @@ -154,7 +162,9 @@ class LeaseControllerTest { } try (PDDocument document = Loader.loadPDF(content)) { String pdfText = new PDFTextStripper().getText(document); - if (!pdfText.contains("Studio République") || !pdfText.contains("Camille Martin")) { + if (!pdfText.contains("Studio République") || !pdfText.contains("Camille Martin") + || !pdfText.contains("Léa Dubois") || !pdfText.contains("lea@example.com") + || !pdfText.contains("+33612345678") || !pdfText.contains("1 rue de Paris")) { throw new AssertionError("Expected rendered lease template values"); } } catch (java.io.IOException exception) { @@ -163,11 +173,66 @@ class LeaseControllerTest { }); } + @Test + void whenLandlordProfileIsIncomplete_thenLeasePdfDownloadIsRejectedWithSettingsGuidance() throws Exception { + when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease())); + when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(new Apartment("apartment-1", + "default-landlord", "Studio République", "12 rue de la République", 150000, ""))); + when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(new Landlord("profile-1", + "subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678", ""))); + + mockMvc.perform(get("/api/leases/lease-1/pdf") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))) + .andExpect(status().isUnprocessableContent()) + .andExpect(jsonPath("$.message").value( + "Votre profil bailleur est incomplet. Complétez-le dans les réglages avant de générer un PDF.")); + } + + @Test + void whenLandlordProfileChanges_thenTheNextLeasePdfUsesItsUpdatedContactDetails() throws Exception { + when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease())); + when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(new Apartment("apartment-1", + "default-landlord", "Studio République", "12 rue de la République", 150000, ""))); + when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); + Landlord updatedLandlord = new Landlord("profile-1", "subject-1", "Alice", "Renard", + "alice@example.com", "+33698765432", "2 rue de Lyon"); + when(landlordRepository.findByOidcSubject("subject-1")) + .thenReturn(Optional.of(landlord())) + .thenReturn(Optional.of(updatedLandlord)); + + String firstPdfText = downloadedLeasePdfText(); + String updatedPdfText = downloadedLeasePdfText(); + + assertTrue(firstPdfText.contains("Léa Dubois")); + assertTrue(updatedPdfText.contains("Alice Renard")); + assertTrue(updatedPdfText.contains("alice@example.com")); + assertTrue(updatedPdfText.contains("+33698765432")); + assertTrue(updatedPdfText.contains("2 rue de Lyon")); + } + private Tenant tenant() { return new Tenant("tenant-1", "default-landlord", "Camille", "Martin", "camille@example.com", "+33612345678", "10 rue des Lilas"); } + private Landlord landlord() { + return new Landlord("profile-1", "subject-1", "Léa", "Dubois", "lea@example.com", "+33612345678", + "1 rue de Paris"); + } + + private String downloadedLeasePdfText() throws Exception { + byte[] content = mockMvc.perform(get("/api/leases/lease-1/pdf") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsByteArray(); + try (PDDocument document = Loader.loadPDF(content)) { + return new PDFTextStripper().getText(document); + } + } + 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", diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java index ca5c123..a390348 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/RentPaymentControllerTest.java @@ -11,6 +11,9 @@ import fr.nessar.lmnporganizer.backend.repository.LandlordRepository; import fr.nessar.lmnporganizer.backend.repository.LeaseRepository; import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -104,13 +107,43 @@ class RentPaymentControllerTest { when(rentPaymentRepository.findById("payment-1")).thenReturn(Optional.of(payment(LocalDate.of(2026, 7, 2)))); when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease())); when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment())); - when(landlordRepository.findById("default-landlord")).thenReturn(Optional.of(landlord())); + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(landlord())); when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); - mockMvc.perform(get("/api/rent-payments/payment-1/receipt").with(oidcLogin())) + mockMvc.perform(get("/api/rent-payments/payment-1/receipt") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))) .andExpect(status().isOk()) .andExpect(header().string("Content-Type", "application/pdf")) - .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("quittance-2026-07.pdf"))); + .andExpect(header().string("Content-Disposition", org.hamcrest.Matchers.containsString("quittance-2026-07.pdf"))) + .andExpect(result -> { + try (PDDocument document = Loader.loadPDF(result.getResponse().getContentAsByteArray())) { + String pdfText = new PDFTextStripper().getText(document); + if (!pdfText.contains("Bailleur LMNP") || !pdfText.contains("bailleur@example.com") + || !pdfText.contains("0102030405") + || !pdfText.contains("123 rue de la Gestion, Paris")) { + throw new AssertionError("Expected rendered landlord profile values"); + } + } catch (java.io.IOException exception) { + throw new AssertionError("Expected a readable PDF", exception); + } + }); + } + + @Test + void whenLandlordProfileIsIncomplete_thenReceiptDownloadIsRejectedWithSettingsGuidance() throws Exception { + when(rentPaymentRepository.findById("payment-1")) + .thenReturn(Optional.of(payment(LocalDate.of(2026, 7, 2)))); + 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())); + when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(new Landlord("profile-1", + "subject-1", "Bailleur", "LMNP", "bailleur@example.com", "0102030405", ""))); + + mockMvc.perform(get("/api/rent-payments/payment-1/receipt") + .with(oidcLogin().idToken(token -> token.claim("sub", "subject-1")))) + .andExpect(status().isUnprocessableContent()) + .andExpect(jsonPath("$.message").value( + "Votre profil bailleur est incomplet. Complétez-le dans les réglages avant de générer un PDF.")); } @Test diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java index f66efc5..d303a37 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/RentPaymentServiceTest.java @@ -10,7 +10,6 @@ import fr.nessar.lmnporganizer.backend.model.RentPaymentStatus; 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.LandlordRepository; import fr.nessar.lmnporganizer.backend.repository.RentPaymentRepository; import fr.nessar.lmnporganizer.backend.repository.TenantRepository; import org.apache.pdfbox.Loader; @@ -51,7 +50,7 @@ class RentPaymentServiceTest { private ApartmentRepository apartmentRepository; @Mock - private LandlordRepository landlordRepository; + private LandlordProfileService landlordProfileService; @Mock private TenantRepository tenantRepository; @@ -66,7 +65,7 @@ class RentPaymentServiceTest { SpringTemplateEngine templateEngine = new SpringTemplateEngine(); templateEngine.setTemplateResolver(resolver); service = new RentPaymentService(rentPaymentRepository, leaseRepository, apartmentRepository, - landlordRepository, tenantRepository, new RentReceiptPdfService(templateEngine), + landlordProfileService, tenantRepository, new RentReceiptPdfService(templateEngine), Clock.fixed(Instant.parse("2026-07-17T00:00:00Z"), ZoneOffset.UTC)); } @@ -119,10 +118,10 @@ class RentPaymentServiceTest { when(leaseRepository.findById("lease-1")).thenReturn(Optional.of(lease( LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31)))); when(apartmentRepository.findById("apartment-1")).thenReturn(Optional.of(apartment())); - when(landlordRepository.findById("default-landlord")).thenReturn(Optional.of(landlord())); + when(landlordProfileService.requireCompleteProfileFor("subject-1")).thenReturn(landlord()); when(tenantRepository.findAllById(List.of("tenant-1"))).thenReturn(List.of(tenant())); - ResponseEntity response = service.downloadReceipt("payment-1"); + ResponseEntity response = service.downloadReceipt("payment-1", "subject-1"); assertEquals(HttpStatus.OK, response.getStatusCode()); assertEquals("application/pdf", response.getHeaders().getContentType().toString()); @@ -137,7 +136,7 @@ class RentPaymentServiceTest { RentPayment unpaid = payment("payment-2", "2026-08", LocalDate.of(2026, 8, 1), null); when(rentPaymentRepository.findById("payment-2")).thenReturn(Optional.of(unpaid)); - assertEquals(HttpStatus.CONFLICT, service.downloadReceipt("payment-2").getStatusCode()); + assertEquals(HttpStatus.CONFLICT, service.downloadReceipt("payment-2", "subject-1").getStatusCode()); } private Apartment apartment() { diff --git a/frontend/src/app/services/file-download.service.ts b/frontend/src/app/services/file-download.service.ts index 7681400..142ba8c 100644 --- a/frontend/src/app/services/file-download.service.ts +++ b/frontend/src/app/services/file-download.service.ts @@ -1,6 +1,7 @@ import { Injectable, signal } from '@angular/core'; import { finalize, Observable } from 'rxjs'; import { withoutRecordKey, withoutSetValue } from './immutable-state'; +import { httpErrorMessage } from './http-error-message'; @Injectable({ providedIn: 'root' }) export class FileDownloadService { @@ -23,10 +24,29 @@ export class FileDownloadService { .pipe(finalize(() => this.pendingKeys.update((keys) => withoutSetValue(keys, key)))) .subscribe({ next: (blob) => this.save(blob, fileName), - error: () => this.errors.update((errors) => ({ ...errors, [key]: errorMessage })), + error: (error) => this.recordError(key, error, errorMessage), }); } + private recordError(key: string, error: unknown, fallback: string): void { + if (this.hasBlobBody(error)) { + void error.error + .text() + .then((body) => this.setError(key, httpErrorMessage({ error: JSON.parse(body) }, fallback))) + .catch(() => this.setError(key, fallback)); + return; + } + this.setError(key, httpErrorMessage(error, fallback)); + } + + private hasBlobBody(error: unknown): error is { error: Blob } { + return typeof error === 'object' && error !== null && 'error' in error && error.error instanceof Blob; + } + + private setError(key: string, message: string): void { + this.errors.update((errors) => ({ ...errors, [key]: message })); + } + private save(blob: Blob, fileName: string): void { const url = URL.createObjectURL(blob); const link = document.createElement('a'); diff --git a/frontend/src/app/services/http-error-message.ts b/frontend/src/app/services/http-error-message.ts new file mode 100644 index 0000000..ad919d1 --- /dev/null +++ b/frontend/src/app/services/http-error-message.ts @@ -0,0 +1,11 @@ +export function httpErrorMessage(error: unknown, fallback: string): string { + return typeof error === 'object' && + error !== null && + 'error' in error && + typeof error.error === 'object' && + error.error !== null && + 'message' in error.error && + typeof error.error.message === 'string' + ? error.error.message + : fallback; +} diff --git a/frontend/src/app/services/tenant-attachment-transfer.service.ts b/frontend/src/app/services/tenant-attachment-transfer.service.ts index b9bfb63..91f3cbb 100644 --- a/frontend/src/app/services/tenant-attachment-transfer.service.ts +++ b/frontend/src/app/services/tenant-attachment-transfer.service.ts @@ -4,6 +4,7 @@ import { finalize } from 'rxjs'; import { FileDownloadService } from './file-download.service'; import { TenantAttachment, TenantService } from './tenant.service'; import { withoutRecordKey, withoutSetValue } from './immutable-state'; +import { httpErrorMessage } from './http-error-message'; @Injectable({ providedIn: 'root' }) export class TenantAttachmentTransferService { @@ -54,7 +55,7 @@ export class TenantAttachmentTransferService { } }, error: (error: unknown) => { - const message = this.errorMessage(error, 'Impossible d’envoyer cette pièce jointe.'); + const message = httpErrorMessage(error, 'Impossible d’envoyer cette pièce jointe.'); this.errors.update((errors) => ({ ...errors, [tenantId]: message })); }, }); @@ -98,15 +99,4 @@ export class TenantAttachmentTransferService { return `attachment:${attachmentId}`; } - private errorMessage(error: unknown, fallback: string): string { - return typeof error === 'object' && - error !== null && - 'error' in error && - typeof error.error === 'object' && - error.error !== null && - 'message' in error.error && - typeof error.error.message === 'string' - ? error.error.message - : fallback; - } } diff --git a/frontend/src/app/services/workspace.service.spec.ts b/frontend/src/app/services/workspace.service.spec.ts index 55e5b40..948bddd 100644 --- a/frontend/src/app/services/workspace.service.spec.ts +++ b/frontend/src/app/services/workspace.service.spec.ts @@ -415,4 +415,21 @@ describe('WorkspaceService', () => { 'Impossible d’envoyer cette pièce jointe.', ); }); + + it('keeps profile-completion guidance returned as a blocked PDF download blob', async () => { + const service = TestBed.inject(WorkspaceService); + leases.downloadPdf.mockReturnValueOnce( + throwError(() => ({ + error: new Blob(['{"message":"Complétez votre profil dans les réglages."}'], { + type: 'application/json', + }), + })), + ); + + service.downloadLeasePdf(lease); + + await vi.waitFor(() => + expect(service.leaseDownloadError(lease.id)).toBe('Complétez votre profil dans les réglages.'), + ); + }); });
BailleurBailleur LMNP
E-mail du bailleurbailleur@example.com
Téléphone du bailleur0102030405
Adresse du bailleur123 rue de la Gestion, Paris
LogementStudio République
Adresse12 rue de la République