Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 전송 관련 빈을 한곳에서 조건부 등록하기 위해.
*
* <p>Policy:
*
* <ul>
* <li>FirebaseMessaging 빈이 없으면 dispatcher/publisher를 등록하지 않는다.
* <li>app.firebase.enabled=false면 dispatcher/publisher를 등록하지 않는다.
* <li>조건부 생성 정책은 서비스 클래스가 아니라 configuration 레이어에서 관리한다.
* </ul>
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(FirebaseMessaging.class)
@ConditionalOnProperty(
prefix = "app.firebase",
name = "enabled",
havingValue = "true",
matchIfMissing = false)
public class OutboxFirebaseConfig {

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -15,15 +14,14 @@
* <p>Policy:
*
* <ul>
* <li>app.outbox.polling.enabled=true 일 때만 빈 등록
* <li>app.firebase.enabled=true이고 app.outbox.polling.enabled=true일 때만 빈 등록
* <li>fixedDelay: app.outbox.polling.fixed-delay 값 사용 (기본 1초)
* </ul>
*/
@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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 -> {
Expand All @@ -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> googleCredentials = mockStatic(GoogleCredentials.class);
MockedStatic<FirebaseApp> firebaseApps = mockStatic(FirebaseApp.class);
MockedStatic<FirebaseMessaging> 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 {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {

Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
*
* <ul>
* <li>@TestConfiguration으로 테스트 환경에만 적용
* <li>@Primary로 FirebaseConfig의 실제 빈보다 우선순위 높게 설정 (실제 FirebaseConfig는 test 환경에서 비활성화되어 있지만 명시적
* 우선순위 지정)
* <li>기본 test 프로필의 app.firebase.enabled=false로 실제 FirebaseConfig를 비활성화하고 Mock 빈을 사용
* <li>@Primary로 테스트용 FirebaseMessaging 의존성을 명확하게 선택
* <li>ApiTest, IntegrationTest 베이스 클래스에 @Import로 자동 적용
* </ul>
*
Expand All @@ -36,9 +36,8 @@ public class FirebaseTestConfig {
*
* <ul>
* <li>Mockito.mock()으로 Mock 객체 생성
* <li>@Primary로 실제 FirebaseConfig보다 우선순위 높게 설정 (test 환경에서 FirebaseConfig는 비활성화되어 있지만 명시적 우선순위
* 지정)
* <li>개별 테스트에서 @MockBean으로 추가 설정 가능 (예: OutboxPublisherIntegrationTest)
* <li>기본 test 프로필에서는 app.firebase.enabled=false이므로 실제 FirebaseConfig를 등록하지 않음
* <li>개별 테스트에서 반환값과 예외 등 Mock 동작을 추가 설정 가능
* </ul>
*/
@Bean
Expand Down
4 changes: 3 additions & 1 deletion src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ app:

outbox:
polling:
enabled: false # Outbox 폴링 스케줄러 비활성화 (수동 호출로 테스트)
# @SpringBootTest 기반 Outbox 통합 테스트에서 처리 시점을 직접 제어하기 위해 스케줄러 자동 실행을 비활성화한다.
# Outbox 통합 테스트는 FirebaseMessaging Mock을 사용하고 OutboxPublisher를 직접 호출한다.
enabled: false

logging:
level:
Expand Down
Loading