diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 4ab8b8e..2b7e520 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,6 +4,13 @@ on: push: branches: - master + paths: + - 'src/main/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' + - 'gradlew' + - 'Dockerfile' permissions: contents: read @@ -20,19 +27,6 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up Java 17 - uses: actions/setup-java@v5 - with: - distribution: temurin - java-version: 17 - cache: gradle - - - name: Grant execute permission for Gradle - run: chmod +x gradlew - - - name: Run tests - run: ./gradlew test - - name: Set image metadata id: meta run: | diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 129794a..482b7ef 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -31,4 +31,4 @@ jobs: run: chmod +x gradlew - name: Build with Gradle - run: ./gradlew build \ No newline at end of file + run: ./gradlew build # runs 'check' test, which runs unit and integration tests \ No newline at end of file diff --git a/build.gradle b/build.gradle index 08bca14..ca462c7 100644 --- a/build.gradle +++ b/build.gradle @@ -15,10 +15,23 @@ java { } } +sourceSets { + integrationTest { + java.srcDir 'src/integrationTest/java' + resources.srcDir 'src/integrationTest/resources' + + compileClasspath += sourceSets.main.output + runtimeClasspath += sourceSets.main.output + } +} + configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } + + integrationTestImplementation.extendsFrom testImplementation + integrationTestRuntimeOnly.extendsFrom testRuntimeOnly } ext { @@ -26,17 +39,17 @@ ext { } dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-webmvc' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-amqp' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' implementation 'io.jsonwebtoken:jjwt-api:0.13.0' implementation 'org.mapstruct:mapstruct:1.6.3' implementation 'org.mapstruct:mapstruct-processor:1.6.3' implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'org.springframework.boot:spring-boot-starter-amqp' implementation 'com.github.enzobbom:ts-events:v1.0.0' implementation 'com.github.enzobbom:ts-api-contract:v1.1.0' @@ -46,13 +59,24 @@ dependencies { annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webflux-test' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-data-mongodb-test' testImplementation 'org.springframework.boot:spring-boot-starter-security-test' - testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'org.testcontainers:testcontainers-mongodb' + testImplementation 'org.testcontainers:testcontainers-rabbitmq' + testImplementation 'org.assertj:assertj-core' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' } dependencyManagement { @@ -69,6 +93,22 @@ tasks.named('test') { useJUnitPlatform() } +tasks.register('integrationTest', Test) { + description = 'Runs integration tests.' + group = 'verification' + + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + + useJUnitPlatform() + + shouldRunAfter tasks.test +} + +tasks.named('check') { + dependsOn tasks.named('integrationTest') +} + sonarqube { properties { property 'sonar.projectKey', 'task' diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java new file mode 100644 index 0000000..0b73081 --- /dev/null +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java @@ -0,0 +1,423 @@ +package com.javanauta.ts.taskscheduler.controller; + +import com.javanauta.ts.apicontract.http.HttpHeaders; +import com.javanauta.ts.apicontract.response.enums.ResponseStatus; +import com.javanauta.ts.taskscheduler.adapters.in.security.authentication.ForwardedIdentityFilter; +import com.javanauta.ts.taskscheduler.adapters.in.security.config.SecurityConfig; +import com.javanauta.ts.taskscheduler.adapters.in.security.exception.JwtAuthenticationEntryPoint; +import com.javanauta.ts.taskscheduler.adapters.in.security.exception.enums.SecurityExceptionCode; +import com.javanauta.ts.taskscheduler.adapters.in.web.controller.TaskController; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.in.CreateTaskRequestDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.dto.in.UpdateTaskRequestDTO; +import com.javanauta.ts.taskscheduler.adapters.in.web.exception.GlobalExceptionHandler; +import com.javanauta.ts.taskscheduler.adapters.in.web.exception.enums.PresentationExceptionCode; +import com.javanauta.ts.taskscheduler.adapters.in.web.mapper.TaskMapperImpl; +import com.javanauta.ts.taskscheduler.adapters.in.web.path.ApiPaths; +import com.javanauta.ts.taskscheduler.application.data.TaskData; +import com.javanauta.ts.taskscheduler.application.exception.enums.ServiceExceptionCode; +import com.javanauta.ts.taskscheduler.application.service.TaskService; +import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainExceptionCode; +import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainValidationExceptionCode; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +import com.javanauta.ts.taskscheduler.shared.exception.ValidationExceptionDetail; +import com.javanauta.ts.taskscheduler.shared.exception.enums.ValidationExceptionSourceType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webtestclient.autoconfigure.AutoConfigureWebTestClient; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest( + classes = { + TaskController.class, + TaskMapperImpl.class, + SecurityConfig.class, + ForwardedIdentityFilter.class, + JwtAuthenticationEntryPoint.class, + GlobalExceptionHandler.class + }, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureWebTestClient +@EnableAutoConfiguration(exclude = { + MongoAutoConfiguration.class +}) +class TaskControllerIT { + private static final UUID USER_ID = UUID.randomUUID(); + private static final String USER_EMAIL = "user@example.com"; + + @Autowired + private WebTestClient webTestClient; + + @MockitoBean + private TaskService taskService; + + private Task task; + + @BeforeEach + void setUp() { + task = mock(Task.class); + + when(task.getId()).thenReturn("task-123"); + when(task.getName()).thenReturn("Test task"); + when(task.getDescription()).thenReturn("Test description"); + when(task.getCreationDateTime()).thenReturn(Instant.parse("2026-09-15T10:00:00Z")); + when(task.getScheduledDateTime()).thenReturn(Instant.parse("2026-09-16T10:00:00Z")); + when(task.getUserId()).thenReturn(USER_ID); + when(task.getUserEmail()).thenReturn(USER_EMAIL); + when(task.getModificationDateTime()).thenReturn(Instant.parse("2026-09-15T11:00:00Z")); + when(task.getNotificationStatus()).thenReturn(NotificationStatus.PENDING); + when(task.getTimeZoneId()).thenReturn(ZoneId.of("Europe/Dublin")); + } + + // Happy paths of all end points + + @Test + void createTask_shouldCreateTaskWhenAuthenticated() { + CreateTaskRequestDTO request = CreateTaskRequestDTO.builder() + .name("Test task") + .description("Test description") + .scheduledDateTime(Instant.parse("2026-09-16T10:00:00Z")) + .timeZoneId("Europe/Dublin") + .build(); + + when(taskService.createTask(any(TaskData.class))).thenReturn(task); + + webTestClient.post() + .uri(ApiPaths.TASKS_V1) + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.SUCCESS.toString()) + .jsonPath("$.code").isEqualTo(200) + .jsonPath("$.data.id").isEqualTo("task-123") + .jsonPath("$.data.name").isEqualTo("Test task") + .jsonPath("$.data.description").isEqualTo("Test description") + .jsonPath("$.data.userId").isEqualTo(USER_ID.toString()) + .jsonPath("$.data.userEmail").isEqualTo(USER_EMAIL) + .jsonPath("$.data.notificationStatus").isEqualTo(NotificationStatus.PENDING.toString()) + .jsonPath("$.data.timeZoneId").isEqualTo("Europe/Dublin"); + + verify(taskService).createTask(any(TaskData.class)); + } + + @Test + void getTasks_shouldGetTasksWhenAuthenticated() { + when(taskService.getTasks()).thenReturn(List.of(task)); + + webTestClient.get() + .uri(ApiPaths.TASKS_V1) + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.SUCCESS.toString()) + .jsonPath("$.code").isEqualTo(200) + .jsonPath("$.data").isArray() + .jsonPath("$.data.length()").isEqualTo(1) + .jsonPath("$.data[0].id").isEqualTo("task-123") + .jsonPath("$.data[0].name").isEqualTo("Test task") + .jsonPath("$.data[0].userId").isEqualTo(USER_ID.toString()) + .jsonPath("$.data[0].timeZoneId").isEqualTo("Europe/Dublin"); + + verify(taskService).getTasks(); + } + + @Test + void deleteTask_shouldDeleteTaskWhenAuthenticated() { + webTestClient.delete() + .uri(ApiPaths.TASKS_V1 + "/task-123") + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .exchange() + .expectStatus().isNoContent() + .expectBody().isEmpty(); + + verify(taskService).deleteTask("task-123"); + } + + @Test + void updateTask_shouldUpdateTaskWhenAuthenticated() { + UpdateTaskRequestDTO request = UpdateTaskRequestDTO.builder() + .name("Updated task") + .description("Updated description") + .scheduledDateTime(Instant.parse("2026-09-17T10:00:00Z")) + .timeZoneId("Europe/Dublin") + .build(); + + when(taskService.updateTask(any(TaskData.class), eq("task-123"))).thenReturn(task); + + webTestClient.patch() + .uri(ApiPaths.TASKS_V1 + "/task-123") + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.SUCCESS.toString()) + .jsonPath("$.code").isEqualTo(200) + .jsonPath("$.data.id").isEqualTo("task-123") + .jsonPath("$.data.name").isEqualTo("Test task") + .jsonPath("$.data.timeZoneId").isEqualTo("Europe/Dublin"); + + verify(taskService).updateTask(any(TaskData.class), eq("task-123")); + } + + // Security + + @Test + void shouldReturnUnauthorizedWhenUserCannotBeAuthenticated() { + CreateTaskRequestDTO request = CreateTaskRequestDTO.builder() + .name("Test task") + .description("Test description") + .scheduledDateTime(Instant.parse("2026-09-16T10:00:00Z")) + .timeZoneId("Europe/Dublin") + .build(); + + webTestClient.post() + .uri(ApiPaths.TASKS_V1) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isUnauthorized() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(401) + .jsonPath("$.errorCode").isEqualTo( + SecurityExceptionCode.AUTHENTICATION_ERROR.getIdentifier() + ) + .jsonPath("$.validationErrors").isArray(); + + verifyNoInteractions(taskService); + } + + // Business validation + + @Test + void shouldReturnUnprocessableContentWhenDomainValidationFails() { + CreateTaskRequestDTO request = CreateTaskRequestDTO.builder() + .name("Test task") + .description("Test description") + .scheduledDateTime(Instant.parse("2020-09-16T10:00:00Z")) + .timeZoneId("Europe/Dublin") + .build(); + + when(taskService.createTask(any(TaskData.class))).thenThrow( + new ApplicationException( + DomainExceptionCode.DOMAIN_VALIDATION_ERROR, + List.of(new ValidationExceptionDetail( + DomainValidationExceptionCode.SCHEDULED_DATETIME_IN_THE_PAST, + "scheduledDateTime" + )) + ) + ); + + webTestClient.post() + .uri(ApiPaths.TASKS_V1) + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(422) + .jsonPath("$.errorCode").isEqualTo( + DomainExceptionCode.DOMAIN_VALIDATION_ERROR.getIdentifier() + ) + .jsonPath("$.validationErrors").isArray() + .jsonPath("$.validationErrors[0].sourceType").isEqualTo(ValidationExceptionSourceType.FIELD.getIdentifier()) + .jsonPath("$.validationErrors[0].source").isEqualTo("scheduledDateTime") + .jsonPath("$.validationErrors[0].message").isEqualTo( + DomainValidationExceptionCode.SCHEDULED_DATETIME_IN_THE_PAST.getDefaultMessage() + ); + } + + @Test + void shouldReturnNotFoundWhenTaskDoesNotExist() { + doThrow(new ApplicationException(ServiceExceptionCode.TASK_NOT_FOUND, "Task not found")) + .when(taskService) + .deleteTask("task-123"); + + webTestClient.delete() + .uri(ApiPaths.TASKS_V1 + "/task-123") + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .exchange() + .expectStatus().isNotFound() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(404) + .jsonPath("$.errorCode").isEqualTo( + ServiceExceptionCode.TASK_NOT_FOUND.getIdentifier() + ); + + verify(taskService).deleteTask("task-123"); + } + + @Test + void shouldReturnForbiddenWhenTaskNotOwned() { + doThrow(new ApplicationException(ServiceExceptionCode.NO_TASK_OWNERSHIP, "User does not own the requested task")) + .when(taskService) + .deleteTask("task-123"); + + webTestClient.delete() + .uri(ApiPaths.TASKS_V1 + "/task-123") + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .exchange() + .expectStatus().isForbidden() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(403) + .jsonPath("$.errorCode").isEqualTo( + ServiceExceptionCode.NO_TASK_OWNERSHIP.getIdentifier() + ); + + verify(taskService).deleteTask("task-123"); + } + + // Request Validations (RequestBody, data type and data format validations) + + @Test + void updateTask_shouldReturnUnprocessableContentWhenRequestBodyHasNoFields() { + // Specific to 'updateTask()' (PATCH) end point where at least one field must be NotNull + + UpdateTaskRequestDTO request = UpdateTaskRequestDTO.builder() + .build(); + + webTestClient.patch() + .uri(ApiPaths.TASKS_V1 + "/task-123") + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(422) + .jsonPath("$.errorCode").isEqualTo(PresentationExceptionCode.REQUEST_BODY_VIOLATION_ERROR.toString()) + .jsonPath("$.message").isNotEmpty() + .jsonPath("$.validationErrors").isArray() + .jsonPath("$.validationErrors[0].sourceType").isEqualTo(ValidationExceptionSourceType.OBJECT.getIdentifier()) + .jsonPath("$.validationErrors[0].source").isEqualTo("task-123") + .jsonPath("$.validationErrors[0].message").isNotEmpty(); + + verifyNoInteractions(taskService); + } + + @Test + void shouldReturnUnprocessableContentWhenRequestBodyFieldValidationFails() { + // Validation of RequestBody's fields + + CreateTaskRequestDTO request = CreateTaskRequestDTO.builder() + .name("") + .scheduledDateTime(null) + .timeZoneId("invalid-time-zone") + .build(); + + webTestClient.post() + .uri(ApiPaths.TASKS_V1) + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(request) + .exchange() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(422) + .jsonPath("$.errorCode").isEqualTo( + PresentationExceptionCode.REQUEST_BODY_VIOLATION_ERROR.getIdentifier() + ) + .jsonPath("$.validationErrors").isArray(); + + verifyNoInteractions(taskService); + } + + @Test + void shouldReturnUnprocessableContentWhenPathVariableIsInvalid() { + // Invalid PathVariable or RequestParam (at the moment no Params used in end points) + + webTestClient.delete() + .uri(ApiPaths.TASKS_V1 + "/ ") + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .exchange() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(422) + .jsonPath("$.errorCode").isEqualTo(PresentationExceptionCode.PARAM_OR_PATH_VAR_VIOLATION_ERROR.toString()) + .jsonPath("$.message").isNotEmpty() + .jsonPath("$.validationErrors").isArray() + .jsonPath("$.validationErrors[0].sourceType").isEqualTo(ValidationExceptionSourceType.PARAMETER.getIdentifier()) + .jsonPath("$.validationErrors[0].source").isEqualTo("deleteTask.id") + .jsonPath("$.validationErrors[0].message").isNotEmpty(); + + verifyNoInteractions(taskService); + } + + // Invalid requests + + @Test + void shouldReturnBadRequestWhenRequestBodyIsMalformed() { + webTestClient.post() + .uri(ApiPaths.TASKS_V1) + .header(HttpHeaders.USER_ID, USER_ID.toString()) + .header(HttpHeaders.USER_EMAIL, USER_EMAIL) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(""" + { + "name": "Test task", + "scheduledDateTime": + } + """) + .exchange() + .expectStatus().isBadRequest() + .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) + .expectBody() + .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) + .jsonPath("$.code").isEqualTo(400) + .jsonPath("$.errorCode").isEqualTo( + PresentationExceptionCode.JSON_PARSE_ERROR.getIdentifier() + ) + .jsonPath("$.validationErrors").isArray(); + + verifyNoInteractions(taskService); + } +} diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java new file mode 100644 index 0000000..6f286bc --- /dev/null +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java @@ -0,0 +1,175 @@ +package com.javanauta.ts.taskscheduler.mongo; + +import com.javanauta.ts.taskscheduler.adapters.out.persistence.MongoTaskRepository; +import com.javanauta.ts.taskscheduler.adapters.out.persistence.config.MongoConfig; +import com.javanauta.ts.taskscheduler.application.data.TaskData; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mongodb.MongoDBContainer; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataMongoTest +@Import(MongoConfig.class) +@Testcontainers +class MongoTaskRepositoryIT { + private static final Instant INITIAL_DATE_TIME = Instant.parse("2099-09-13T10:00:00Z"); + private static final Instant FINAL_DATE_TIME = Instant.parse("2099-09-13T11:00:00Z"); + + @Autowired + private MongoTaskRepository taskRepository; + + @Container + static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8") + .withEnv("MONGO_INITDB_ROOT_USERNAME", "dev_user") + .withEnv("MONGO_INITDB_ROOT_PASSWORD", "devpass"); + + @DynamicPropertySource + static void mongoProperties(DynamicPropertyRegistry registry) { + registry.add( + "spring.data.mongodb.uri", + () -> String.format( + "mongodb://dev_user:devpass@%s:%d?authSource=admin", + MONGO.getHost(), + MONGO.getMappedPort(27017) + ) + ); + } + + @BeforeEach + void cleanDatabase() { + taskRepository.deleteAll(); + } + + @Test + void findByUserId_shouldReturnOnlyTasksBelongingToUser() { + UUID userId = UUID.randomUUID(); + UUID anotherUserId = UUID.randomUUID(); + + taskRepository.save(taskForUser(userId)); + taskRepository.save(taskForUser(userId)); + taskRepository.save(taskForUser(anotherUserId)); + + List result = taskRepository.findByUserId(userId); + + assertThat(result) + .hasSize(2) + .allMatch(task -> task.getUserId().equals(userId)); + } + + @Test + void findByNotificationStatusInAndScheduledDateTimeBetween_shouldReturnNotifiableTaskInsideRange() { + Instant initialDateTime = INITIAL_DATE_TIME; + Instant finalDateTime = FINAL_DATE_TIME; + + Task task = taskAt( + initialDateTime.plus(30, ChronoUnit.MINUTES), + NotificationStatus.PENDING); + + taskRepository.save(task); + + List result = + taskRepository.findByNotificationStatusInAndScheduledDateTimeBetween( + NotificationStatus.notifiableStatuses(), + initialDateTime, + finalDateTime); + + assertThat(result) + .hasSize(1) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("creationDateTime") + .containsExactly(task) + .allMatch(t -> t.getCreationDateTime().equals(truncateToMillis(task.getCreationDateTime()))); + } + + @Test + void findByNotificationStatusInAndScheduledDateTimeBetween_shouldExcludeTaskOutsideRange() { + Instant initialDateTime = INITIAL_DATE_TIME; + Instant finalDateTime = FINAL_DATE_TIME; + + Task task = taskAt( + finalDateTime.plus(1, ChronoUnit.MINUTES), + NotificationStatus.PENDING); + + taskRepository.save(task); + + List result = + taskRepository.findByNotificationStatusInAndScheduledDateTimeBetween( + NotificationStatus.notifiableStatuses(), + initialDateTime, + finalDateTime); + + assertThat(result).isEmpty(); + } + + @Test + void findByNotificationStatusInAndScheduledDateTimeBetween_shouldExcludeNonNotifiableTask() { + Instant initialDateTime = INITIAL_DATE_TIME; + Instant finalDateTime = FINAL_DATE_TIME; + + Task task = taskAt( + initialDateTime.plus(30, ChronoUnit.MINUTES), + NotificationStatus.NOTIFIED); + + taskRepository.save(task); + + List result = + taskRepository.findByNotificationStatusInAndScheduledDateTimeBetween( + NotificationStatus.notifiableStatuses(), + initialDateTime, + finalDateTime); + + assertThat(result).isEmpty(); + } + + private Task taskForUser(UUID userId) { + return taskAt( + Instant.parse("2099-09-13T10:30:00.000Z"), + NotificationStatus.PENDING, + userId); + } + + private Task taskAt( + Instant scheduledDateTime, + NotificationStatus status) { + + return taskAt(scheduledDateTime, status, UUID.randomUUID()); + } + + private Task taskAt( + Instant scheduledDateTime, + NotificationStatus status, + UUID userId) { + + Task task = Task.create( + new TaskData( + "Test task", + "Test description", + scheduledDateTime, + ZoneId.of("UTC")), + userId, + "test@example.com"); + + task.updateStatus(status); + + return task; + } + + private Instant truncateToMillis(Instant instant) { + return instant.truncatedTo(ChronoUnit.MILLIS); + } +} diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java new file mode 100644 index 0000000..e9719e1 --- /dev/null +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java @@ -0,0 +1,178 @@ +package com.javanauta.ts.taskscheduler.rabbitmq; + +import com.javanauta.ts.events.notification.NotificationCompletedEvent; +import com.javanauta.ts.events.notification.NotificationFailedEvent; +import com.javanauta.ts.events.notification.enums.NotificationFailureType; +import com.javanauta.ts.events.notification.messaging.Exchanges; +import com.javanauta.ts.events.notification.messaging.Queues; +import com.javanauta.ts.events.notification.messaging.RoutingKeys; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.NotificationEventRecoverer; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.RabbitNotificationCompletedListener; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.RabbitNotificationFailedListener; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.config.RabbitInConfig; +import com.javanauta.ts.taskscheduler.adapters.in.messaging.validation.NotificationEventValidator; +import com.javanauta.ts.taskscheduler.adapters.shared.config.RabbitCommonConfig; +import com.javanauta.ts.taskscheduler.application.data.enums.NotificationResult; +import com.javanauta.ts.taskscheduler.application.service.TaskService; +import org.junit.jupiter.api.Test; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.annotation.EnableRabbit; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.testcontainers.rabbitmq.RabbitMQContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; + +@SpringBootTest( + classes = { + RabbitNotificationCompletedListener.class, + RabbitNotificationFailedListener.class, + NotificationEventValidator.class, + NotificationEventRecoverer.class, + RabbitCommonConfig.class, + RabbitInConfig.class, + RabbitNotificationListenersIT.RabbitTestTopology.class + } +) +@EnableAutoConfiguration(exclude = { + MongoAutoConfiguration.class +}) +@EnableRabbit +@Testcontainers +class RabbitNotificationListenersIT { + + @Container + @ServiceConnection + static final RabbitMQContainer RABBITMQ = + new RabbitMQContainer("rabbitmq:4-management"); + + @MockitoBean + private TaskService taskService; + + @Autowired + private RabbitTemplate rabbitTemplate; + + @Test + void completedEvent_shouldProcessNotificationCompletion() { + String taskId = "task-123"; + + NotificationCompletedEvent event = + NotificationCompletedEvent.create(taskId); + + rabbitTemplate.convertAndSend( + Exchanges.NOTIFICATION, + RoutingKeys.NOTIFICATION_COMPLETED, + event); + + // Wait until the listener has consumed the message. + await().untilAsserted(() -> + verify(taskService).processTaskNotificationCompletion( + argThat(result -> + result.taskId().equals(taskId) + && result.notificationResult() == NotificationResult.SUCCESS + && result.errorMessage() == null))); + } + + @Test + void temporaryFailureEvent_shouldProcessTemporaryFailure() { + String taskId = "task-123"; + String error = "SMTP server temporarily unavailable"; + + NotificationFailedEvent event = + NotificationFailedEvent.create( + taskId, + NotificationFailureType.TEMPORARY, + error); + + rabbitTemplate.convertAndSend( + Exchanges.NOTIFICATION, + RoutingKeys.NOTIFICATION_FAILED, + event); + + await().untilAsserted(() -> + verify(taskService).processTaskNotificationFailure( + argThat(result -> + result.taskId().equals(taskId) + && result.notificationResult() == NotificationResult.TEMPORARY_FAILURE + && result.errorMessage().equals(error)))); + } + + @Test + void permanentFailureEvent_shouldProcessPermanentFailure() { + String taskId = "task-123"; + String error = "Invalid recipient address"; + + NotificationFailedEvent event = + NotificationFailedEvent.create( + taskId, + NotificationFailureType.PERMANENT, + error); + + rabbitTemplate.convertAndSend( + Exchanges.NOTIFICATION, + RoutingKeys.NOTIFICATION_FAILED, + event); + + await().untilAsserted(() -> + verify(taskService).processTaskNotificationFailure( + argThat(result -> + result.taskId().equals(taskId) + && result.notificationResult() == NotificationResult.PERMANENT_FAILURE + && result.errorMessage().equals(error)))); + } + + @TestConfiguration + static class RabbitTestTopology { + + @Bean + TopicExchange notificationExchange() { + return new TopicExchange(Exchanges.NOTIFICATION); + } + + @Bean + Queue notificationCompletedQueue() { + return new Queue(Queues.NOTIFICATION_COMPLETED); + } + + @Bean + Binding notificationCompletedBinding( + Queue notificationCompletedQueue, + TopicExchange notificationExchange) { + + return BindingBuilder + .bind(notificationCompletedQueue) + .to(notificationExchange) + .with(RoutingKeys.NOTIFICATION_COMPLETED); + } + + @Bean + Queue notificationFailedQueue() { + return new Queue(Queues.NOTIFICATION_FAILED); + } + + @Bean + Binding notificationFailedBinding( + Queue notificationFailedQueue, + TopicExchange notificationExchange) { + + return BindingBuilder + .bind(notificationFailedQueue) + .to(notificationExchange) + .with(RoutingKeys.NOTIFICATION_FAILED); + } + } +} diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java new file mode 100644 index 0000000..bf7a8a3 --- /dev/null +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java @@ -0,0 +1,114 @@ +package com.javanauta.ts.taskscheduler.rabbitmq; + +import com.javanauta.ts.events.notification.NotificationRequestEvent; +import com.javanauta.ts.events.notification.messaging.Exchanges; +import com.javanauta.ts.events.notification.messaging.Queues; +import com.javanauta.ts.events.notification.messaging.RoutingKeys; +import com.javanauta.ts.taskscheduler.adapters.out.messaging.RabbitNotificationRequestPublisher; +import com.javanauta.ts.taskscheduler.adapters.shared.config.RabbitCommonConfig; +import com.javanauta.ts.taskscheduler.application.data.TaskData; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import org.junit.jupiter.api.Test; +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.mongodb.autoconfigure.MongoAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.rabbitmq.RabbitMQContainer; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest( + classes = { + RabbitNotificationRequestPublisher.class, + RabbitCommonConfig.class, + RabbitNotificationRequestPublisherIT.RabbitTestTopology.class + } +) +@EnableAutoConfiguration(exclude = { + MongoAutoConfiguration.class +}) +@Testcontainers +class RabbitNotificationRequestPublisherIT { + + @Container + @ServiceConnection + static final RabbitMQContainer RABBITMQ = + new RabbitMQContainer("rabbitmq:4-management"); + + @Autowired + private RabbitNotificationRequestPublisher publisher; + + @Autowired + private RabbitTemplate rabbitTemplate; + + @Test + void publishNotificationRequest_shouldPublishNotificationRequestEvent() { + Task task = Task.create( + new TaskData( + "Test task", + "Test description", + Instant.parse("2099-09-15T12:00:00Z"), + ZoneId.of("Europe/Dublin")), + UUID.randomUUID(), + "test@example.com"); + + task.setId("task-123"); + + publisher.publishNotificationRequest(task); + + NotificationRequestEvent event = + (NotificationRequestEvent) rabbitTemplate.receiveAndConvert( + Queues.NOTIFICATION_REQUEST, + 5_000); + + assertThat(event).isNotNull(); + assertThat(event.taskId()).isEqualTo(task.getId()); + assertThat(event.taskName()).isEqualTo(task.getName()); + assertThat(event.taskDescription()).isEqualTo(task.getDescription()); + assertThat(event.taskScheduledDateTime()).isEqualTo(task.getScheduledDateTime()); + assertThat(event.taskRecipient()).isEqualTo(task.getUserEmail()); + assertThat(event.taskZoneId()).isEqualTo(task.getTimeZoneId().toString()); + + assertThat(event.eventId()).isNotNull(); + assertThat(event.occurredAt()).isNotNull(); + } + + @TestConfiguration + static class RabbitTestTopology { + + @Bean + TopicExchange notificationExchange() { + return new TopicExchange(Exchanges.NOTIFICATION); + } + + @Bean + Queue notificationRequestQueue() { + return new Queue(Queues.NOTIFICATION_REQUEST); + } + + @Bean + Binding notificationRequestBinding( + Queue notificationRequestQueue, + TopicExchange notificationExchange) { + + return BindingBuilder + .bind(notificationRequestQueue) + .to(notificationExchange) + .with(RoutingKeys.NOTIFICATION_REQUEST); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java index 9f04a4b..2d1ad4e 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/GlobalExceptionHandler.java @@ -38,7 +38,6 @@ public class GlobalExceptionHandler { PresentationExceptionCode.PARAM_OR_PATH_VAR_VIOLATION_ERROR, HttpStatus.UNPROCESSABLE_CONTENT, PresentationExceptionCode.TYPE_MISMATCH_ERROR, HttpStatus.BAD_REQUEST, PresentationExceptionCode.JSON_PARSE_ERROR, HttpStatus.BAD_REQUEST, - PresentationExceptionCode.MISSING_PARAMETER_ERROR, HttpStatus.BAD_REQUEST, PresentationExceptionCode.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR); // Input validation exceptions: @Valid and @Validated @@ -150,23 +149,6 @@ public ResponseEntity handleHttpMessageNotReadableException(HttpM return ResponseEntity.status(httpCode).body(errorResponse); } - // Handles missing required parameters in request - @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException ex) { - ExceptionCode exceptionCode = PresentationExceptionCode.MISSING_PARAMETER_ERROR; - HttpStatus httpCode = PRESENTATION_CODE_HTTP_STATUS_MAP.get(exceptionCode); - String errorIdentifier = exceptionCode.getIdentifier(); - String message = "Missing required parameter: " + ex.getParameterName(); - - ErrorResponse errorResponse = new ErrorResponse( - httpCode.value(), - errorIdentifier, - message, - List.of()); - - return ResponseEntity.status(httpCode).body(errorResponse); - } - // @ExceptionHandler(ApplicationException.class) diff --git a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java index 6a8a403..e8cde62 100644 --- a/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java +++ b/src/main/java/com/javanauta/ts/taskscheduler/adapters/in/web/exception/enums/PresentationExceptionCode.java @@ -7,7 +7,6 @@ public enum PresentationExceptionCode implements ExceptionCode { PARAM_OR_PATH_VAR_VIOLATION_ERROR("Request parameter or path variable validation failed. One or more fields did not meet the validation criteria"), TYPE_MISMATCH_ERROR("Request parameter type mismatch. One or more fields could not be converted to the expected type"), JSON_PARSE_ERROR("Invalid JSON format or invalid data types. The request body could not be parsed"), - MISSING_PARAMETER_ERROR("Missing required request parameter. One or more required parameters were not provided"), INTERNAL_SERVER_ERROR("Internal server error"); private final String defaultMessage; diff --git a/src/test/java/com/javanauta/ts/taskscheduler/application/service/TaskServiceTest.java b/src/test/java/com/javanauta/ts/taskscheduler/application/service/TaskServiceTest.java new file mode 100644 index 0000000..ad0cdd8 --- /dev/null +++ b/src/test/java/com/javanauta/ts/taskscheduler/application/service/TaskServiceTest.java @@ -0,0 +1,377 @@ +package com.javanauta.ts.taskscheduler.application.service; + +import com.javanauta.ts.taskscheduler.application.data.NotificationResultDetails; +import com.javanauta.ts.taskscheduler.application.data.TaskData; +import com.javanauta.ts.taskscheduler.application.data.enums.NotificationResult; +import com.javanauta.ts.taskscheduler.application.exception.enums.ServiceExceptionCode; +import com.javanauta.ts.taskscheduler.domain.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import com.javanauta.ts.taskscheduler.ports.out.messaging.NotificationRequestPublisher; +import com.javanauta.ts.taskscheduler.ports.out.persistence.TaskPersister; +import com.javanauta.ts.taskscheduler.ports.out.security.PrincipalProvider; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class TaskServiceTest { + @Mock + private TaskPersister taskPersister; + + @Mock + private PrincipalProvider principalProvider; + + @Mock + private NotificationRequestPublisher notificationRequestPublisher; + + @InjectMocks + private TaskService underTest; + + private static final UUID USER_ID = UUID.randomUUID(); + private static final UUID OTHER_USER_ID = UUID.randomUUID(); + private static final String USER_EMAIL = "user@example.com"; + private static final String TASK_ID = "task-id"; + + private static final Instant SCHEDULED_DATE_TIME = + Instant.now().plusSeconds(3600); + + private static TaskData validTaskData() { + return new TaskData( + "Test task", + "Test description", + SCHEDULED_DATE_TIME, + ZoneId.of("Europe/Dublin")); + } + + private static Task taskOwnedBy(UUID userId) { + Task task = Task.create(validTaskData(), userId, USER_EMAIL); + task.setId(TASK_ID); + return task; + } + + // createTask + + @Test + void createTask_shouldCreateAndPersistTaskForCurrentUser() { + TaskData taskData = validTaskData(); + Task savedTask = taskOwnedBy(USER_ID); + + when(principalProvider.getId()).thenReturn(USER_ID); + when(principalProvider.getEmail()).thenReturn(USER_EMAIL); + when(taskPersister.save(any(Task.class))).thenReturn(savedTask); + + Task result = underTest.createTask(taskData); + + assertThat(result).isSameAs(savedTask); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Task.class); + verify(taskPersister).save(taskCaptor.capture()); + + Task taskToSave = taskCaptor.getValue(); + assertThat(taskToSave.getName()).isEqualTo(taskData.name()); + assertThat(taskToSave.getDescription()).isEqualTo(taskData.description()); + assertThat(taskToSave.getScheduledDateTime()) + .isEqualTo(taskData.scheduledDateTime()); + assertThat(taskToSave.getUserId()).isEqualTo(USER_ID); + assertThat(taskToSave.getUserEmail()).isEqualTo(USER_EMAIL); + assertThat(taskToSave.getNotificationStatus()) + .isEqualTo(NotificationStatus.PENDING); + } + + // deleteTask + + @Test + void deleteTask_shouldDeleteOwnedTask() { + Task task = taskOwnedBy(USER_ID); + + when(principalProvider.getId()).thenReturn(USER_ID); + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + + underTest.deleteTask(TASK_ID); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister).deleteById(TASK_ID); + } + + @Test + void deleteTask_shouldRejectWhenTaskDoesNotExist() { + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> underTest.deleteTask(TASK_ID)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(ServiceExceptionCode.TASK_NOT_FOUND); + }); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).deleteById(any()); + verifyNoInteractions(principalProvider); + } + + @Test + void deleteTask_shouldRejectWhenTaskDoesNotBelongToCurrentUser() { + Task task = taskOwnedBy(OTHER_USER_ID); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + when(principalProvider.getId()).thenReturn(USER_ID); + + assertThatThrownBy(() -> underTest.deleteTask(TASK_ID)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(ServiceExceptionCode.NO_TASK_OWNERSHIP); + }); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).deleteById(any()); + } + + // updateTask + + @Test + void updateTask_shouldUpdateAndPersistOwnedTask() { + Task task = taskOwnedBy(USER_ID); + TaskData updateData = new TaskData( + "Updated task", + "Updated description", + Instant.now().plusSeconds(7200), + ZoneId.of("America/New_York")); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + when(principalProvider.getId()).thenReturn(USER_ID); + when(taskPersister.save(task)).thenReturn(task); + + Task result = underTest.updateTask(updateData, TASK_ID); + + assertThat(result).isSameAs(task); + assertThat(task.getName()).isEqualTo("Updated task"); + assertThat(task.getDescription()).isEqualTo("Updated description"); + assertThat(task.getScheduledDateTime()).isEqualTo(updateData.scheduledDateTime()); + assertThat(task.getTimeZoneId()).isEqualTo(updateData.timeZoneId()); + assertThat(task.getModificationDateTime()).isNotNull(); + + verify(taskPersister).save(task); + } + + @Test + void updateTask_shouldRejectWhenTaskDoesNotExist() { + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + underTest.updateTask(validTaskData(), TASK_ID)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(ServiceExceptionCode.TASK_NOT_FOUND); + }); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).save(any()); + verifyNoInteractions(principalProvider); + } + + @Test + void updateTask_shouldRejectWhenTaskDoesNotBelongToCurrentUser() { + Task task = taskOwnedBy(OTHER_USER_ID); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + when(principalProvider.getId()).thenReturn(USER_ID); + + assertThatThrownBy(() -> + underTest.updateTask(validTaskData(), TASK_ID)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(ServiceExceptionCode.NO_TASK_OWNERSHIP); + }); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).save(any()); + } + + @Test + void updateTask_shouldNotPersistWhenDomainValidationFails() { + Task task = taskOwnedBy(USER_ID); + + TaskData invalidData = new TaskData( + "Updated task", + "Updated description", + Instant.now().minusSeconds(1), + ZoneId.of("Europe/Dublin")); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + when(principalProvider.getId()).thenReturn(USER_ID); + + assertThatThrownBy(() -> + underTest.updateTask(invalidData, TASK_ID)) + .isInstanceOf(ApplicationException.class); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).save(any()); + } + + // requestTaskNotification + + @Test + void requestTaskNotification_shouldPublishAndMarkTaskAsDispatchedWhenTaskCanBeNotified() { + Task task = taskOwnedBy(USER_ID); + + // The Task starts as PENDING, which is expected to be notifiable. + underTest.requestTaskNotification(task); + + verify(notificationRequestPublisher).publishNotificationRequest(task); + verify(taskPersister).save(task); + + assertThat(task.getNotificationStatus()) + .isEqualTo(NotificationStatus.DISPATCHED); + } + + @Test + void requestTaskNotification_shouldDoNothingWhenTaskCannotBeNotified() { + Task task = taskOwnedBy(USER_ID); + task.updateStatus(NotificationStatus.NOTIFIED); + + underTest.requestTaskNotification(task); + + verifyNoInteractions(notificationRequestPublisher); + verifyNoInteractions(taskPersister); + } + + // processTaskNotificationCompletion + + @Test + void processTaskNotificationCompletion_shouldMarkTaskAsNotified() { + Task task = taskOwnedBy(USER_ID); + + NotificationResultDetails resultDetails = + new NotificationResultDetails( + TASK_ID, + NotificationResult.SUCCESS, + null); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + + underTest.processTaskNotificationCompletion(resultDetails); + + assertThat(task.getNotificationStatus()) + .isEqualTo(NotificationStatus.NOTIFIED); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister).save(task); + } + + @Test + void processTaskNotificationCompletion_shouldRejectWhenTaskDoesNotExist() { + NotificationResultDetails resultDetails = + new NotificationResultDetails( + TASK_ID, + NotificationResult.SUCCESS, + null); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + underTest.processTaskNotificationCompletion(resultDetails)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(ServiceExceptionCode.TASK_NOT_FOUND); + }); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).save(any()); + } + + // processTaskNotificationFailure + + @Test + void processTaskNotificationFailure_shouldMarkTaskAsPendingRetryForTemporaryFailure() { + Task task = taskOwnedBy(USER_ID); + + NotificationResultDetails resultDetails = + new NotificationResultDetails( + TASK_ID, + NotificationResult.TEMPORARY_FAILURE, + "Temporary error"); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + + underTest.processTaskNotificationFailure(resultDetails); + + assertThat(task.getNotificationStatus()) + .isEqualTo(NotificationStatus.PENDING_RETRY); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister).save(task); + } + + @Test + void processTaskNotificationFailure_shouldMarkTaskAsFailedForPermanentFailure() { + Task task = taskOwnedBy(USER_ID); + + NotificationResultDetails resultDetails = + new NotificationResultDetails( + TASK_ID, + NotificationResult.PERMANENT_FAILURE, + "Permanent error"); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.of(task)); + + underTest.processTaskNotificationFailure(resultDetails); + + assertThat(task.getNotificationStatus()) + .isEqualTo(NotificationStatus.FAILED); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister).save(task); + } + + @Test + void processTaskNotificationFailure_shouldRejectWhenTaskDoesNotExist() { + NotificationResultDetails resultDetails = + new NotificationResultDetails( + TASK_ID, + NotificationResult.TEMPORARY_FAILURE, + "Temporary error"); + + when(taskPersister.findById(TASK_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> + underTest.processTaskNotificationFailure(resultDetails)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(ServiceExceptionCode.TASK_NOT_FOUND); + }); + + verify(taskPersister).findById(TASK_ID); + verify(taskPersister, never()).save(any()); + } +} diff --git a/src/test/java/com/javanauta/ts/taskscheduler/domain/model/TaskTest.java b/src/test/java/com/javanauta/ts/taskscheduler/domain/model/TaskTest.java new file mode 100644 index 0000000..e2eb188 --- /dev/null +++ b/src/test/java/com/javanauta/ts/taskscheduler/domain/model/TaskTest.java @@ -0,0 +1,194 @@ +package com.javanauta.ts.taskscheduler.domain.model; + +import com.javanauta.ts.taskscheduler.application.data.TaskData; +import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainExceptionCode; +import com.javanauta.ts.taskscheduler.domain.exception.enums.DomainValidationExceptionCode; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +import com.javanauta.ts.taskscheduler.shared.exception.ValidationExceptionDetail; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.ZoneId; +import java.util.UUID; + +import static java.util.Arrays.stream; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TaskTest { + private static final UUID USER_ID = UUID.randomUUID(); + private static final String USER_EMAIL = "user@example.com"; + private static final ZoneId TIME_ZONE_ID = ZoneId.of("Europe/Dublin"); + + private static TaskData validTaskData() { + return new TaskData( + "Test task", + "Test description", + Instant.now().plusSeconds(3600), + TIME_ZONE_ID); + } + + @Test + void create_shouldInitializeTaskWithExpectedValuesAndDefaults() { + TaskData taskData = validTaskData(); + + Task task = Task.create(taskData, USER_ID, USER_EMAIL); + + assertThat(task.getId()).isNull(); + assertThat(task.getName()).isEqualTo(taskData.name()); + assertThat(task.getDescription()).isEqualTo(taskData.description()); + assertThat(task.getScheduledDateTime()).isEqualTo(taskData.scheduledDateTime()); + assertThat(task.getUserId()).isEqualTo(USER_ID); + assertThat(task.getUserEmail()).isEqualTo(USER_EMAIL); + assertThat(task.getTimeZoneId()).isEqualTo(TIME_ZONE_ID); + + assertThat(task.getCreationDateTime()).isNotNull(); + assertThat(task.getNotificationStatus()).isEqualTo(NotificationStatus.PENDING); + assertThat(task.getModificationDateTime()).isNull(); + } + + @Test + void create_shouldRejectScheduledDateInThePast() { + TaskData taskData = new TaskData( + "Test task", + "Test description", + Instant.now().minusSeconds(1), + TIME_ZONE_ID); + + assertThatThrownBy(() -> Task.create(taskData, USER_ID, USER_EMAIL)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(DomainExceptionCode.DOMAIN_VALIDATION_ERROR); + + assertThat(applicationException.getValidationExceptionDetails()) + .containsExactly( + new ValidationExceptionDetail( + DomainValidationExceptionCode.SCHEDULED_DATETIME_IN_THE_PAST, + "scheduledDateTime")); + }); + } + + @Test + void updateStatus_shouldUpdateStatusAndModificationDateTime() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + task.updateStatus(NotificationStatus.NOTIFIED); + + assertThat(task.getNotificationStatus()).isEqualTo(NotificationStatus.NOTIFIED); + assertThat(task.getModificationDateTime()).isNotNull(); + } + + @Test + void updateStatus_shouldNotModifyTaskWhenStatusIsUnchanged() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + task.updateStatus(NotificationStatus.PENDING); + + assertThat(task.getNotificationStatus()).isEqualTo(NotificationStatus.PENDING); + assertThat(task.getModificationDateTime()).isNull(); + } + + @Test + void update_shouldUpdateProvidedFields() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + TaskData updateData = new TaskData( + "Updated name", + "Updated description", + Instant.now().plusSeconds(7200), + ZoneId.of("America/New_York")); + + task.update(updateData); + + assertThat(task.getName()).isEqualTo("Updated name"); + assertThat(task.getDescription()).isEqualTo("Updated description"); + assertThat(task.getScheduledDateTime()).isEqualTo(updateData.scheduledDateTime()); + assertThat(task.getTimeZoneId()).isEqualTo(updateData.timeZoneId()); + assertThat(task.getModificationDateTime()).isNotNull(); + } + + @Test + void update_shouldKeepExistingValuesWhenFieldsAreNull() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + String originalName = task.getName(); + String originalDescription = task.getDescription(); + Instant originalScheduledDateTime = task.getScheduledDateTime(); + ZoneId originalTimeZoneId = task.getTimeZoneId(); + + task.update(new TaskData(null, null, null, null)); + + assertThat(task.getName()).isEqualTo(originalName); + assertThat(task.getDescription()).isEqualTo(originalDescription); + assertThat(task.getScheduledDateTime()).isEqualTo(originalScheduledDateTime); + assertThat(task.getTimeZoneId()).isEqualTo(originalTimeZoneId); + assertThat(task.getModificationDateTime()).isNotNull(); + } + + @Test + void update_shouldSetBlankDescriptionToNull() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + task.update(new TaskData(null, " ", null, null)); + + assertThat(task.getDescription()).isNull(); + assertThat(task.getModificationDateTime()).isNotNull(); + } + + @Test + void update_shouldRejectScheduledDateInThePast() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + TaskData updateData = new TaskData( + null, + null, + Instant.now().minusSeconds(1), + null); + + assertThatThrownBy(() -> task.update(updateData)) + .isInstanceOf(ApplicationException.class) + .satisfies(exception -> { + ApplicationException applicationException = (ApplicationException) exception; + + assertThat(applicationException.getCode()) + .isEqualTo(DomainExceptionCode.DOMAIN_VALIDATION_ERROR); + + assertThat(applicationException.getValidationExceptionDetails()) + .containsExactly( + new ValidationExceptionDetail( + DomainValidationExceptionCode.SCHEDULED_DATETIME_IN_THE_PAST, + "scheduledDateTime")); + }); + } + + @Test + void canBeNotified_shouldReturnTrueForNotifiableStatus() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + NotificationStatus notifiableStatus = + NotificationStatus.notifiableStatuses().iterator().next(); + + task.updateStatus(notifiableStatus); + + assertThat(task.canBeNotified()).isTrue(); + } + + @Test + void canBeNotified_shouldReturnFalseForNonNotifiableStatus() { + Task task = Task.create(validTaskData(), USER_ID, USER_EMAIL); + + NotificationStatus nonNotifiableStatus = + stream(NotificationStatus.values()) + .filter(status -> !NotificationStatus.notifiableStatuses().contains(status)) + .findFirst() + .orElseThrow(); + + task.updateStatus(nonNotifiableStatus); + + assertThat(task.canBeNotified()).isFalse(); + } +}