diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c9a8675e..a1fbde0c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -41,9 +41,23 @@ jobs: java-version: "21" cache: gradle + - name: Check Docker availability + run: docker info + - name: Build & Test run: ./gradlew clean generateProto test build + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: cd-test-artifacts + if-no-files-found: warn + path: | + build/test-results/test/** + build/reports/tests/test/** + build/testcontainers-logs/** + - name: Login to GHCR uses: docker/login-action@v3 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceab4c14..2e5e72f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,19 @@ jobs: java-version: "21" cache: gradle + - name: Check Docker availability + run: docker info + - name: Build & Test run: ./gradlew clean generateProto test build + + - name: Upload test artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: ci-test-artifacts + if-no-files-found: warn + path: | + build/test-results/test/** + build/reports/tests/test/** + build/testcontainers-logs/** diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0bae9f03 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,46 @@ +# 저장소 가이드라인 + +## 프로젝트 구조 및 모듈 구성 + +* DDD 성격의 레이어를 둔 Java 21 / Spring Boot 3 프로젝트. +* 도메인 로직: `.../domain/**` (schedule, dependency, statistics, member, datetime, converters, events). +* 애플리케이션 서비스: `.../application/**` 에서 유스케이스를 오케스트레이션하며, 얇고 트랜잭션 경계 중심으로 유지. +* 인터페이스: `.../interfaces/web` 컨트롤러 + `.../interfaces/dto` DTO, 공용 헬퍼는 `.../interfaces/utils`. +* 인프라스트럭처: `.../infrastructure/config|repository|web` 에 영속성/어댑터/설정 배치. +* 리소스와 설정은 `src/main/resources` 에 위치(로컬은 `application.yml`). 테스트는 패키지 구조를 동일하게 미러링. + +## 빌드, 테스트, 개발 명령어 + +* `./gradlew clean build` — 컴파일, 테스트 실행, 아티팩트 생성. +* `./gradlew test` — JUnit 5 테스트 스위트 실행(로컬/CI 기본 검증 작업). +* `./gradlew bootRun` — `application.yml` 로컬 설정으로 API 실행. +* `./gradlew bootJar` — 배포 가능한 실행 JAR 생성이 필요할 때 사용. + +## 코딩 스타일 및 네이밍 컨벤션 + +* 4칸 들여쓰기와 표준 Java 컨벤션 사용; 가능한 범위에서 `final`/불변성 선호. +* 레이어 역할이 드러나도록 네이밍(`*Service`, `*Controller`, `*Repository`, `*Response`/`*Request` DTOs). +* 이미 사용 중인 Lombok을 보일러플레이트 제거에 활용하며, 생성자/`@Builder` 사용 패턴은 기존 코드와 일관되게 유지. +* Querydsl은 리포지토리와 함께 두되, 쿼리는 컨트롤러/서비스가 아니라 인프라 레이어에 둔다. +* 문서화 주석은 Javadoc 스타일로 작성하고, 공개 API 메서드에만 적용. +* openapi/Swagger 주석은 컨트롤러 메서드 및 DTO에 추가. +* 도메인 예외 발생 시 예외 클래스 자체로 타입화 (ProductNotFoundException, InvalidStockException), 도메인 레이어가 인터페이스 레이어에 의존하지 않도록 유의 + +## 테스트 가이드라인 + +* 프레임워크: JUnit 5, Spring Test, Mockito(JUnit Jupiter 통합 포함). +* 단위 테스트는 도메인/서비스 인접 위치에 두고, 클래스명은 `*Test`, 메서드명은 행위 중심(예: `shouldCreateScheduleWhen...`). +* 도메인 규칙(의존성 사이클, patch 동작)과 리포지토리 쿼리를 커버하고, 외부 경계는 목으로 처리. +* PR 열기 전 `./gradlew test` 실행. 샘플 데이터/설정 추가는 필요한 경우에만. + +## 커밋 및 PR 가이드라인 + +* 히스토리에 사용된 Conventional Commit 스타일 준수(예: `feat: ...`, `docs: ...`, `fix: ...`), 제목은 간결하게. +* PR 설명에는 what/why, 연결된 이슈, 테스트 결과(`./gradlew test` 출력) 포함. 스크린샷은 API 문서/UI 변경에만. +* 스키마/설정 변경(`application.yml`, DB URL/DDL)은 명시하고, 필요 시 마이그레이션/롤백 노트 제공. +* 시크릿 커밋 금지. DB 크리덴셜/외부 키는 환경변수나 프로파일로 오버라이드하고, `application.yml` 은 로컬 개발 기준으로 정상 동작하도록 유지. + +## 보안 및 설정 팁 + +* 로컬 DB 기본값은 `src/main/resources/application.yml` 에 두고, 실제 배포에서는 환경변수/프로파일로 민감값을 오버라이드. +* 로컬이 아닌 환경에서는 `ddl-auto` 를 보수적으로(`validate`/`none`) 유지하고, 스키마 설정 변경 전 팀과 조율. diff --git a/build.gradle b/build.gradle index 804e2d28..9ebc8e0a 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,8 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:rabbitmq' // MySQL implementation 'com.mysql:mysql-connector-j' diff --git "a/docs/006 Testcontainers \355\206\265\355\225\251 \355\205\214\354\212\244\355\212\270/rabbitmq-testcontainers-runbook.md" "b/docs/006 Testcontainers \355\206\265\355\225\251 \355\205\214\354\212\244\355\212\270/rabbitmq-testcontainers-runbook.md" new file mode 100644 index 00000000..1e445c5a --- /dev/null +++ "b/docs/006 Testcontainers \355\206\265\355\225\251 \355\205\214\354\212\244\355\212\270/rabbitmq-testcontainers-runbook.md" @@ -0,0 +1,74 @@ +# RabbitMQ Testcontainers 통합 테스트 런북 + +## 목적 + +- RabbitMQ Testcontainers 기반 통합 테스트를 로컬/CI에서 동일한 방식으로 실행하고, 실패 시 빠르게 원인을 진단한다. + +## 사전 점검 + +1. Docker 상태 확인 + +```bash +docker info +``` + +2. Java/Gradle 확인 + +```bash +java -version +./gradlew --version +``` + +3. 테스트 프로파일 확인 + +- `src/test/resources/application-test.yml`이 로드되는지 확인한다. + +## 실행 방법 + +1. 단일 통합 테스트 실행 + +```bash +./gradlew test --tests "*ScheduleStateRabbitPublishIntegrationTest" +``` + +2. 전체 테스트 실행 + +```bash +./gradlew test +``` + +## Docker 미가용 정책 + +- 테스트 코드는 Docker 미가용 시 `Assumptions.assumeTrue`로 skip 처리한다. +- skip 사유는 테스트 로그에 출력된다. +- CI에서는 테스트 전 `docker info`를 실행해 환경 문제를 조기 감지한다. + +## 실패 시 1차 진단 순서 + +1. Docker 동작 확인: `docker info` +2. Testcontainers 로그 확인: `build/testcontainers-logs/rabbitmq-container.log` +3. 테스트 리포트 확인: + - `build/test-results/test` + - `build/reports/tests/test` +4. RabbitMQ 설정 확인: + - exchange: `task.schedule.direct` + - routing key: `schedule.state.started`, `schedule.state.completed`, `schedule.state.canceled` + - `@DynamicPropertySource` 주입 값(host/port/username/password) +5. 큐 바인딩/정리 확인: + - 테스트 큐 생성 및 바인딩 + - purge/drain 후 검증 시작 여부 + +## flaky 판정 기준 + +- 동일 커밋에서 동일 테스트를 3회 재실행했을 때 1회 이상 timeout/수신 실패가 발생하면 flaky 후보로 분류한다. +- flaky 후보는 아래 항목을 우선 점검한다. + - 비동기 타임아웃 상수 적정성 + - 큐 정리 누락 여부 + - CI 러너의 Docker/RabbitMQ 리소스 상태 + +## CI 아티팩트 + +- 테스트 실패 시 아래 산출물을 보존한다. + - `build/test-results/test/**` + - `build/reports/tests/test/**` + - `build/testcontainers-logs/**` diff --git "a/docs/006 Testcontainers \355\206\265\355\225\251 \355\205\214\354\212\244\355\212\270/testcontainers-integration-test-checklist.md" "b/docs/006 Testcontainers \355\206\265\355\225\251 \355\205\214\354\212\244\355\212\270/testcontainers-integration-test-checklist.md" new file mode 100644 index 00000000..d99750c7 --- /dev/null +++ "b/docs/006 Testcontainers \355\206\265\355\225\251 \355\205\214\354\212\244\355\212\270/testcontainers-integration-test-checklist.md" @@ -0,0 +1,75 @@ +# Testcontainers 기반 통합 테스트 체크리스트 + +> 목적: 로컬/CI 환경에서 RabbitMQ Testcontainers 통합 테스트를 안정적으로 수행하기 위한 구현/운영 체크리스트를 제공한다. + +## 적용 범위 + +- [x] 적용 대상: RabbitMQ Testcontainers 통합 테스트 +- [x] 비적용 범위: 순수 단위 테스트, Mock-only 테스트, RabbitMQ 외 컨테이너(MySQL/Redis/Kafka) + +## 사전 준비 + +- [x] Docker 실행 가능 여부 확인 절차를 문서화 (`docker info`)했다 +- [x] 테스트 의존성 확인 (`build.gradle`의 `org.testcontainers:junit-jupiter`, `org.testcontainers:rabbitmq`) +- [x] 테스트 프로파일 분리 확인 (`src/test/resources/application-test.yml`) +- [x] Java 21 런타임 일치 확인 (로컬/CI) +- [x] CI 러너 Docker 접근 권한 점검 단계를 워크플로에 추가했다 + +## 테스트 설계 + +- [x] 통합 테스트 경계를 메시징 발행(RabbitMQ)으로 명시했다 +- [x] 검증 포인트를 비즈니스 결과(상태 전환별 이벤트 발행/미발행) 기준으로 정의했다 +- [x] 테스트 데이터 격리 전략을 반영했다 (테스트별 동적 큐명) +- [x] 시간 의존 로직은 고정 시각(`BASE_TIME`)을 사용한다 +- [x] 실패 케이스를 포함한다 (권한 불일치, 잘못된 상태 전이, 라우팅 미바인딩) + +## 구현 + +- [x] `@SpringBootTest` + `@ActiveProfiles("test")` 기준을 지킨다 +- [x] 공통 베이스에서 `@DynamicPropertySource`로 런타임 프로퍼티를 주입한다 +- [x] 공통 베이스에서 컨테이너 생명주기(start/stop)를 관리한다 +- [x] Docker 미가용 환경은 `Assumptions.assumeTrue`로 skip 처리하고 사유를 로그에 남긴다 +- [x] 테스트 리소스(큐/바인딩) 초기화/정리(선언, purge/drain, delete)를 명시적으로 수행한다 +- [x] 비동기 검증 타임아웃 상수를 표준화했다 (`MESSAGE_TIMEOUT_MS`, `NO_MESSAGE_TIMEOUT_MS`) +- [x] 테스트 간 상태 누수 방지 절차를 적용했다 (DB 삭제, 큐 purge, `DomainEvents` clear) +- [x] 컨테이너 로그를 `build/testcontainers-logs/rabbitmq-container.log`로 수집한다 + +## 실행 및 확인 + +- [x] 단일 통합 테스트 실행 방법을 문서화한다 + +```bash +./gradlew test --tests "*ScheduleStateRabbitPublishIntegrationTest" +``` + +- [x] 전체 테스트 실행 방법을 문서화한다 + +```bash +./gradlew test +``` + +- [x] 실패 시 1차 진단 순서를 런북에 명시했다 (Docker -> 컨테이너 로그 -> 바인딩/프로퍼티) +- [x] flaky 판단 기준(동일 커밋 재실행 편차/타임아웃 빈도)을 런북에 명시했다 + +## CI 파이프라인 + +- [x] CI에서 Docker 가용성 점검(`docker info`)을 수행한다 +- [x] 기존 `./gradlew ... test ...` 흐름에 통합 테스트를 포함한다 +- [x] 테스트 실패 시 리포트/로그 아티팩트를 보존한다 +- [x] Gradle 캐시(`actions/setup-java`의 `cache: gradle`)를 유지한다 + +## 현재 저장소 기준 메모 + +- [x] Testcontainers 적용 범위는 RabbitMQ 중심이다 +- [x] 참조 테스트 클래스: + `src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/ScheduleStateRabbitPublishIntegrationTest.java` +- [x] 공통 베이스 클래스: `src/test/java/me/gg/pinit/pinittask/infrastructure/events/support/RabbitMqTestcontainersSupport.java` +- [x] 관련 의존성: `build.gradle` +- [x] 테스트 프로파일: `src/test/resources/application-test.yml` +- [x] 실행/진단 런북: `docs/006 Testcontainers 통합 테스트/rabbitmq-testcontainers-runbook.md` + +## 완료 기준 + +- [x] 신규 RabbitMQ 통합 테스트 추가 시 본 체크리스트만으로 PR 준비가 가능하다 +- [x] 로컬/CI 공통 실패 원인 진단 절차가 문서에 포함되어 있다 +- [x] 실제 케이스(RabbitMQ 상태 이벤트 발행 테스트)에 즉시 적용 가능하다 diff --git a/src/test/java/me/gg/pinit/pinittask/infrastructure/events/RabbitEventPublisherTest.java b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/RabbitEventPublisherTest.java new file mode 100644 index 00000000..91163447 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/RabbitEventPublisherTest.java @@ -0,0 +1,75 @@ +package me.gg.pinit.pinittask.infrastructure.events; + +import me.gg.pinit.pinittask.domain.events.DomainEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.rabbit.core.RabbitTemplate; + +import java.util.List; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class RabbitEventPublisherTest { + + @Mock + RabbitTemplate rabbitTemplate; + + RabbitEventPublisher rabbitEventPublisher; + + @BeforeEach + void setUp() { + rabbitEventPublisher = new RabbitEventPublisher( + rabbitTemplate, + List.of(new MappedEventMapper()) + ); + } + + @Test + void publish_withMappedEvent_sendsMessage() { + MappedEvent event = new MappedEvent(10L); + + rabbitEventPublisher.publish(event); + + verify(rabbitTemplate).convertAndSend("exchange.test", "routing.test", "payload-10"); + } + + @Test + void publish_withoutMapper_doesNothing() { + rabbitEventPublisher.publish(new UnmappedEvent()); + + verifyNoInteractions(rabbitTemplate); + } + + private record MappedEvent(Long id) implements DomainEvent { + } + + private record UnmappedEvent() implements DomainEvent { + } + + private static class MappedEventMapper implements AmqpEventMapper { + @Override + public Class eventType() { + return MappedEvent.class; + } + + @Override + public String exchange() { + return "exchange.test"; + } + + @Override + public String routingKey() { + return "routing.test"; + } + + @Override + public Object payload(MappedEvent event) { + return "payload-" + event.id(); + } + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/ScheduleStateRabbitPublishIntegrationTest.java b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/ScheduleStateRabbitPublishIntegrationTest.java new file mode 100644 index 00000000..a090402e --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/ScheduleStateRabbitPublishIntegrationTest.java @@ -0,0 +1,233 @@ +package me.gg.pinit.pinittask.infrastructure.events.schedule; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import me.gg.pinit.pinittask.application.schedule.service.ScheduleStateChangeService; +import me.gg.pinit.pinittask.domain.events.DomainEvents; +import me.gg.pinit.pinittask.domain.member.model.Member; +import me.gg.pinit.pinittask.domain.member.repository.MemberRepository; +import me.gg.pinit.pinittask.domain.schedule.exception.IllegalTransitionException; +import me.gg.pinit.pinittask.domain.schedule.model.Schedule; +import me.gg.pinit.pinittask.domain.schedule.model.ScheduleType; +import me.gg.pinit.pinittask.domain.schedule.repository.ScheduleRepository; +import me.gg.pinit.pinittask.infrastructure.events.support.RabbitMqTestcontainersSupport; +import org.junit.jupiter.api.*; +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +@ActiveProfiles("test") +@Tag("rabbitmq-integration") +class ScheduleStateRabbitPublishIntegrationTest extends RabbitMqTestcontainersSupport { + + private static final Long MEMBER_ID = 77001L; + private static final ZoneId ZONE_ID = ZoneId.of("Asia/Seoul"); + private static final ZonedDateTime BASE_TIME = ZonedDateTime.of(2026, 2, 1, 9, 0, 0, 0, ZONE_ID); + + @Autowired + ScheduleStateChangeService scheduleStateChangeService; + @Autowired + ScheduleRepository scheduleRepository; + @Autowired + MemberRepository memberRepository; + @Autowired + AmqpAdmin amqpAdmin; + @Autowired + RabbitTemplate rabbitTemplate; + @Autowired + ObjectMapper objectMapper; + + private String testQueueName; + + @BeforeEach + void setUp() { + assumeRabbitAvailable(); + Assumptions.assumeTrue(rabbitMQContainer.isRunning(), "RabbitMQ container is not running"); + + scheduleRepository.deleteAll(); + memberRepository.deleteAll(); + + memberRepository.save(new Member(MEMBER_ID, "rabbit-user", ZONE_ID)); + testQueueName = "test.schedule.state.events." + UUID.randomUUID(); + declareTestQueueAndBindings( + ScheduleMessaging.RK_SCHEDULE_STARTED, + ScheduleMessaging.RK_SCHEDULE_COMPLETED, + ScheduleMessaging.RK_SCHEDULE_CANCELED + ); + purgeQueue(); + DomainEvents.getEventsAndClear(); + } + + @AfterEach + void cleanUpQueue() { + if (testQueueName != null) { + amqpAdmin.deleteQueue(testQueueName); + } + DomainEvents.getEventsAndClear(); + } + + @Test + void startSchedule_publishesStartedMessage() throws IOException { + Schedule schedule = saveNotStartedSchedule(); + + scheduleStateChangeService.startSchedule(MEMBER_ID, schedule.getId(), BASE_TIME.plusHours(1)); + + Message message = receiveMessage(MESSAGE_TIMEOUT_MS); + assertThat(message).isNotNull(); + assertThat(message.getMessageProperties().getReceivedRoutingKey()).isEqualTo(ScheduleMessaging.RK_SCHEDULE_STARTED); + + JsonNode payload = objectMapper.readTree(message.getBody()); + assertThat(payload.get("scheduleId").asLong()).isEqualTo(schedule.getId()); + assertThat(payload.get("ownerId").asLong()).isEqualTo(MEMBER_ID); + assertThat(payload.get("beforeState").asText()).isEqualTo("NOT_STARTED"); + assertThat(payload.get("occurredAt").asText()).isNotBlank(); + assertThat(payload.get("idempotentKey").asText()).isNotBlank(); + } + + @Test + void completeSchedule_publishesCompletedMessage() throws IOException { + Schedule schedule = saveNotStartedSchedule(); + + scheduleStateChangeService.completeSchedule(MEMBER_ID, schedule.getId(), BASE_TIME.plusHours(2)); + + Message message = receiveMessage(MESSAGE_TIMEOUT_MS); + assertThat(message).isNotNull(); + assertThat(message.getMessageProperties().getReceivedRoutingKey()).isEqualTo(ScheduleMessaging.RK_SCHEDULE_COMPLETED); + + JsonNode payload = objectMapper.readTree(message.getBody()); + assertThat(payload.get("scheduleId").asLong()).isEqualTo(schedule.getId()); + assertThat(payload.get("ownerId").asLong()).isEqualTo(MEMBER_ID); + assertThat(payload.get("beforeState").asText()).isEqualTo("NOT_STARTED"); + assertThat(payload.get("occurredAt").asText()).isNotBlank(); + assertThat(payload.get("idempotentKey").asText()).isNotBlank(); + } + + @Test + void cancelSchedule_publishesCanceledMessage() throws IOException { + Schedule schedule = saveNotStartedSchedule(); + + scheduleStateChangeService.startSchedule(MEMBER_ID, schedule.getId(), BASE_TIME.plusHours(1)); + assertThat(receiveMessage(MESSAGE_TIMEOUT_MS)).isNotNull(); + + scheduleStateChangeService.cancelSchedule(MEMBER_ID, schedule.getId()); + + Message message = receiveMessage(MESSAGE_TIMEOUT_MS); + assertThat(message).isNotNull(); + assertThat(message.getMessageProperties().getReceivedRoutingKey()).isEqualTo(ScheduleMessaging.RK_SCHEDULE_CANCELED); + + JsonNode payload = objectMapper.readTree(message.getBody()); + assertThat(payload.get("scheduleId").asLong()).isEqualTo(schedule.getId()); + assertThat(payload.get("ownerId").asLong()).isEqualTo(MEMBER_ID); + assertThat(payload.get("beforeState").asText()).isEqualTo("IN_PROGRESS"); + assertThat(payload.get("occurredAt").asText()).isNotBlank(); + assertThat(payload.get("idempotentKey").asText()).isNotBlank(); + } + + @Test + void suspendSchedule_doesNotPublishStateMessage() { + Schedule schedule = saveNotStartedSchedule(); + + scheduleStateChangeService.startSchedule(MEMBER_ID, schedule.getId(), BASE_TIME.plusHours(1)); + assertThat(receiveMessage(MESSAGE_TIMEOUT_MS)).isNotNull(); + + scheduleStateChangeService.suspendSchedule(MEMBER_ID, schedule.getId(), BASE_TIME.plusHours(2)); + + Message message = receiveMessage(NO_MESSAGE_TIMEOUT_MS); + assertThat(message).isNull(); + } + + @Test + void startSchedule_whenOwnerMismatch_doesNotPublishMessage() { + Schedule schedule = saveNotStartedSchedule(); + + assertThatThrownBy(() -> scheduleStateChangeService.startSchedule(MEMBER_ID + 1000, schedule.getId(), BASE_TIME.plusHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Member does not own the schedule"); + + Message message = receiveMessage(NO_MESSAGE_TIMEOUT_MS); + assertThat(message).isNull(); + } + + @Test + void cancelSchedule_whenNotStarted_throwsAndDoesNotPublishMessage() { + Schedule schedule = saveNotStartedSchedule(); + + assertThatThrownBy(() -> scheduleStateChangeService.cancelSchedule(MEMBER_ID, schedule.getId())) + .isInstanceOf(IllegalTransitionException.class); + + Message message = receiveMessage(NO_MESSAGE_TIMEOUT_MS); + assertThat(message).isNull(); + } + + @Test + void startSchedule_whenRoutingKeyNotBound_doesNotReachQueue() { + Schedule schedule = saveNotStartedSchedule(); + recreateQueueWithBindings(List.of(ScheduleMessaging.RK_SCHEDULE_COMPLETED)); + purgeQueue(); + + scheduleStateChangeService.startSchedule(MEMBER_ID, schedule.getId(), BASE_TIME.plusHours(1)); + + Message message = receiveMessage(NO_MESSAGE_TIMEOUT_MS); + assertThat(message).isNull(); + } + + private Schedule saveNotStartedSchedule() { + Schedule schedule = new Schedule( + MEMBER_ID, + null, + "mq-test", + "state-event", + BASE_TIME, + ScheduleType.DEEP_WORK + ); + Schedule saved = scheduleRepository.save(schedule); + DomainEvents.getEventsAndClear(); + purgeQueue(); + return saved; + } + + private void recreateQueueWithBindings(List routingKeys) { + amqpAdmin.deleteQueue(testQueueName); + declareTestQueueAndBindings(routingKeys.toArray(String[]::new)); + } + + private void declareTestQueueAndBindings(String... routingKeys) { + Queue queue = QueueBuilder.nonDurable(testQueueName) + .build(); + amqpAdmin.declareQueue(queue); + + for (String routingKey : routingKeys) { + bindRoutingKey(routingKey); + } + } + + private void bindRoutingKey(String routingKey) { + Binding binding = BindingBuilder.bind(new Queue(testQueueName)) + .to(new DirectExchange(ScheduleMessaging.DIRECT_EXCHANGE)) + .with(routingKey); + amqpAdmin.declareBinding(binding); + } + + private void purgeQueue() { + amqpAdmin.purgeQueue(testQueueName, true); + while (rabbitTemplate.receive(testQueueName) != null) { + // drain leftovers that may still arrive asynchronously + } + } + + private Message receiveMessage(long timeoutMillis) { + return rabbitTemplate.receive(testQueueName, timeoutMillis); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/handler/ScheduleIntegratedEventHandlerTest.java b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/handler/ScheduleIntegratedEventHandlerTest.java new file mode 100644 index 00000000..ab9206d8 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/handler/ScheduleIntegratedEventHandlerTest.java @@ -0,0 +1,49 @@ +package me.gg.pinit.pinittask.infrastructure.events.schedule.handler; + +import me.gg.pinit.pinittask.domain.schedule.event.ScheduleCanceledEvent; +import me.gg.pinit.pinittask.domain.schedule.event.ScheduleCompletedEvent; +import me.gg.pinit.pinittask.domain.schedule.event.ScheduleStartedEvent; +import me.gg.pinit.pinittask.infrastructure.events.RabbitEventPublisher; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ScheduleIntegratedEventHandlerTest { + + @Mock + RabbitEventPublisher rabbitEventPublisher; + + @Test + void startedHandler_publishesEvent() { + ScheduleStartedIntegratedEventHandler handler = new ScheduleStartedIntegratedEventHandler(rabbitEventPublisher); + ScheduleStartedEvent event = new ScheduleStartedEvent(10L, 1L, "NOT_STARTED"); + + handler.on(event); + + verify(rabbitEventPublisher).publish(event); + } + + @Test + void completedHandler_publishesEvent() { + ScheduleCompletedIntegratedEventHandler handler = new ScheduleCompletedIntegratedEventHandler(rabbitEventPublisher); + ScheduleCompletedEvent event = new ScheduleCompletedEvent(11L, 1L, "IN_PROGRESS"); + + handler.on(event); + + verify(rabbitEventPublisher).publish(event); + } + + @Test + void canceledHandler_publishesEvent() { + ScheduleCanceledIntegratedEventHandler handler = new ScheduleCanceledIntegratedEventHandler(rabbitEventPublisher); + ScheduleCanceledEvent event = new ScheduleCanceledEvent(12L, 1L, "COMPLETED"); + + handler.on(event); + + verify(rabbitEventPublisher).publish(event); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/mapper/ScheduleEventMapperTest.java b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/mapper/ScheduleEventMapperTest.java new file mode 100644 index 00000000..2de7c566 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/schedule/mapper/ScheduleEventMapperTest.java @@ -0,0 +1,74 @@ +package me.gg.pinit.pinittask.infrastructure.events.schedule.mapper; + +import me.gg.pinit.pinittask.domain.schedule.event.ScheduleCanceledEvent; +import me.gg.pinit.pinittask.domain.schedule.event.ScheduleCompletedEvent; +import me.gg.pinit.pinittask.domain.schedule.event.ScheduleStartedEvent; +import me.gg.pinit.pinittask.infrastructure.events.schedule.ScheduleMessaging; +import me.gg.pinit.pinittask.infrastructure.events.schedule.dto.ScheduleCanceledPayload; +import me.gg.pinit.pinittask.infrastructure.events.schedule.dto.ScheduleCompletedPayload; +import me.gg.pinit.pinittask.infrastructure.events.schedule.dto.ScheduleStartedPayload; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class ScheduleEventMapperTest { + + @Test + void startedMapper_mapsToExpectedExchangeRoutingAndPayload() { + ScheduleStartedEventMapper mapper = new ScheduleStartedEventMapper(); + ScheduleStartedEvent event = new ScheduleStartedEvent(11L, 101L, "SUSPENDED"); + + Object mapped = mapper.payload(event); + + assertThat(mapper.exchange()).isEqualTo(ScheduleMessaging.DIRECT_EXCHANGE); + assertThat(mapper.routingKey()).isEqualTo(ScheduleMessaging.RK_SCHEDULE_STARTED); + assertThat(mapped).isInstanceOf(ScheduleStartedPayload.class); + + ScheduleStartedPayload payload = (ScheduleStartedPayload) mapped; + assertThat(payload.scheduleId()).isEqualTo(11L); + assertThat(payload.ownerId()).isEqualTo(101L); + assertThat(payload.beforeState()).isEqualTo("SUSPENDED"); + assertThat(payload.idempotentKey()).isNotBlank(); + assertThat(OffsetDateTime.parse(payload.occurredAt())).isNotNull(); + } + + @Test + void completedMapper_mapsToExpectedExchangeRoutingAndPayload() { + ScheduleCompletedEventMapper mapper = new ScheduleCompletedEventMapper(); + ScheduleCompletedEvent event = new ScheduleCompletedEvent(12L, 102L, "IN_PROGRESS"); + + Object mapped = mapper.payload(event); + + assertThat(mapper.exchange()).isEqualTo(ScheduleMessaging.DIRECT_EXCHANGE); + assertThat(mapper.routingKey()).isEqualTo(ScheduleMessaging.RK_SCHEDULE_COMPLETED); + assertThat(mapped).isInstanceOf(ScheduleCompletedPayload.class); + + ScheduleCompletedPayload payload = (ScheduleCompletedPayload) mapped; + assertThat(payload.scheduleId()).isEqualTo(12L); + assertThat(payload.ownerId()).isEqualTo(102L); + assertThat(payload.beforeState()).isEqualTo("IN_PROGRESS"); + assertThat(payload.idempotentKey()).isNotBlank(); + assertThat(OffsetDateTime.parse(payload.occurredAt())).isNotNull(); + } + + @Test + void canceledMapper_mapsToExpectedExchangeRoutingAndPayload() { + ScheduleCanceledEventMapper mapper = new ScheduleCanceledEventMapper(); + ScheduleCanceledEvent event = new ScheduleCanceledEvent(13L, 103L, "COMPLETED"); + + Object mapped = mapper.payload(event); + + assertThat(mapper.exchange()).isEqualTo(ScheduleMessaging.DIRECT_EXCHANGE); + assertThat(mapper.routingKey()).isEqualTo(ScheduleMessaging.RK_SCHEDULE_CANCELED); + assertThat(mapped).isInstanceOf(ScheduleCanceledPayload.class); + + ScheduleCanceledPayload payload = (ScheduleCanceledPayload) mapped; + assertThat(payload.scheduleId()).isEqualTo(13L); + assertThat(payload.ownerId()).isEqualTo(103L); + assertThat(payload.beforeState()).isEqualTo("COMPLETED"); + assertThat(payload.idempotentKey()).isNotBlank(); + assertThat(OffsetDateTime.parse(payload.occurredAt())).isNotNull(); + } +} diff --git a/src/test/java/me/gg/pinit/pinittask/infrastructure/events/support/RabbitMqTestcontainersSupport.java b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/support/RabbitMqTestcontainersSupport.java new file mode 100644 index 00000000..cd77e7d5 --- /dev/null +++ b/src/test/java/me/gg/pinit/pinittask/infrastructure/events/support/RabbitMqTestcontainersSupport.java @@ -0,0 +1,86 @@ +package me.gg.pinit.pinittask.infrastructure.events.support; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assumptions; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.DockerClientFactory; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +public abstract class RabbitMqTestcontainersSupport { + protected static final long MESSAGE_TIMEOUT_MS = 5_000L; + protected static final long NO_MESSAGE_TIMEOUT_MS = 1_500L; + private static final DockerImageName RABBIT_IMAGE = DockerImageName.parse("rabbitmq:3.13-management-alpine"); + protected static final RabbitMQContainer rabbitMQContainer = new RabbitMQContainer(RABBIT_IMAGE); + private static final Path CONTAINER_LOG_PATH = Path.of("build", "testcontainers-logs", "rabbitmq-container.log"); + private static volatile boolean rabbitAvailable; + private static volatile String unavailableReason = "not checked"; + + @DynamicPropertySource + static void configureRabbitProperties(DynamicPropertyRegistry registry) { + try { + if (System.getProperty("api.version") == null || System.getProperty("api.version").isBlank()) { + System.setProperty("api.version", "1.44"); + } + + if (!DockerClientFactory.instance().isDockerAvailable()) { + rabbitAvailable = false; + unavailableReason = "Docker/Testcontainers is not available in this environment"; + System.err.println("[RabbitMQ Testcontainers] " + unavailableReason); + return; + } + if (!rabbitMQContainer.isRunning()) { + rabbitMQContainer.start(); + } + + rabbitAvailable = true; + unavailableReason = ""; + registry.add("spring.rabbitmq.host", rabbitMQContainer::getHost); + registry.add("spring.rabbitmq.port", rabbitMQContainer::getAmqpPort); + registry.add("spring.rabbitmq.username", rabbitMQContainer::getAdminUsername); + registry.add("spring.rabbitmq.password", rabbitMQContainer::getAdminPassword); + } catch (Throwable throwable) { + rabbitAvailable = false; + unavailableReason = throwable.getClass().getSimpleName() + ": " + throwable.getMessage(); + System.err.println("[RabbitMQ Testcontainers] " + unavailableReason); + } + } + + @AfterAll + static void tearDownRabbitContainer() { + writeContainerLogs(); + if (rabbitMQContainer.isRunning()) { + rabbitMQContainer.stop(); + } + } + + private static void writeContainerLogs() { + if (!rabbitMQContainer.isRunning()) { + return; + } + + try { + Files.createDirectories(CONTAINER_LOG_PATH.getParent()); + Files.writeString( + CONTAINER_LOG_PATH, + rabbitMQContainer.getLogs(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING + ); + } catch (IOException e) { + System.err.println("[RabbitMQ Testcontainers] failed to write container logs: " + e.getMessage()); + } + } + + protected void assumeRabbitAvailable() { + Assumptions.assumeTrue(rabbitAvailable, "RabbitMQ integration test skipped: " + unavailableReason); + } +}