diff --git a/application/pom.xml b/application/pom.xml index d4a0db7..4367d24 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -23,5 +23,25 @@ 2.0.13 + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + org.mockito + mockito-core + 5.14.2 + test + + + + org.assertj + assertj-core + 3.24.2 + test + \ No newline at end of file diff --git a/application/src/test/java/com/pablotj/restemailbridge/application/usecase/SendEmailUseCaseTest.java b/application/src/test/java/com/pablotj/restemailbridge/application/usecase/SendEmailUseCaseTest.java new file mode 100644 index 0000000..70f5532 --- /dev/null +++ b/application/src/test/java/com/pablotj/restemailbridge/application/usecase/SendEmailUseCaseTest.java @@ -0,0 +1,129 @@ +package com.pablotj.restemailbridge.application.usecase; + +import com.pablotj.restemailbridge.application.dto.EmailDTO; +import com.pablotj.restemailbridge.application.port.in.EmailDefaultConfigPort; +import com.pablotj.restemailbridge.application.port.out.EmailPort; +import com.pablotj.restemailbridge.domain.model.Email; +import com.pablotj.restemailbridge.domain.model.EmailStatus; +import com.pablotj.restemailbridge.domain.repository.EmailRepository; +import com.pablotj.restemailbridge.domain.service.EmailValidatorService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SendEmailUseCaseTest { + + private EmailValidatorService emailValidatorService; + private EmailPort emailPort; + private EmailRepository emailRepository; + + private SendEmailUseCase useCase; + + @BeforeEach + void setUp() { + EmailDefaultConfigPort emailDefaultConfigPort = mock(EmailDefaultConfigPort.class); + + emailValidatorService = mock(EmailValidatorService.class); + emailPort = mock(EmailPort.class); + emailRepository = mock(EmailRepository.class); + + useCase = new SendEmailUseCase( + emailValidatorService, + emailDefaultConfigPort, + emailPort, + emailRepository + ); + + when(emailDefaultConfigPort.getDefaultRecipient()).thenReturn("default@example.com"); + } + + @Test + void shouldSendEmailSuccessfully() { + // given + EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body"); + when(emailPort.sendEmail(any(Email.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + useCase.handle(dto); + + // then + verify(emailValidatorService).validate(any(Email.class)); + verify(emailPort).sendEmail(any(Email.class)); + verify(emailRepository).save(argThat(email -> + email.getStatus() == EmailStatus.SENT + )); + } + + @Test + void shouldMarkEmailAsFailedWhenSendThrowsException() { + // given + EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body"); + when(emailPort.sendEmail(any(Email.class))).thenThrow(new RuntimeException("SMTP error")); + + // when + useCase.handle(dto); + + // then + verify(emailValidatorService).validate(any(Email.class)); + verify(emailRepository).save(argThat(email -> + email.getStatus() == EmailStatus.FAILED && + email.getErrorDescription().contains("SMTP error") + )); + } + + @Test + void shouldUseDefaultRecipientFromConfigPort() { + // given + EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body"); + + when(emailPort.sendEmail(any(Email.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + useCase.handle(dto); + + // then + verify(emailRepository).save(argThat(email -> + email.getTo().equals("default@example.com") + )); + } + + @Test + void shouldPropagateExceptionWhenValidatorFails() { + // given + EmailDTO dto = new EmailDTO("sender@example.com", "Subject", "Body"); + + doThrow(new RuntimeException("Invalid email")).when(emailValidatorService).validate(any()); + + // when & then + RuntimeException ex = assertThrows(RuntimeException.class, () -> useCase.handle(dto)); + assertThat(ex.getMessage()).isEqualTo("Invalid email"); + + // El repositorio no debería guardar el email, porque la validación falló antes + verify(emailRepository, never()).save(any()); + } + + @Test + void shouldFailWhenEmailDTOHasNullFields() { + // given + EmailDTO dto = new EmailDTO(null, null, null); + + doThrow(new IllegalArgumentException("Invalid fields")).when(emailValidatorService).validate(any()); + + // when & then + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> useCase.handle(dto)); + assertThat(ex.getMessage()).isEqualTo("Invalid fields"); + + verify(emailRepository, never()).save(any()); + } +} \ No newline at end of file diff --git a/domain/pom.xml b/domain/pom.xml index 5e99338..c4443bb 100644 --- a/domain/pom.xml +++ b/domain/pom.xml @@ -21,5 +21,33 @@ slf4j-api 2.0.13 + + + org.junit.jupiter + junit-jupiter + 5.10.0 + test + + + + org.mockito + mockito-core + 5.14.2 + test + + + + org.assertj + assertj-core + 3.24.2 + test + + + + maven_central + Maven Central + https://repo.maven.apache.org/maven2/ + + \ No newline at end of file diff --git a/domain/src/test/java/com/pablotj/restemailbridge/domain/service/EmailValidatorServiceTest.java b/domain/src/test/java/com/pablotj/restemailbridge/domain/service/EmailValidatorServiceTest.java new file mode 100644 index 0000000..65a49b6 --- /dev/null +++ b/domain/src/test/java/com/pablotj/restemailbridge/domain/service/EmailValidatorServiceTest.java @@ -0,0 +1,62 @@ +package com.pablotj.restemailbridge.domain.service; + +import com.pablotj.restemailbridge.domain.model.Email; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class EmailValidatorServiceTest { + + private final EmailValidatorService validator = new EmailValidatorService(); + + @Test + void shouldThrowIfEmailIsNull() { + assertThatThrownBy(() -> validator.validate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Email cannot be null"); + } + + @Test + void shouldThrowIfRecipientInvalid() { + Email email = Email.create("sender@example.com", "not-an-email", "Subject", "Body"); + + assertThatThrownBy(() -> validator.validate(email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Recipient email is invalid"); + } + + @Test + void shouldThrowIfSenderIsBlank() { + Email email = Email.create("", "recipient@example.com", "Subject", "Body"); + + assertThatThrownBy(() -> validator.validate(email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Sender email is required"); + } + + @Test + void shouldThrowIfSubjectIsBlank() { + Email email = Email.create("sender@example.com", "recipient@example.com", "", "Body"); + + assertThatThrownBy(() -> validator.validate(email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Subject is required"); + } + + @Test + void shouldThrowIfBodyIsBlank() { + Email email = Email.create("sender@example.com", "recipient@example.com", "Subject", ""); + + assertThatThrownBy(() -> validator.validate(email)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Body is required"); + } + + @Test + void shouldPassForValidEmail() { + Email email = Email.create("sender@example.com", "recipient@example.com", "Subject", "Body"); + + // no debe lanzar excepción + validator.validate(email); + } +} \ No newline at end of file diff --git a/infrastructure/pom.xml b/infrastructure/pom.xml index 9e4fd99..adef519 100644 --- a/infrastructure/pom.xml +++ b/infrastructure/pom.xml @@ -149,6 +149,24 @@ lombok true + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.h2database + h2 + test + + + + org.mockito + mockito-core + test + diff --git a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/EncryptionConverter.java b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/encryption/EncryptionConverter.java similarity index 90% rename from infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/EncryptionConverter.java rename to infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/encryption/EncryptionConverter.java index 5897474..f73fef3 100644 --- a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/EncryptionConverter.java +++ b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/encryption/EncryptionConverter.java @@ -1,4 +1,4 @@ -package com.pablotj.restemailbridge.infrastructure.persistence; +package com.pablotj.restemailbridge.infrastructure.encryption; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; diff --git a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/EncryptionUtils.java b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/encryption/EncryptionUtils.java similarity index 97% rename from infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/EncryptionUtils.java rename to infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/encryption/EncryptionUtils.java index eeec59c..67a7ede 100644 --- a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/EncryptionUtils.java +++ b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/encryption/EncryptionUtils.java @@ -1,13 +1,13 @@ -package com.pablotj.restemailbridge.infrastructure.persistence; +package com.pablotj.restemailbridge.infrastructure.encryption; -import javax.crypto.Cipher; -import javax.crypto.SecretKey; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; public class EncryptionUtils { diff --git a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/MailJpa.java b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/MailJpa.java index 5ad3a0f..9a8fb74 100644 --- a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/MailJpa.java +++ b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/persistence/MailJpa.java @@ -1,6 +1,7 @@ package com.pablotj.restemailbridge.infrastructure.persistence; import com.pablotj.restemailbridge.domain.model.EmailStatus; +import com.pablotj.restemailbridge.infrastructure.encryption.EncryptionConverter; import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; diff --git a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/rest/GlobalExceptionHandler.java b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/rest/GlobalExceptionHandler.java index 2c87116..f97120b 100644 --- a/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/rest/GlobalExceptionHandler.java +++ b/infrastructure/src/main/java/com/pablotj/restemailbridge/infrastructure/rest/GlobalExceptionHandler.java @@ -17,4 +17,10 @@ public class GlobalExceptionHandler { errors.put(error.getField(), error.getDefaultMessage())); return ResponseEntity.badRequest().body(errors); } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException ex) { + Map error = Map.of("error", ex.getMessage()); + return ResponseEntity.unprocessableEntity().body(error); // 422 + } } diff --git a/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/RestEmailBridgeTestApplication.java b/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/RestEmailBridgeTestApplication.java new file mode 100644 index 0000000..1da449d --- /dev/null +++ b/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/RestEmailBridgeTestApplication.java @@ -0,0 +1,11 @@ +package com.pablotj.restemailbridge.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication(scanBasePackages = "com.pablotj.restemailbridge") +@EnableJpaRepositories(basePackages = "com.pablotj.restemailbridge") +@EntityScan(basePackages = "com.pablotj.restemailbridge") +public class RestEmailBridgeTestApplication { +} \ No newline at end of file diff --git a/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/persistence/MailRepositoryAdapterIT.java b/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/persistence/MailRepositoryAdapterIT.java new file mode 100644 index 0000000..4fd0a1e --- /dev/null +++ b/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/persistence/MailRepositoryAdapterIT.java @@ -0,0 +1,40 @@ +package com.pablotj.restemailbridge.infrastructure.persistence; + +import com.pablotj.restemailbridge.domain.model.Email; +import com.pablotj.restemailbridge.domain.model.EmailStatus; +import com.pablotj.restemailbridge.infrastructure.config.JpaConfig; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +@Import(JpaConfig.class) +@DataJpaTest +class MailRepositoryAdapterIT { + + @Autowired + private SpringDataMailRepository springDataMailRepository; + + @Test + void shouldSaveEmailSuccessfully() { + MailRepositoryAdapter adapter = new MailRepositoryAdapter(springDataMailRepository); + + Email email = Email.create("sender@example.com", "recipient@example.com", "Subject", "Body"); + + Email saved = adapter.save(email); + + assertThat(saved.getFrom()).isEqualTo("sender@example.com"); + assertThat(saved.getTo()).isEqualTo("recipient@example.com"); + assertThat(saved.getSubject()).isEqualTo("Subject"); + assertThat(saved.getBody()).isEqualTo("Body"); + assertThat(saved.getStatus()).isEqualTo(EmailStatus.PENDING); + + assertThat(springDataMailRepository.findAll()) + .hasSize(1) + .first() + .extracting("sender", "recipient") + .containsExactly("sender@example.com", "recipient@example.com"); + } +} \ No newline at end of file diff --git a/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/rest/MailControllerIT.java b/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/rest/MailControllerIT.java new file mode 100644 index 0000000..017b45e --- /dev/null +++ b/infrastructure/src/test/java/com/pablotj/restemailbridge/infrastructure/rest/MailControllerIT.java @@ -0,0 +1,53 @@ +package com.pablotj.restemailbridge.infrastructure.rest; + +import com.pablotj.restemailbridge.application.port.out.EmailPort; +import com.pablotj.restemailbridge.infrastructure.RestEmailBridgeTestApplication; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest( + classes = RestEmailBridgeTestApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT +) +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MailControllerIT { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private EmailPort emailPort; + + @Test + void shouldReturn200WhenEmailIsSent() throws Exception { + when(emailPort.sendEmail(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + mockMvc.perform(post("/v1/mail") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"from\":\"sender@example.com\",\"subject\":\"Subject\",\"body\":\"Body\"}")) + .andExpect(status().isOk()); + + verify(emailPort).sendEmail(any()); + } + + @Test + void shouldReturn400WhenRequestIsInvalid() throws Exception { + mockMvc.perform(post("/v1/mail") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"subject\":\"Subject\",\"body\":\"Body\"}")) + .andExpect(status().isBadRequest()); + } +} \ No newline at end of file diff --git a/infrastructure/src/test/resources/application.yml b/infrastructure/src/test/resources/application.yml new file mode 100644 index 0000000..c52da3b --- /dev/null +++ b/infrastructure/src/test/resources/application.yml @@ -0,0 +1,27 @@ +spring: + application: + name: restemailbridge + datasource: + url: jdbc:h2:mem:restemailbridge;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + +server: + port: 0 + servlet: + context-path: /api + +app: + encryption: + secret: 0123456789ABCDEF0123456789ABCDEF + +gmail: + oauth2: + clientId: dummy + clientSecret: dummy + redirectUri: http://localhost:8888/Callback \ No newline at end of file