test: add unit and integration tests

This commit is contained in:
Pablo de la Torre Jamardo 2025-09-15 21:06:20 +02:00
parent ee88068d99
commit fea733f990
13 changed files with 401 additions and 6 deletions

View File

@ -23,5 +23,25 @@
<version>2.0.13</version> <version>2.0.13</version>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@ -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());
}
}

View File

@ -21,5 +21,33 @@
<artifactId>slf4j-api</artifactId> <artifactId>slf4j-api</artifactId>
<version>2.0.13</version> <version>2.0.13</version>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.24.2</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<repositories>
<repository>
<id>maven_central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2/</url>
</repository>
</repositories>
</project> </project>

View File

@ -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);
}
}

View File

@ -149,6 +149,24 @@
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<repositories> <repositories>
<repository> <repository>

View File

@ -1,4 +1,4 @@
package com.pablotj.restemailbridge.infrastructure.persistence; package com.pablotj.restemailbridge.infrastructure.encryption;
import jakarta.persistence.AttributeConverter; import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter; import jakarta.persistence.Converter;

View File

@ -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.ByteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.util.Base64; 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 { public class EncryptionUtils {

View File

@ -1,6 +1,7 @@
package com.pablotj.restemailbridge.infrastructure.persistence; package com.pablotj.restemailbridge.infrastructure.persistence;
import com.pablotj.restemailbridge.domain.model.EmailStatus; import com.pablotj.restemailbridge.domain.model.EmailStatus;
import com.pablotj.restemailbridge.infrastructure.encryption.EncryptionConverter;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Convert; import jakarta.persistence.Convert;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@ -17,4 +17,10 @@ public class GlobalExceptionHandler {
errors.put(error.getField(), error.getDefaultMessage())); errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors); return ResponseEntity.badRequest().body(errors);
} }
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, String>> handleRuntimeException(RuntimeException ex) {
Map<String, String> error = Map.of("error", ex.getMessage());
return ResponseEntity.unprocessableEntity().body(error); // 422
}
} }

View File

@ -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 {
}

View File

@ -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");
}
}

View File

@ -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());
}
}

View File

@ -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