diff --git a/src/main/java/com/beachcheck/outbox/config/OutboxFirebaseConfig.java b/src/main/java/com/beachcheck/outbox/config/OutboxFirebaseConfig.java index e4f59fd3..d5c81d5d 100644 --- a/src/main/java/com/beachcheck/outbox/config/OutboxFirebaseConfig.java +++ b/src/main/java/com/beachcheck/outbox/config/OutboxFirebaseConfig.java @@ -7,22 +7,26 @@ import com.beachcheck.outbox.tracing.OutboxTracing; import com.google.firebase.messaging.FirebaseMessaging; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** - * Why: FirebaseMessaging 빈이 있을 때만 Outbox 전송 관련 빈을 한곳에서 조건부 등록하기 위해. + * Why: Firebase가 활성화됐을 때만 Outbox 전송 관련 빈을 한곳에서 조건부 등록하기 위해. * *

Policy: * *

*/ @Configuration(proxyBeanMethods = false) -@ConditionalOnBean(FirebaseMessaging.class) +@ConditionalOnProperty( + prefix = "app.firebase", + name = "enabled", + havingValue = "true", + matchIfMissing = false) public class OutboxFirebaseConfig { @Bean diff --git a/src/main/java/com/beachcheck/outbox/config/OutboxSchedulingConfig.java b/src/main/java/com/beachcheck/outbox/config/OutboxSchedulingConfig.java index 0770c4fd..1058b210 100644 --- a/src/main/java/com/beachcheck/outbox/config/OutboxSchedulingConfig.java +++ b/src/main/java/com/beachcheck/outbox/config/OutboxSchedulingConfig.java @@ -3,7 +3,6 @@ import com.beachcheck.outbox.service.OutboxPublisher; import java.util.UUID; import org.slf4j.MDC; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.Scheduled; @@ -15,15 +14,14 @@ *

Policy: * *

*/ @Configuration -@ConditionalOnBean(OutboxPublisher.class) @ConditionalOnProperty( - prefix = "app.outbox.polling", - name = "enabled", + prefix = "app", + name = {"firebase.enabled", "outbox.polling.enabled"}, havingValue = "true", matchIfMissing = false) public class OutboxSchedulingConfig { diff --git a/src/test/java/com/beachcheck/outbox/config/OutboxFirebaseConditionContextTest.java b/src/test/java/com/beachcheck/outbox/config/OutboxFirebaseConditionContextTest.java index 1f1193d2..4589dc38 100644 --- a/src/test/java/com/beachcheck/outbox/config/OutboxFirebaseConditionContextTest.java +++ b/src/test/java/com/beachcheck/outbox/config/OutboxFirebaseConditionContextTest.java @@ -1,16 +1,24 @@ package com.beachcheck.outbox.config; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import com.beachcheck.global.config.FirebaseConfig; import com.beachcheck.notification.repository.NotificationRepository; import com.beachcheck.outbox.repository.OutboxEventRepository; import com.beachcheck.outbox.service.OutboxEventDispatcher; import com.beachcheck.outbox.service.OutboxPublisher; import com.beachcheck.outbox.tracing.OutboxTracing; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; import com.google.firebase.messaging.FirebaseMessaging; +import java.io.InputStream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; @@ -20,28 +28,32 @@ class OutboxFirebaseConditionContextTest { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withUserConfiguration(OutboxBeansConfig.class) .withPropertyValues("app.outbox.polling.enabled=true", "app.outbox.polling.batch-size=10") .withBean(OutboxEventRepository.class, () -> mock(OutboxEventRepository.class)) .withBean(NotificationRepository.class, () -> mock(NotificationRepository.class)) .withBean(OutboxTracing.class, () -> mock(OutboxTracing.class)); @Test - @DisplayName("FirebaseMessaging 빈이 없으면 Outbox 관련 빈 없이도 컨텍스트가 정상 기동된다") - void whenFirebaseMessagingMissing_thenContextLoadsWithoutOutboxBeans() { - contextRunner.run( - context -> { - assertThat(context).hasNotFailed(); - assertThat(context).doesNotHaveBean(OutboxEventDispatcher.class); - assertThat(context).doesNotHaveBean(OutboxPublisher.class); - assertThat(context).doesNotHaveBean(OutboxSchedulingConfig.class); - }); + @DisplayName("Firebase가 비활성화되면 Outbox 관련 빈 없이도 컨텍스트가 정상 기동된다") + void whenFirebaseDisabled_thenContextLoadsWithoutOutboxBeans() { + contextRunner + .withUserConfiguration(OutboxBeansConfig.class) + .withPropertyValues("app.firebase.enabled=false") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(OutboxEventDispatcher.class); + assertThat(context).doesNotHaveBean(OutboxPublisher.class); + assertThat(context).doesNotHaveBean(OutboxSchedulingConfig.class); + }); } @Test - @DisplayName("FirebaseMessaging 빈이 있으면 Outbox 관련 빈을 함께 등록한다") - void whenFirebaseMessagingPresent_thenRegisterOutboxBeans() { + @DisplayName("Firebase가 활성화되고 FirebaseMessaging 의존성이 있으면 Outbox 관련 빈을 등록한다") + void whenFirebaseEnabledWithMessaging_thenRegisterOutboxBeans() { contextRunner + .withUserConfiguration(OutboxBeansConfig.class) + .withPropertyValues("app.firebase.enabled=true") .withBean(FirebaseMessaging.class, () -> mock(FirebaseMessaging.class)) .run( context -> { @@ -52,7 +64,65 @@ void whenFirebaseMessagingPresent_thenRegisterOutboxBeans() { }); } + @Test + @DisplayName("Outbox 폴링이 비활성화되면 전송 빈은 유지하고 스케줄러만 등록하지 않는다") + void whenOutboxPollingDisabled_thenRegisterDispatchBeansWithoutScheduler() { + contextRunner + .withUserConfiguration(OutboxBeansConfig.class) + .withPropertyValues("app.firebase.enabled=true", "app.outbox.polling.enabled=false") + .withBean(FirebaseMessaging.class, () -> mock(FirebaseMessaging.class)) + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(OutboxEventDispatcher.class); + assertThat(context).hasSingleBean(OutboxPublisher.class); + assertThat(context).doesNotHaveBean(OutboxSchedulingConfig.class); + }); + } + + @Test + @DisplayName("운영 Firebase와 Outbox 설정을 함께 로드하면 처리 순서와 관계없이 관련 빈을 등록한다") + void whenProductionConfigurationsLoad_thenRegisterFirebaseAndOutboxBeans() { + FirebaseApp firebaseApp = mock(FirebaseApp.class); + FirebaseMessaging firebaseMessaging = mock(FirebaseMessaging.class); + + try (MockedStatic googleCredentials = mockStatic(GoogleCredentials.class); + MockedStatic firebaseApps = mockStatic(FirebaseApp.class); + MockedStatic firebaseMessagingInstances = + mockStatic(FirebaseMessaging.class)) { + googleCredentials + .when(() -> GoogleCredentials.fromStream(any(InputStream.class))) + .thenReturn(mock(GoogleCredentials.class)); + firebaseApps.when(FirebaseApp::getApps).thenReturn(java.util.List.of()); + firebaseApps + .when(() -> FirebaseApp.initializeApp(any(FirebaseOptions.class))) + .thenReturn(firebaseApp); + firebaseMessagingInstances + .when(() -> FirebaseMessaging.getInstance(firebaseApp)) + .thenReturn(firebaseMessaging); + + contextRunner + .withInitializer(context -> context.getEnvironment().setActiveProfiles("test")) + .withUserConfiguration(ProductionBeansConfig.class) + .withPropertyValues( + "app.firebase.enabled=true", "app.firebase.credentials-json-base64=e30=") + .run( + context -> { + assertThat(context).hasNotFailed(); + assertThat(context).hasSingleBean(FirebaseApp.class); + assertThat(context).hasSingleBean(FirebaseMessaging.class); + assertThat(context).hasSingleBean(OutboxEventDispatcher.class); + assertThat(context).hasSingleBean(OutboxPublisher.class); + assertThat(context).hasSingleBean(OutboxSchedulingConfig.class); + }); + } + } + @Configuration(proxyBeanMethods = false) - @Import({OutboxFirebaseConfig.class, OutboxSchedulingConfig.class}) + @Import({OutboxSchedulingConfig.class, OutboxFirebaseConfig.class}) static class OutboxBeansConfig {} + + @Configuration(proxyBeanMethods = false) + @Import({OutboxSchedulingConfig.class, OutboxFirebaseConfig.class, FirebaseConfig.class}) + static class ProductionBeansConfig {} } diff --git a/src/test/java/com/beachcheck/outbox/integration/OutboxPublisherIntegrationTest.java b/src/test/java/com/beachcheck/outbox/integration/OutboxPublisherIntegrationTest.java index 631c6b33..d7202956 100644 --- a/src/test/java/com/beachcheck/outbox/integration/OutboxPublisherIntegrationTest.java +++ b/src/test/java/com/beachcheck/outbox/integration/OutboxPublisherIntegrationTest.java @@ -19,7 +19,9 @@ import com.beachcheck.notification.repository.NotificationRepository; import com.beachcheck.outbox.domain.OutboxEvent; import com.beachcheck.outbox.repository.OutboxEventRepository; +import com.beachcheck.outbox.service.OutboxEventDispatcher; import com.beachcheck.outbox.service.OutboxPublisher; +import com.beachcheck.outbox.tracing.OutboxTracing; import com.beachcheck.support.base.IntegrationTest; import com.beachcheck.support.tracing.RecordingSpanExporter; import com.beachcheck.support.tracing.TracingTestConfiguration; @@ -39,6 +41,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Propagation; @@ -62,7 +67,7 @@ Propagation .NOT_SUPPORTED) // OutboxPublisher의 REQUIRES_NEW 트랜잭션과 격리하여 테스트 간 트랜잭션 롤백이 영향을 주지 않도록 함. // 대신 @BeforeEach에서 deleteAll()로 DB 상태 초기화 -@Import(TracingTestConfiguration.class) +@Import({TracingTestConfiguration.class, OutboxPublisherIntegrationTest.OutboxTestConfig.class}) @TestPropertySource(properties = "management.tracing.sampling.probability=1.0") class OutboxPublisherIntegrationTest extends IntegrationTest { @@ -85,6 +90,29 @@ class OutboxPublisherIntegrationTest extends IntegrationTest { @Autowired private SdkTracerProvider tracerProvider; + @TestConfiguration(proxyBeanMethods = false) + static class OutboxTestConfig { + + @Bean + OutboxEventDispatcher outboxEventDispatcher( + OutboxEventRepository outboxEventRepository, + NotificationRepository notificationRepository, + FirebaseMessaging firebaseMessaging) { + return new OutboxEventDispatcher( + outboxEventRepository, notificationRepository, firebaseMessaging); + } + + @Bean + OutboxPublisher outboxPublisher( + OutboxEventRepository outboxEventRepository, + OutboxEventDispatcher outboxEventDispatcher, + OutboxTracing outboxTracing, + @Value("${app.outbox.polling.batch-size:10}") int batchSize) { + return new OutboxPublisher( + outboxEventRepository, outboxEventDispatcher, outboxTracing, batchSize); + } + } + @BeforeEach void setUp() throws FirebaseMessagingException { flushSpans(tracerProvider); diff --git a/src/test/java/com/beachcheck/support/config/FirebaseTestConfig.java b/src/test/java/com/beachcheck/support/config/FirebaseTestConfig.java index 6725ca85..f856bc76 100644 --- a/src/test/java/com/beachcheck/support/config/FirebaseTestConfig.java +++ b/src/test/java/com/beachcheck/support/config/FirebaseTestConfig.java @@ -16,8 +16,8 @@ * *
    *
  • @TestConfiguration으로 테스트 환경에만 적용 - *
  • @Primary로 FirebaseConfig의 실제 빈보다 우선순위 높게 설정 (실제 FirebaseConfig는 test 환경에서 비활성화되어 있지만 명시적 - * 우선순위 지정) + *
  • 기본 test 프로필의 app.firebase.enabled=false로 실제 FirebaseConfig를 비활성화하고 Mock 빈을 사용 + *
  • @Primary로 테스트용 FirebaseMessaging 의존성을 명확하게 선택 *
  • ApiTest, IntegrationTest 베이스 클래스에 @Import로 자동 적용 *
* @@ -36,9 +36,8 @@ public class FirebaseTestConfig { * *
    *
  • Mockito.mock()으로 Mock 객체 생성 - *
  • @Primary로 실제 FirebaseConfig보다 우선순위 높게 설정 (test 환경에서 FirebaseConfig는 비활성화되어 있지만 명시적 우선순위 - * 지정) - *
  • 개별 테스트에서 @MockBean으로 추가 설정 가능 (예: OutboxPublisherIntegrationTest) + *
  • 기본 test 프로필에서는 app.firebase.enabled=false이므로 실제 FirebaseConfig를 등록하지 않음 + *
  • 개별 테스트에서 반환값과 예외 등 Mock 동작을 추가 설정 가능 *
*/ @Bean diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 9aed543d..15f47825 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -70,7 +70,9 @@ app: outbox: polling: - enabled: false # Outbox 폴링 스케줄러 비활성화 (수동 호출로 테스트) + # @SpringBootTest 기반 Outbox 통합 테스트에서 처리 시점을 직접 제어하기 위해 스케줄러 자동 실행을 비활성화한다. + # Outbox 통합 테스트는 FirebaseMessaging Mock을 사용하고 OutboxPublisher를 직접 호출한다. + enabled: false logging: level: