From 2c0f1f0346a62293b9ef25e79c8ac1cdf641db8b Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Sun, 13 Sep 2026 17:26:40 +0100 Subject: [PATCH 01/15] Introduce unit tests for the Task domain model --- build.gradle | 2 + .../taskscheduler/domain/model/TaskTest.java | 194 ++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/test/java/com/javanauta/ts/taskscheduler/domain/model/TaskTest.java diff --git a/build.gradle b/build.gradle index 08bca14..e2590b1 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,8 @@ dependencies { 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.junit.jupiter:junit-jupiter' + testImplementation 'org.assertj:assertj-core' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' 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(); + } +} From daec1041d4fb71f66241e9dfd0c0914213301a3a Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Sun, 13 Sep 2026 20:16:21 +0100 Subject: [PATCH 02/15] Introduce unit tests for TaskService --- build.gradle | 2 + .../application/service/TaskServiceTest.java | 377 ++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 src/test/java/com/javanauta/ts/taskscheduler/application/service/TaskServiceTest.java diff --git a/build.gradle b/build.gradle index e2590b1..c86a288 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,8 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation 'org.mockito:mockito-core' + testImplementation 'org.mockito:mockito-junit-jupiter' testImplementation 'org.assertj:assertj-core' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' 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()); + } +} From d08dcc5f2c17e429c7f2269c91a658ad3d881ee8 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Mon, 14 Sep 2026 21:38:19 +0100 Subject: [PATCH 03/15] Add integration test for MongoDB's 'custom' queries --- build.gradle | 13 +- .../mongo/MongoTaskRepositoryIT.java | 162 ++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java diff --git a/build.gradle b/build.gradle index c86a288..a92b0ef 100644 --- a/build.gradle +++ b/build.gradle @@ -26,9 +26,9 @@ 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.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' @@ -46,17 +46,22 @@ 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-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.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 { diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java b/src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java new file mode 100644 index 0000000..4f18c4c --- /dev/null +++ b/src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java @@ -0,0 +1,162 @@ +package com.javanauta.ts.taskscheduler.integration.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.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Import; +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"); + + @Container + @ServiceConnection + static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8"); + + @Autowired + private MongoTaskRepository taskRepository; + + @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); + } +} From 3cf140e4ab512717f64f6b68afbe55ecbcb9ea43 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Mon, 14 Sep 2026 22:18:54 +0100 Subject: [PATCH 04/15] Add integration test for RabbitMQ's NotificationRequest event --- build.gradle | 1 + .../RabbitNotificationRequestPublisherIT.java | 112 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java diff --git a/build.gradle b/build.gradle index a92b0ef..bccea3b 100644 --- a/build.gradle +++ b/build.gradle @@ -59,6 +59,7 @@ dependencies { 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' diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java b/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java new file mode 100644 index 0000000..2ee45e4 --- /dev/null +++ b/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java @@ -0,0 +1,112 @@ +package com.javanauta.ts.taskscheduler.integration.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.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.context.annotation.Import; +import org.testcontainers.rabbitmq.RabbitMQContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +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 +@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 From 9fa631ed041174d027ea8f925fa51dbdbe861449 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Tue, 15 Sep 2026 13:05:11 +0100 Subject: [PATCH 05/15] Add integration test for RabbitMQ's listeners (Notification result events) --- .../RabbitNotificationListenersIT.java | 175 ++++++++++++++++++ .../RabbitNotificationRequestPublisherIT.java | 3 +- 2 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java b/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java new file mode 100644 index 0000000..99954f5 --- /dev/null +++ b/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java @@ -0,0 +1,175 @@ +package com.javanauta.ts.taskscheduler.integration.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.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 +@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/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java b/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java index 2ee45e4..f7d2250 100644 --- a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java +++ b/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java @@ -20,10 +20,9 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; -import org.testcontainers.rabbitmq.RabbitMQContainer; 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; From f143ef9b3af796a5b6bf6ef675af53a09c3dd4ed Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Tue, 15 Sep 2026 21:36:31 +0100 Subject: [PATCH 06/15] Add integration test for Controller/API contract --- build.gradle | 3 +- .../controller/TaskControllerIT.java | 309 ++++++++++++++++++ 2 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java diff --git a/build.gradle b/build.gradle index bccea3b..1c218af 100644 --- a/build.gradle +++ b/build.gradle @@ -31,12 +31,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' 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' @@ -50,6 +50,7 @@ dependencies { 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' diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java b/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java new file mode 100644 index 0000000..2ff811c --- /dev/null +++ b/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java @@ -0,0 +1,309 @@ +package com.javanauta.ts.taskscheduler.integration.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.model.Task; +import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; +import com.javanauta.ts.taskscheduler.shared.exception.ApplicationException; +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.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 +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")); + } + + @Test + void 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 shouldReturnUnauthorizedWhenCreatingTaskWithoutAuthentication() { + 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); + } + + @Test + void shouldReturnUnprocessableContentWhenCreateRequestIsInvalid() { + 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 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 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 shouldReturnNotFoundWhenDeletingNonExistingTask() { + 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 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")); + } + + @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); + } + + @Test + void shouldReturnForbiddenWhenDeletingTaskNotOwnedByUser() { + 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"); + } +} From cf78c01efdfaa3a316ab65e93ec7c5b66d6b64c7 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Tue, 15 Sep 2026 23:13:36 +0100 Subject: [PATCH 07/15] Remove unused ControllerAdvice handler --- .../web/exception/GlobalExceptionHandler.java | 18 ------------------ .../enums/PresentationExceptionCode.java | 1 - 2 files changed, 19 deletions(-) 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; From 8f29cc7d20aa65505355d4de3077cb25686ecbfa Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Tue, 15 Sep 2026 23:14:00 +0100 Subject: [PATCH 08/15] Wrap up integration test for Controller/API contract --- .../controller/TaskControllerIT.java | 261 +++++++++++++----- 1 file changed, 186 insertions(+), 75 deletions(-) diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java b/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java index 2ff811c..da5823d 100644 --- a/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java +++ b/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java @@ -16,9 +16,13 @@ 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; @@ -78,8 +82,10 @@ void setUp() { when(task.getTimeZoneId()).thenReturn(ZoneId.of("Europe/Dublin")); } + // Happy paths of all end points + @Test - void shouldCreateTaskWhenAuthenticated() { + void createTask_shouldCreateTaskWhenAuthenticated() { CreateTaskRequestDTO request = CreateTaskRequestDTO.builder() .name("Test task") .description("Test description") @@ -113,7 +119,76 @@ void shouldCreateTaskWhenAuthenticated() { } @Test - void shouldReturnUnauthorizedWhenCreatingTaskWithoutAuthentication() { + 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") @@ -139,14 +214,27 @@ void shouldReturnUnauthorizedWhenCreatingTaskWithoutAuthentication() { verifyNoInteractions(taskService); } + // Business validation + @Test - void shouldReturnUnprocessableContentWhenCreateRequestIsInvalid() { + void shouldReturnUnprocessableContentWhenDomainValidationFails() { CreateTaskRequestDTO request = CreateTaskRequestDTO.builder() - .name("") - .scheduledDateTime(null) - .timeZoneId("invalid-time-zone") + .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()) @@ -160,53 +248,42 @@ void shouldReturnUnprocessableContentWhenCreateRequestIsInvalid() { .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) .jsonPath("$.code").isEqualTo(422) .jsonPath("$.errorCode").isEqualTo( - PresentationExceptionCode.REQUEST_BODY_VIOLATION_ERROR.getIdentifier() + DomainExceptionCode.DOMAIN_VALIDATION_ERROR.getIdentifier() ) - .jsonPath("$.validationErrors").isArray(); - - verifyNoInteractions(taskService); + .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 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(); - } + void shouldReturnNotFoundWhenTaskDoesNotExist() { + doThrow(new ApplicationException(ServiceExceptionCode.TASK_NOT_FOUND, "Task not found")) + .when(taskService) + .deleteTask("task-123"); - @Test - void 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(); + .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 shouldReturnNotFoundWhenDeletingNonExistingTask() { - doThrow(new ApplicationException(ServiceExceptionCode.TASK_NOT_FOUND, "Task not found")) + void shouldReturnForbiddenWhenTaskNotOwned() { + doThrow(new ApplicationException(ServiceExceptionCode.NO_TASK_OWNERSHIP, "User does not own the requested task")) .when(taskService) .deleteTask("task-123"); @@ -215,29 +292,27 @@ void shouldReturnNotFoundWhenDeletingNonExistingTask() { .header(HttpHeaders.USER_ID, USER_ID.toString()) .header(HttpHeaders.USER_EMAIL, USER_EMAIL) .exchange() - .expectStatus().isNotFound() + .expectStatus().isForbidden() .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) - .jsonPath("$.code").isEqualTo(404) + .jsonPath("$.code").isEqualTo(403) .jsonPath("$.errorCode").isEqualTo( - ServiceExceptionCode.TASK_NOT_FOUND.getIdentifier() + ServiceExceptionCode.NO_TASK_OWNERSHIP.getIdentifier() ); verify(taskService).deleteTask("task-123"); } + // Request Validations (RequestBody, data type and data format validations) + @Test - void shouldUpdateTaskWhenAuthenticated() { + void updateTask_shouldReturnUnprocessableContentWhenRequestBodyHasNoFields() { + // Specific to 'updateTask()' (PATCH) end point where at least one field must be NotNull + 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()) @@ -245,39 +320,45 @@ void shouldUpdateTaskWhenAuthenticated() { .contentType(MediaType.APPLICATION_JSON) .bodyValue(request) .exchange() - .expectStatus().isOk() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) .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"); + .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(); - verify(taskService).updateTask(any(TaskData.class), eq("task-123")); + verifyNoInteractions(taskService); } @Test - void shouldReturnBadRequestWhenRequestBodyIsMalformed() { + 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(""" - { - "name": "Test task", - "scheduledDateTime": - } - """) + .bodyValue(request) .exchange() - .expectStatus().isBadRequest() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) - .jsonPath("$.code").isEqualTo(400) + .jsonPath("$.code").isEqualTo(422) .jsonPath("$.errorCode").isEqualTo( - PresentationExceptionCode.JSON_PARSE_ERROR.getIdentifier() + PresentationExceptionCode.REQUEST_BODY_VIOLATION_ERROR.getIdentifier() ) .jsonPath("$.validationErrors").isArray(); @@ -285,25 +366,55 @@ void shouldReturnBadRequestWhenRequestBodyIsMalformed() { } @Test - void shouldReturnForbiddenWhenDeletingTaskNotOwnedByUser() { - doThrow(new ApplicationException(ServiceExceptionCode.NO_TASK_OWNERSHIP, "User does not own the requested task")) - .when(taskService) - .deleteTask("task-123"); + void shouldReturnUnprocessableContentWhenPathVariableIsInvalid() { + // Invalid PathVariable or RequestParam (at the moment no Params used in end points) webTestClient.delete() - .uri(ApiPaths.TASKS_V1 + "/task-123") + .uri(ApiPaths.TASKS_V1 + "/ ") .header(HttpHeaders.USER_ID, USER_ID.toString()) .header(HttpHeaders.USER_EMAIL, USER_EMAIL) .exchange() - .expectStatus().isForbidden() + .expectStatus().isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT) .expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON) .expectBody() .jsonPath("$.status").isEqualTo(ResponseStatus.ERROR.toString()) - .jsonPath("$.code").isEqualTo(403) + .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( - ServiceExceptionCode.NO_TASK_OWNERSHIP.getIdentifier() - ); + PresentationExceptionCode.JSON_PARSE_ERROR.getIdentifier() + ) + .jsonPath("$.validationErrors").isArray(); - verify(taskService).deleteTask("task-123"); + verifyNoInteractions(taskService); } } From 5162edf34e11b6e26b73a3ebc67c6697d1f57284 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Wed, 16 Sep 2026 16:15:18 +0100 Subject: [PATCH 09/15] Introduce new 'integrationTest' task to Gradle to separate unit and integration tests execution --- build.gradle | 35 +++++++++++++++++-- .../controller/TaskControllerIT.java | 2 +- .../mongo/MongoTaskRepositoryIT.java | 2 +- .../RabbitNotificationListenersIT.java | 2 +- .../RabbitNotificationRequestPublisherIT.java | 2 +- 5 files changed, 36 insertions(+), 7 deletions(-) rename src/{test/java/com/javanauta/ts/taskscheduler/integration => integrationTest/java/com/javanauta/ts/taskscheduler}/controller/TaskControllerIT.java (99%) rename src/{test/java/com/javanauta/ts/taskscheduler/integration => integrationTest/java/com/javanauta/ts/taskscheduler}/mongo/MongoTaskRepositoryIT.java (98%) rename src/{test/java/com/javanauta/ts/taskscheduler/integration => integrationTest/java/com/javanauta/ts/taskscheduler}/rabbitmq/RabbitNotificationListenersIT.java (99%) rename src/{test/java/com/javanauta/ts/taskscheduler/integration => integrationTest/java/com/javanauta/ts/taskscheduler}/rabbitmq/RabbitNotificationRequestPublisherIT.java (98%) diff --git a/build.gradle b/build.gradle index 1c218af..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 { @@ -80,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/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java similarity index 99% rename from src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java rename to src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java index da5823d..fa9408f 100644 --- a/src/test/java/com/javanauta/ts/taskscheduler/integration/controller/TaskControllerIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.integration.controller; +package com.javanauta.ts.taskscheduler.controller; import com.javanauta.ts.apicontract.http.HttpHeaders; import com.javanauta.ts.apicontract.response.enums.ResponseStatus; diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java similarity index 98% rename from src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java rename to src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java index 4f18c4c..6c7659b 100644 --- a/src/test/java/com/javanauta/ts/taskscheduler/integration/mongo/MongoTaskRepositoryIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.integration.mongo; +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; diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java similarity index 99% rename from src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java rename to src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java index 99954f5..6c636f4 100644 --- a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationListenersIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.integration.rabbitmq; +package com.javanauta.ts.taskscheduler.rabbitmq; import com.javanauta.ts.events.notification.NotificationCompletedEvent; import com.javanauta.ts.events.notification.NotificationFailedEvent; diff --git a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java similarity index 98% rename from src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java rename to src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java index f7d2250..2309c32 100644 --- a/src/test/java/com/javanauta/ts/taskscheduler/integration/rabbitmq/RabbitNotificationRequestPublisherIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java @@ -1,4 +1,4 @@ -package com.javanauta.ts.taskscheduler.integration.rabbitmq; +package com.javanauta.ts.taskscheduler.rabbitmq; import com.javanauta.ts.events.notification.NotificationRequestEvent; import com.javanauta.ts.events.notification.messaging.Exchanges; From fd37bc79136b4f2266e958a7afd7eaac159be72b Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Wed, 16 Sep 2026 16:25:42 +0100 Subject: [PATCH 10/15] Modify CD workflow to not run tests (already done in the CI workflow) and to build a new image only upon relevant code changes --- .github/workflows/docker-publish.yml | 20 +++++++------------- .github/workflows/pr-check.yml | 2 +- 2 files changed, 8 insertions(+), 14 deletions(-) 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 From 973d0c69f5b54de3267886a7e0700bedcdb03221 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Wed, 16 Sep 2026 16:45:01 +0100 Subject: [PATCH 11/15] Temporary pr-check.yml changes to debug MongoDB integration tests failing on GitHub Actions --- .github/workflows/pr-check.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 482b7ef..8ea518c 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -30,5 +30,10 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew + - name: Docker info + run: | + docker version + docker info + - name: Build with Gradle - run: ./gradlew build # runs 'check' test, which runs unit and integration tests \ No newline at end of file + run: ./gradlew build --info # runs 'check' test, which runs unit and integration tests \ No newline at end of file From 8543cc2706158a35b162bf1191af5863ebd892f1 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Thu, 17 Sep 2026 00:33:26 +0100 Subject: [PATCH 12/15] Adjust Mongo Testcontainer settings so it works with the application's custom MongoConfig class --- .../mongo/MongoTaskRepositoryIT.java | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java index 6c7659b..03cc801 100644 --- a/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java @@ -7,10 +7,14 @@ import com.javanauta.ts.taskscheduler.domain.model.enums.NotificationStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.output.Slf4jLogConsumer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.mongodb.MongoDBContainer; @@ -31,13 +35,28 @@ 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"); - @Container - @ServiceConnection - static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8"); - @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") + .withLogConsumer(new Slf4jLogConsumer( + LoggerFactory.getLogger(MongoTaskRepositoryIT.class))); + + @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(); From 9e35341e1cc522d9cc38eae49b3665cb668b9575 Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Thu, 17 Sep 2026 12:12:05 +0100 Subject: [PATCH 13/15] Remove Mongo auto configuration from Controller and Rabbit integration tests to prevent unnecessary attempts to connect to mongo --- .../ts/taskscheduler/controller/TaskControllerIT.java | 5 ++++- .../rabbitmq/RabbitNotificationListenersIT.java | 5 ++++- .../rabbitmq/RabbitNotificationRequestPublisherIT.java | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java index fa9408f..0b73081 100644 --- a/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/controller/TaskControllerIT.java @@ -27,6 +27,7 @@ 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; @@ -53,7 +54,9 @@ }, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient -@EnableAutoConfiguration +@EnableAutoConfiguration(exclude = { + MongoAutoConfiguration.class +}) class TaskControllerIT { private static final UUID USER_ID = UUID.randomUUID(); private static final String USER_EMAIL = "user@example.com"; diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java index 6c636f4..e9719e1 100644 --- a/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationListenersIT.java @@ -23,6 +23,7 @@ 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; @@ -47,7 +48,9 @@ RabbitNotificationListenersIT.RabbitTestTopology.class } ) -@EnableAutoConfiguration +@EnableAutoConfiguration(exclude = { + MongoAutoConfiguration.class +}) @EnableRabbit @Testcontainers class RabbitNotificationListenersIT { diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java index 2309c32..bf7a8a3 100644 --- a/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/rabbitmq/RabbitNotificationRequestPublisherIT.java @@ -16,6 +16,7 @@ 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; @@ -37,7 +38,9 @@ RabbitNotificationRequestPublisherIT.RabbitTestTopology.class } ) -@EnableAutoConfiguration +@EnableAutoConfiguration(exclude = { + MongoAutoConfiguration.class +}) @Testcontainers class RabbitNotificationRequestPublisherIT { From 60d624692a7afbb24569d845261a4b9def327e0f Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Thu, 17 Sep 2026 12:23:52 +0100 Subject: [PATCH 14/15] Temporarily disable MongoTaskRepositoryIT to make sure other tests are not starting up mongo auto config --- .../javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java index 03cc801..36cc604 100644 --- a/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java @@ -6,6 +6,7 @@ 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.Disabled; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +32,7 @@ @DataMongoTest @Import(MongoConfig.class) @Testcontainers +@Disabled 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"); From 0ba0b2379390852f6659db1ba76532a1f342ae6c Mon Sep 17 00:00:00 2001 From: Enzo Bom Date: Thu, 17 Sep 2026 12:34:28 +0100 Subject: [PATCH 15/15] Remove debugging-related modifications --- .github/workflows/pr-check.yml | 7 +------ .../ts/taskscheduler/mongo/MongoTaskRepositoryIT.java | 10 +--------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8ea518c..482b7ef 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -30,10 +30,5 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x gradlew - - name: Docker info - run: | - docker version - docker info - - name: Build with Gradle - run: ./gradlew build --info # runs 'check' test, which runs unit and integration tests \ 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/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java index 36cc604..6f286bc 100644 --- a/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java +++ b/src/integrationTest/java/com/javanauta/ts/taskscheduler/mongo/MongoTaskRepositoryIT.java @@ -6,21 +6,16 @@ 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.Disabled; import org.junit.jupiter.api.Test; -import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.data.mongodb.test.autoconfigure.DataMongoTest; -import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Import; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.output.Slf4jLogConsumer; 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; @@ -32,7 +27,6 @@ @DataMongoTest @Import(MongoConfig.class) @Testcontainers -@Disabled 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"); @@ -43,9 +37,7 @@ class MongoTaskRepositoryIT { @Container static final MongoDBContainer MONGO = new MongoDBContainer("mongo:8") .withEnv("MONGO_INITDB_ROOT_USERNAME", "dev_user") - .withEnv("MONGO_INITDB_ROOT_PASSWORD", "devpass") - .withLogConsumer(new Slf4jLogConsumer( - LoggerFactory.getLogger(MongoTaskRepositoryIT.class))); + .withEnv("MONGO_INITDB_ROOT_PASSWORD", "devpass"); @DynamicPropertySource static void mongoProperties(DynamicPropertyRegistry registry) {