Align tenant and attachment validation (#48)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent a0f86bb511
commit 88d3f3c2a5
20 files changed
+550 -102

No files matched your search

@@ -1,7 +1,9 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantAttachment;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
@@ -14,7 +16,17 @@ record Request(
@NotBlank String lastName,
@NotBlank @Email String email,
String phone,
String address) {}
String address) {
@AssertTrue
boolean isPhoneValid() {
try {
new PhoneNumber(phone);
return true;
} catch (IllegalArgumentException exception) {
return false;
}
}
}
record AttachmentResponse(String id, String fileName, String contentType, long size) {
static AttachmentResponse from(TenantAttachment attachment) {
@@ -34,6 +34,9 @@ public TenantAttachmentService(
public TenantAttachment upload(
LandlordId landlordId, TenantId tenantId, TenantAttachmentStorage.Upload upload) {
Tenant tenant = tenant(landlordId, tenantId);
if (upload.size() == 0) {
throw new IllegalArgumentException("Tenant attachment file is required.");
}
if (upload.size() > maximumSize) {
throw new AttachmentTooLarge(maximumSize);
}
@@ -3,7 +3,6 @@ package fr.nessar.lmnporganizer.rental.application.tenant;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.application.shared.ResourceNotFound;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
@@ -34,7 +33,7 @@ public Tenant create(LandlordId landlordId, ContactDetails details) {
details.firstName(),
details.lastName(),
details.email(),
phone(details.phone()),
details.phone(),
details.address(),
List.of()));
}
@@ -45,15 +44,11 @@ public Tenant revise(LandlordId landlordId, TenantId tenantId, ContactDetails de
details.firstName(),
details.lastName(),
details.email(),
phone(details.phone()),
details.phone(),
details.address());
return tenants.save(tenant);
}
private static String phone(String value) {
return new PhoneNumber(value).value();
}
public record ContactDetails(
String firstName, String lastName, String email, String phone, String address) {}
}
@@ -2,6 +2,7 @@ package fr.nessar.lmnporganizer.rental.domain.tenant;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
import fr.nessar.lmnporganizer.rental.domain.shared.PhoneNumber;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@@ -103,8 +104,8 @@ public void updateContactDetails(
String firstName, String lastName, String email, String phone, String address) {
String updatedFirstName = required(firstName, "Tenant first name");
String updatedLastName = required(lastName, "Tenant last name");
String updatedEmail = required(email, "Tenant email");
String updatedPhone = phone == null ? "" : phone.trim();
String updatedEmail = email(email);
String updatedPhone = new PhoneNumber(phone).value();
String updatedAddress = address == null ? "" : address.trim();
this.firstName = updatedFirstName;
this.lastName = updatedLastName;
@@ -161,6 +162,20 @@ private static String required(String value, String field) {
return value.trim();
}
private static String email(String value) {
String email = required(value, "Tenant email");
int at = email.indexOf('@');
if (at <= 0 || at != email.lastIndexOf('@') || at == email.length() - 1) {
throw new IllegalArgumentException("Tenant email must be valid.");
}
for (int index = 0; index < email.length(); index++) {
if (Character.isWhitespace(email.charAt(index))) {
throw new IllegalArgumentException("Tenant email must be valid.");
}
}
return email;
}
public TenantId id() {
return id;
}
@@ -0,0 +1,61 @@
package fr.nessar.lmnporganizer.rental.adapter.in.web.tenant;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
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.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import fr.nessar.lmnporganizer.LmnpOrganizerApplication;
import fr.nessar.lmnporganizer.identity.adapter.in.security.SecurityConfiguration;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.AuthenticatedLandlord;
import fr.nessar.lmnporganizer.rental.adapter.in.web.common.RentalApiExceptionHandler;
import fr.nessar.lmnporganizer.rental.application.landlord.LandlordAccountService;
import fr.nessar.lmnporganizer.rental.application.tenant.DeleteTenantService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantAttachmentService;
import fr.nessar.lmnporganizer.rental.application.tenant.TenantDirectoryService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
@WebMvcTest(controllers = TenantController.class)
@ContextConfiguration(classes = LmnpOrganizerApplication.class)
@Import({AuthenticatedLandlord.class, RentalApiExceptionHandler.class, SecurityConfiguration.class})
class TenantWebTest {
@Autowired MockMvc mockMvc;
@MockitoBean LandlordAccountService accounts;
@MockitoBean TenantDirectoryService tenants;
@MockitoBean DeleteTenantService deletion;
@MockitoBean TenantAttachmentService attachments;
@Test
void reportsStableTenantContactValidation() throws Exception {
mockMvc
.perform(
post("/api/tenants")
.with(oidcLogin())
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(
"""
{"firstName":" ","lastName":" ","email":"not-an-email","phone":"1234","address":" "}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("validation_failed"))
.andExpect(
jsonPath("$.errors[?(@.field == 'firstName' && @.code == 'not_blank')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'lastName' && @.code == 'not_blank')]").exists())
.andExpect(jsonPath("$.errors[?(@.field == 'email' && @.code == 'email')]").exists())
.andExpect(
jsonPath("$.errors[?(@.field == 'phoneValid' && @.code == 'assert_true')]").exists());
verifyNoInteractions(tenants);
}
}
@@ -39,6 +39,22 @@ private static final TenantAttachment SECOND_ATTACHMENT =
new TenantAttachment(
"attachment-2", "lease.pdf", "application/pdf", 20, "tenant-1/attachment-2");
@Test
void rejectsAnEmptyUploadBeforeStorage() {
TenantRepository tenants = mock(TenantRepository.class);
TenantAttachmentStorage storage = mock(TenantAttachmentStorage.class);
Tenant tenant = tenant(List.of());
when(tenants.find(LANDLORD_ID, TENANT_ID)).thenReturn(Optional.of(tenant));
TenantAttachmentService service =
new TenantAttachmentService(tenants, storage, () -> "unused", 10);
assertThrows(
IllegalArgumentException.class,
() -> service.upload(LANDLORD_ID, TENANT_ID, upload("empty.pdf", 0)));
verify(storage, never()).store(any(), any(), any());
}
@Test
void rejectsOversizedUploadsAfterTheLandlordScopedLookupAndBeforeStorage() {
TenantRepository tenants = mock(TenantRepository.class);
@@ -0,0 +1,40 @@
package fr.nessar.lmnporganizer.rental.application.tenant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import fr.nessar.lmnporganizer.rental.application.port.out.IdentifierGenerator;
import fr.nessar.lmnporganizer.rental.domain.landlord.LandlordId;
import fr.nessar.lmnporganizer.rental.domain.tenant.Tenant;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantId;
import fr.nessar.lmnporganizer.rental.domain.tenant.TenantRepository;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class TenantDirectoryServiceTest {
private static final LandlordId LANDLORD_ID = new LandlordId("landlord-1");
private static final TenantId TENANT_ID = new TenantId("tenant-1");
@Test
void normalizesAnOptionalPhoneForCreationAndRevision() {
TenantRepository tenants = mock(TenantRepository.class);
IdentifierGenerator identifiers = () -> TENANT_ID.value();
when(tenants.save(org.mockito.ArgumentMatchers.any()))
.thenAnswer(invocation -> invocation.getArgument(0));
TenantDirectoryService service = new TenantDirectoryService(tenants, identifiers);
TenantDirectoryService.ContactDetails details =
new TenantDirectoryService.ContactDetails(
"Camille", "Martin", "camille@example.com", "06 12 34 56 78", "");
Tenant created = service.create(LANDLORD_ID, details);
when(tenants.find(LANDLORD_ID, TENANT_ID)).thenReturn(Optional.of(created));
Tenant revised = service.revise(LANDLORD_ID, TENANT_ID, details);
assertEquals("+33612345678", revised.phone());
assertEquals("", revised.address());
verify(tenants, times(2)).save(created);
}
}
@@ -10,6 +10,41 @@ import java.util.List;
import org.junit.jupiter.api.Test;
class TenantTest {
@Test
void requiresFormattedContactIdentity() {
assertThrows(
IllegalArgumentException.class,
() ->
new Tenant(
new TenantId("tenant-1"),
new LandlordId("landlord-1"),
"Camille",
"Martin",
"camille-at-example.com",
"",
"",
List.of()));
}
@Test
void normalizesOptionalPhonesAndRejectsInvalidOnes() {
Tenant tenant =
new Tenant(
new TenantId("tenant-1"),
new LandlordId("landlord-1"),
"Camille",
"Martin",
"camille@example.com",
"06 12 34 56 78",
"",
List.of());
assertEquals("+33612345678", tenant.phone());
assertThrows(
IllegalArgumentException.class,
() -> tenant.updateContactDetails("Camille", "Martin", "camille@example.com", "1234", ""));
}
@Test
void ownsImmutableAttachmentMetadata() {
List<TenantAttachment> initialAttachments = new ArrayList<>();