attachment = tenant.findAttachment(attachmentId);
+ if (attachment.isEmpty()) {
+ return false;
+ }
+ TenantAttachmentStorage.StagedDeletion deletion = attachmentStorage.stageDeletion(attachment.get());
+ tenant.removeAttachment(attachmentId);
+ try {
+ tenantRepository.save(tenant);
+ } catch (RuntimeException failure) {
+ tenant.addAttachment(attachment.get());
+ try {
+ attachmentStorage.restore(deletion);
+ } catch (IOException | RuntimeException rollbackFailure) {
+ failure.addSuppressed(rollbackFailure);
+ }
+ throw failure;
+ }
+ attachmentStorage.finalizeDeletion(deletion);
+ return true;
+ }
+
+ private TenantRequest normalize(TenantRequest request) {
+ return new TenantRequest(request.firstName().trim(), request.lastName().trim(), request.email().trim(),
+ value(request.phone()), value(request.address()));
+ }
+
+ private String value(String value) {
+ return value == null ? "" : value.trim();
+ }
+
+ public record AttachmentDownload(Resource resource, String fileName, String contentType) {
}
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java
index ddc4603..889de92 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/TenantControllerTest.java
@@ -1,27 +1,33 @@
package fr.nessar.lmnporganizer.backend.controller;
+import fr.nessar.lmnporganizer.backend.model.Landlord;
import fr.nessar.lmnporganizer.backend.model.Tenant;
import fr.nessar.lmnporganizer.backend.model.TenantAttachment;
+import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
import fr.nessar.lmnporganizer.backend.service.TenantAttachmentStorage;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
-import org.springframework.http.MediaType;
import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
-import static org.mockito.ArgumentMatchers.any;
import java.util.List;
import java.util.Optional;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.not;
+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.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -35,17 +41,27 @@ class TenantControllerTest {
@MockitoBean
private TenantRepository tenantRepository;
+ @MockitoBean
+ private LandlordRepository landlordRepository;
+
@MockitoBean
private TenantAttachmentStorage attachmentStorage;
+ @BeforeEach
+ void resolveAuthenticatedLandlord() {
+ when(landlordRepository.findByOidcSubject("subject-1")).thenReturn(Optional.of(
+ new Landlord("landlord-1", "subject-1", "Camille", "Martin", "camille@example.com",
+ "+33600000000", "1 rue de Paris")));
+ }
+
@Test
- void whenCreateTenant_thenCreatedForDefaultLandlord() throws Exception {
- Tenant savedTenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
+ void whenCreateTenant_thenCreatedForAuthenticatedLandlord() throws Exception {
+ Tenant savedTenant = new Tenant("tenant-1", "landlord-1", "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())
+ .with(asLandlord())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
@@ -70,7 +86,7 @@ class TenantControllerTest {
});
mockMvc.perform(post("/api/tenants")
- .with(oidcLogin())
+ .with(asLandlord())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
@@ -88,7 +104,7 @@ class TenantControllerTest {
@Test
void whenCreateTenantWithInvalidEmail_thenBadRequest() throws Exception {
mockMvc.perform(post("/api/tenants")
- .with(oidcLogin())
+ .with(asLandlord())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
@@ -111,32 +127,32 @@ class TenantControllerTest {
@Test
void whenGetTenants_thenReturnsPortfolioTenants() throws Exception {
- Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin",
"camille@example.com", "+33612345678", "10 rue des Lilas");
- when(tenantRepository.findAll()).thenReturn(List.of(tenant));
+ when(tenantRepository.findByLandlordId("landlord-1")).thenReturn(List.of(tenant));
- mockMvc.perform(get("/api/tenants").with(oidcLogin()))
+ mockMvc.perform(get("/api/tenants").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].firstName").value("Camille"));
}
@Test
void whenGetUnknownTenant_thenNotFound() throws Exception {
- when(tenantRepository.findById("missing")).thenReturn(Optional.empty());
+ when(tenantRepository.findByIdAndLandlordId("missing", "landlord-1")).thenReturn(Optional.empty());
- mockMvc.perform(get("/api/tenants/missing").with(oidcLogin()))
+ mockMvc.perform(get("/api/tenants/missing").with(asLandlord()))
.andExpect(status().isNotFound());
}
@Test
void whenUpdateTenant_thenContactDetailsAreUpdated() throws Exception {
- Tenant existing = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
+ Tenant existing = new Tenant("tenant-1", "landlord-1", "Camille", "Martin",
"old@example.com", "0100000000", "Ancienne adresse");
- when(tenantRepository.findById("tenant-1")).thenReturn(Optional.of(existing));
+ when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-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())
+ .with(asLandlord())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
@@ -154,28 +170,43 @@ class TenantControllerTest {
@Test
void whenDeleteTenant_thenNoContent() throws Exception {
- when(tenantRepository.existsById("tenant-1")).thenReturn(true);
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin",
+ "camille@example.com", "+33612345678", "10 rue des Lilas");
+ when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+ when(tenantRepository.deleteByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(1L);
- mockMvc.perform(delete("/api/tenants/tenant-1").with(oidcLogin()))
+ mockMvc.perform(delete("/api/tenants/tenant-1").with(asLandlord()))
.andExpect(status().isNoContent());
+ }
- verify(tenantRepository).deleteById("tenant-1");
+ @Test
+ void whenDeleteTenantWithAttachments_thenConflictExplainsRequiredAction() throws Exception {
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin",
+ "camille@example.com", "+33612345678", "10 rue des Lilas");
+ tenant.setAttachments(List.of(new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
+ "tenant-1/document-1.pdf")));
+ when(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+
+ mockMvc.perform(delete("/api/tenants/tenant-1").with(asLandlord()))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.message")
+ .value("Supprimez les pièces jointes du locataire avant de le supprimer."));
}
@Test
void whenUploadAttachment_thenMetadataIsAddedToTenant() throws Exception {
- Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "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(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-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()))
+ .with(asLandlord()))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value("attachment-1"))
.andExpect(jsonPath("$.fileName").value("identite.pdf"))
@@ -184,15 +215,15 @@ class TenantControllerTest {
@Test
void whenDownloadAttachment_thenReturnsAttachment() throws Exception {
- Tenant tenant = new Tenant("tenant-1", "default-landlord", "Camille", "Martin",
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "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(tenantRepository.findByIdAndLandlordId("tenant-1", "landlord-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()))
+ mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(asLandlord()))
.andExpect(status().isOk())
.andExpect(result -> {
if (!"PDF!".equals(result.getResponse().getContentAsString())) {
@@ -200,4 +231,25 @@ class TenantControllerTest {
}
});
}
+
+ @Test
+ void whenAttachmentStorageReadFails_thenReturnsSafeStructuredError() throws Exception {
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "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.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+ when(attachmentStorage.read(attachment))
+ .thenThrow(new java.io.IOException("Storage read failed at /private/tenant-1/document-1.pdf"));
+
+ mockMvc.perform(get("/api/tenants/tenant-1/attachments/attachment-1").with(asLandlord()))
+ .andExpect(status().isInternalServerError())
+ .andExpect(jsonPath("$.message").value("Impossible de traiter la pièce jointe."))
+ .andExpect(content().string(not(containsString("/private/tenant-1/document-1.pdf"))));
+ }
+
+ private static RequestPostProcessor asLandlord() {
+ return oidcLogin().idToken(token -> token.claim("sub", "subject-1"));
+ }
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java
new file mode 100644
index 0000000..2e6e01f
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/integration/TenantPortfolioApiIT.java
@@ -0,0 +1,256 @@
+package fr.nessar.lmnporganizer.backend.integration;
+
+import fr.nessar.lmnporganizer.backend.BackendApplication;
+import fr.nessar.lmnporganizer.backend.model.Landlord;
+import fr.nessar.lmnporganizer.backend.repository.LandlordRepository;
+import fr.nessar.lmnporganizer.backend.repository.TenantRepository;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.util.FileSystemUtils;
+import org.testcontainers.mongodb.MongoDBContainer;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static com.jayway.jsonpath.JsonPath.read;
+import static org.hamcrest.Matchers.hasSize;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@SpringBootTest(classes = BackendApplication.class, properties = {
+ "lmnp.data-initializer.enabled=false",
+ "lmnp.attachments.max-size=4B"
+})
+@AutoConfigureMockMvc
+class TenantPortfolioApiIT {
+
+ private static final String VALID_TENANT = """
+ {"firstName":"Camille","lastName":"Martin","email":"camille@example.com","phone":"06 12 34 56 78","address":"10 rue des Lilas, Lyon"}
+ """;
+ private static final String UPDATED_TENANT = """
+ {"firstName":"Camille","lastName":"Bernard","email":"camille.bernard@example.com","phone":"+33612345678","address":"20 rue des Lilas, Lyon"}
+ """;
+ private static final String INVALID_TENANT = """
+ {"firstName":" ","lastName":"","email":"not-an-email","phone":"","address":"Lyon"}
+ """;
+ private static final MongoDBContainer mongoDb = startMongoDb();
+ private static final Path attachmentRoot = temporaryAttachmentRoot();
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private TenantRepository tenantRepository;
+
+ @Autowired
+ private LandlordRepository landlordRepository;
+
+ @DynamicPropertySource
+ static void configureInfrastructure(DynamicPropertyRegistry registry) {
+ if (mongoDb != null) {
+ registry.add("spring.mongodb.uri", () -> mongoDb.getConnectionString() + "/lmnp-tenant-portfolios");
+ }
+ registry.add("lmnp.storage.attachments", attachmentRoot::toString);
+ }
+
+ @BeforeEach
+ void setUpBailleurs() throws IOException {
+ tenantRepository.deleteAll();
+ landlordRepository.deleteAll();
+ FileSystemUtils.deleteRecursively(attachmentRoot);
+ Files.createDirectories(attachmentRoot);
+ landlordRepository.save(new Landlord("landlord-alice", "subject-alice", "Alice", "Martin",
+ "alice@example.com", "+33600000000", "1 rue de Paris"));
+ landlordRepository.save(new Landlord("landlord-bob", "subject-bob", "Bob", "Durand",
+ "bob@example.com", "+33600000001", "2 rue de Lyon"));
+ }
+
+ @AfterEach
+ void clearData() {
+ tenantRepository.deleteAll();
+ landlordRepository.deleteAll();
+ }
+
+ @AfterAll
+ static void clearAttachments() throws IOException {
+ FileSystemUtils.deleteRecursively(attachmentRoot);
+ }
+
+ @Test
+ void eachBailleurCanManageOnlyTheirOwnTenants() throws Exception {
+ String aliceTenantId = createTenant(as("subject-alice"), VALID_TENANT);
+ String bobTenantId = createTenant(as("subject-bob"), """
+ {"firstName":"Louis","lastName":"Durand","email":"louis@example.com","phone":"+33611111111","address":"4 montée de la Boucle, Lyon"}
+ """);
+
+ mockMvc.perform(get("/api/tenants").with(as("subject-alice")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$", hasSize(1)))
+ .andExpect(jsonPath("$[0].id").value(aliceTenantId))
+ .andExpect(jsonPath("$[0].landlordId").doesNotExist());
+
+ mockMvc.perform(get("/api/tenants/{id}", aliceTenantId).with(as("subject-alice")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.email").value("camille@example.com"))
+ .andExpect(jsonPath("$.landlordId").doesNotExist());
+
+ mockMvc.perform(get("/api/tenants/{id}", aliceTenantId).with(as("subject-bob")))
+ .andExpect(status().isNotFound());
+
+ mockMvc.perform(put("/api/tenants/{id}", aliceTenantId)
+ .with(as("subject-bob"))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(UPDATED_TENANT))
+ .andExpect(status().isNotFound());
+
+ mockMvc.perform(delete("/api/tenants/{id}", aliceTenantId).with(as("subject-bob")))
+ .andExpect(status().isNotFound());
+
+ mockMvc.perform(put("/api/tenants/{id}", aliceTenantId)
+ .with(as("subject-alice"))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(UPDATED_TENANT))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.lastName").value("Bernard"))
+ .andExpect(jsonPath("$.landlordId").doesNotExist());
+
+ mockMvc.perform(delete("/api/tenants/{id}", bobTenantId).with(as("subject-bob")))
+ .andExpect(status().isNoContent());
+
+ mockMvc.perform(get("/api/tenants/{id}", bobTenantId).with(as("subject-bob")))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void invalidTenantRequestIsRejected() throws Exception {
+ mockMvc.perform(post("/api/tenants")
+ .with(as("subject-alice"))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(INVALID_TENANT))
+ .andExpect(status().isBadRequest());
+ }
+
+ @Test
+ void attachmentsCanBeManagedOnlyThroughTheirOwningTenant() throws Exception {
+ String tenantId = createTenant(as("subject-alice"), VALID_TENANT);
+ MockMultipartFile attachment = new MockMultipartFile(
+ "file", "identite.pdf", MediaType.APPLICATION_PDF_VALUE, "PDF!".getBytes());
+ String attachmentId = uploadAttachment(tenantId, attachment, as("subject-alice"));
+
+ mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.attachments", hasSize(1)))
+ .andExpect(jsonPath("$.attachments[0].id").value(attachmentId))
+ .andExpect(jsonPath("$.attachments[0].fileName").value("identite.pdf"))
+ .andExpect(jsonPath("$.attachments[0].storagePath").doesNotExist());
+
+ mockMvc.perform(get("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId)
+ .with(as("subject-alice")))
+ .andExpect(status().isOk())
+ .andExpect(content().bytes("PDF!".getBytes()))
+ .andExpect(header().string("Content-Disposition", "attachment; filename=\"identite.pdf\""));
+
+ MockMultipartFile oversized = new MockMultipartFile(
+ "file", "secret.pdf", MediaType.APPLICATION_PDF_VALUE, "PDF!!".getBytes());
+ mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId)
+ .file(oversized)
+ .with(as("subject-bob")))
+ .andExpect(status().isNotFound());
+
+ mockMvc.perform(get("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId)
+ .with(as("subject-bob")))
+ .andExpect(status().isNotFound());
+
+ mockMvc.perform(delete("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId)
+ .with(as("subject-bob")))
+ .andExpect(status().isNotFound());
+
+ mockMvc.perform(delete("/api/tenants/{tenantId}/attachments/{attachmentId}", tenantId, attachmentId)
+ .with(as("subject-alice")))
+ .andExpect(status().isNoContent());
+
+ mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.attachments", hasSize(0)));
+ }
+
+ @Test
+ void ownedOversizedAttachmentIsRejectedWithoutMetadata() throws Exception {
+ String tenantId = createTenant(as("subject-alice"), VALID_TENANT);
+ MockMultipartFile oversized = new MockMultipartFile(
+ "file", "identite.pdf", MediaType.APPLICATION_PDF_VALUE, "PDF!!".getBytes());
+
+ mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId)
+ .file(oversized)
+ .with(as("subject-alice")))
+ .andExpect(status().isContentTooLarge());
+
+ mockMvc.perform(get("/api/tenants/{id}", tenantId).with(as("subject-alice")))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.attachments", hasSize(0)));
+ }
+
+ private String createTenant(RequestPostProcessor principal, String request) throws Exception {
+ String response = mockMvc.perform(post("/api/tenants")
+ .with(principal)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(request))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.landlordId").doesNotExist())
+ .andReturn().getResponse().getContentAsString();
+ return read(response, "$.id");
+ }
+
+ private String uploadAttachment(String tenantId, MockMultipartFile file, RequestPostProcessor principal)
+ throws Exception {
+ String response = mockMvc.perform(multipart("/api/tenants/{tenantId}/attachments", tenantId)
+ .file(file)
+ .with(principal))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.storagePath").doesNotExist())
+ .andReturn().getResponse().getContentAsString();
+ return read(response, "$.id");
+ }
+
+ private static RequestPostProcessor as(String oidcSubject) {
+ return oidcLogin().idToken(token -> token.claim("sub", oidcSubject));
+ }
+
+ private static MongoDBContainer startMongoDb() {
+ if (System.getenv("SPRING_DATA_MONGODB_URI") != null) {
+ return null;
+ }
+ MongoDBContainer container = new MongoDBContainer("mongo:8.0");
+ container.start();
+ return container;
+ }
+
+ private static Path temporaryAttachmentRoot() {
+ try {
+ return Files.createTempDirectory("lmnp-tenant-api-it-");
+ } catch (IOException exception) {
+ throw new UncheckedIOException(exception);
+ }
+ }
+}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java
new file mode 100644
index 0000000..9f51d03
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/model/TenantTest.java
@@ -0,0 +1,33 @@
+package fr.nessar.lmnporganizer.backend.model;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TenantTest {
+
+ @Test
+ void ownsAttachmentMembership() {
+ Tenant tenant = new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com",
+ "+33612345678", "10 rue des Lilas");
+ TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
+ "tenant-1/attachment-1");
+
+ assertFalse(tenant.hasAttachments());
+
+ tenant.addAttachment(attachment);
+
+ assertAll(
+ () -> assertTrue(tenant.hasAttachments()),
+ () -> assertSame(attachment, tenant.findAttachment("attachment-1").orElseThrow()));
+
+ tenant.removeAttachment("attachment-1");
+
+ assertAll(
+ () -> assertFalse(tenant.hasAttachments()),
+ () -> assertTrue(tenant.findAttachment("attachment-1").isEmpty()));
+ }
+}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java
index e0a9a20..58fecae 100644
--- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantAttachmentStorageTest.java
@@ -4,8 +4,10 @@ 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 org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
@@ -38,6 +40,39 @@ class TenantAttachmentStorageTest {
TenantAttachment unsafe = new TenantAttachment("id", "unsafe", "text/plain", 1, "../unsafe");
assertThrows(IOException.class, () -> storage.read(unsafe));
assertThrows(IOException.class, () -> storage.delete(unsafe));
+ TenantAttachment staged = new TenantAttachment("id", "staged", "text/plain", 1, ".staging/id");
+ assertThrows(IOException.class, () -> storage.read(staged));
assertThrows(IOException.class, () -> storage.store("../outside", file));
}
+
+ @Test
+ void stagedDeletionCanBeRestoredBeforeItIsFinalized() throws IOException {
+ TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
+ TenantAttachment attachment = storage.store("tenant-1",
+ new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
+
+ TenantAttachmentStorage.StagedDeletion deletion = storage.stageDeletion(attachment);
+ assertThrows(IOException.class, () -> storage.read(attachment));
+
+ storage.restore(deletion);
+ assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes()));
+ }
+
+ @Test
+ void failedStoreLeavesNoPartialAttachment() throws IOException {
+ TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
+ MultipartFile file = new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()) {
+ @Override
+ public void transferTo(Path destination) throws IOException {
+ Files.writeString(destination, "partial");
+ throw new IOException("interrupted upload");
+ }
+ };
+
+ assertThrows(IOException.class, () -> storage.store("tenant-1", file));
+
+ try (var storedPaths = Files.walk(directory)) {
+ assertEquals(0, storedPaths.filter(Files::isRegularFile).count());
+ }
+ }
}
diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java
new file mode 100644
index 0000000..0700ebd
--- /dev/null
+++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/service/TenantServiceTest.java
@@ -0,0 +1,127 @@
+package fr.nessar.lmnporganizer.backend.service;
+
+import fr.nessar.lmnporganizer.backend.exception.TenantHasAttachmentsException;
+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.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.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+class TenantServiceTest {
+ @TempDir
+ Path directory;
+
+ @Test
+ void failedUploadMetadataSaveLeavesNoAttachmentOrActiveFile() throws IOException {
+ TenantRepository repository = mock(TenantRepository.class);
+ Tenant tenant = tenant();
+ when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+ IllegalStateException failure = new IllegalStateException("Mongo unavailable");
+ when(repository.save(tenant)).thenThrow(failure);
+ TenantService service = service(repository, new TenantAttachmentStorage(directory.toString()));
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> service.uploadAttachment("landlord-1", "tenant-1",
+ new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes())));
+
+ assertAll(
+ () -> assertSame(failure, thrown),
+ () -> assertTrue(tenant.getAttachments().isEmpty()),
+ () -> {
+ try (var storedPaths = Files.walk(directory)) {
+ assertEquals(0, storedPaths.filter(Files::isRegularFile).count());
+ }
+ });
+ }
+
+ @Test
+ void failedMetadataRemovalRestoresAttachmentAndRethrowsOriginalFailure() throws IOException {
+ TenantRepository repository = mock(TenantRepository.class);
+ TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
+ Tenant tenant = tenant();
+ TenantAttachment attachment = storage.store("tenant-1",
+ new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
+ tenant.setAttachments(List.of(attachment));
+ when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+ IllegalStateException failure = new IllegalStateException("Mongo unavailable");
+ when(repository.save(tenant)).thenThrow(failure);
+
+ IllegalStateException thrown = assertThrows(IllegalStateException.class,
+ () -> service(repository, storage).deleteAttachment("landlord-1", "tenant-1", attachment.getId()));
+
+ assertAll(
+ () -> assertSame(failure, thrown),
+ () -> assertEquals(List.of(attachment), tenant.getAttachments()),
+ () -> assertEquals("PDF!", new String(storage.read(attachment).getInputStream().readAllBytes())));
+ }
+
+ @Test
+ void successfulAttachmentRemovalDeletesMetadataAndActiveFile() throws IOException {
+ TenantRepository repository = mock(TenantRepository.class);
+ TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
+ Tenant tenant = tenant();
+ TenantAttachment attachment = storage.store("tenant-1",
+ new MockMultipartFile("file", "identite.pdf", "application/pdf", "PDF!".getBytes()));
+ tenant.setAttachments(List.of(attachment));
+ when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+
+ assertTrue(service(repository, storage)
+ .deleteAttachment("landlord-1", "tenant-1", attachment.getId()));
+
+ assertAll(
+ () -> assertTrue(tenant.getAttachments().isEmpty()),
+ () -> assertThrows(IOException.class, () -> storage.read(attachment)));
+ }
+
+ @Test
+ void failedStorageDeleteKeepsAttachmentMetadata() throws IOException {
+ TenantRepository repository = mock(TenantRepository.class);
+ TenantAttachmentStorage storage = new TenantAttachmentStorage(directory.toString());
+ Tenant tenant = tenant();
+ TenantAttachment attachment = new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
+ "../outside/attachment-1");
+ tenant.setAttachments(List.of(attachment));
+ when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+
+ assertThrows(IOException.class, () -> service(repository, storage)
+ .deleteAttachment("landlord-1", "tenant-1", "attachment-1"));
+
+ assertEquals(List.of(attachment), tenant.getAttachments());
+ verify(repository, never()).save(any());
+ }
+
+ @Test
+ void tenantWithAttachmentsMustBeEmptiedBeforeDeletion() {
+ TenantRepository repository = mock(TenantRepository.class);
+ Tenant tenant = tenant();
+ tenant.setAttachments(List.of(new TenantAttachment("attachment-1", "identite.pdf", "application/pdf", 4,
+ "tenant-1/attachment-1")));
+ when(repository.findByIdAndLandlordId("tenant-1", "landlord-1")).thenReturn(Optional.of(tenant));
+
+ assertThrows(TenantHasAttachmentsException.class,
+ () -> service(repository, mock(TenantAttachmentStorage.class)).delete("landlord-1", "tenant-1"));
+
+ verify(repository, never()).deleteByIdAndLandlordId(anyString(), anyString());
+ }
+
+ private TenantService service(TenantRepository repository, TenantAttachmentStorage storage) {
+ return new TenantService(repository, storage, new AttachmentUploadPolicy("250MB"),
+ mock(PhoneNumberNormalizer.class), mock(TenantMapper.class));
+ }
+
+ private Tenant tenant() {
+ return new Tenant("tenant-1", "landlord-1", "Camille", "Martin", "camille@example.com",
+ "+33612345678", "10 rue des Lilas");
+ }
+}
diff --git a/frontend/src/app/components/tenant-form/tenant-form.html b/frontend/src/app/components/tenant-form/tenant-form.html
index db6acef..50cbd74 100644
--- a/frontend/src/app/components/tenant-form/tenant-form.html
+++ b/frontend/src/app/components/tenant-form/tenant-form.html
@@ -15,12 +15,7 @@
Renseignez les coordonnées nécessaires à la gestion du bail.
-