Align tenant and attachment validation (#48)
This commit is contained in:
1 parent
a0f86bb511
commit
88d3f3c2a5
20 files changed
+550
-102
No files matched your search
+13
-1
@@ -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) {
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+2
-7
@@ -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;
|
||||
}
|
||||
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
|
||||
+40
@@ -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<>();
|
||||
|
||||
Reference in new issue
Block a user