diff --git a/docs/infra.md b/docs/infra.md index 163f1c5..452f3b5 100644 --- a/docs/infra.md +++ b/docs/infra.md @@ -91,9 +91,9 @@ make ps # 컨테이너 상태 --- -## 4. AWS 새벽시간대 자동 정지 스케줄링 +## 4. AWS 새벽시간대 자동 정지 스케줄링 (중단) -- 서버 비용 절감을 위해 AWS 운영서버를 새벽 12시 ~ 아침 9시(9시간) 동안 정지 예정입니다. +~~- 서버 비용 절감을 위해 AWS 운영서버를 새벽 12시 ~ 아침 9시(9시간) 동안 정지 예정입니다.~~ - EventBridge Scheduler와 Global Target를 적용했습니다. (테스트 서버는 상시 동작합니다) 상세 코드는 `../infra/modules/aws/app/schedule.tf`를 참고해주세요. diff --git a/infra/modules/aws/app/schedule.tf b/infra/modules/aws/app/schedule.tf index 041c416..12e8500 100644 --- a/infra/modules/aws/app/schedule.tf +++ b/infra/modules/aws/app/schedule.tf @@ -23,7 +23,8 @@ resource "aws_iam_role_policy" "scheduler" { } resource "aws_scheduler_schedule" "stop" { - name = "${var.name_prefix}-stop-night" + name = "${var.name_prefix}-stop-night" + state = "DISABLED" flexible_time_window { mode = "OFF" } @@ -42,7 +43,8 @@ resource "aws_scheduler_schedule" "stop" { } resource "aws_scheduler_schedule" "start" { - name = "${var.name_prefix}-start-morning" + name = "${var.name_prefix}-start-morning" + state = "DISABLED" flexible_time_window { mode = "OFF" } diff --git a/src/main/java/chaeso/zip/server/channel/domain/vo/AgeBand.java b/src/main/java/chaeso/zip/server/channel/domain/vo/AgeBand.java index ad69ffb..6f2f8dd 100644 --- a/src/main/java/chaeso/zip/server/channel/domain/vo/AgeBand.java +++ b/src/main/java/chaeso/zip/server/channel/domain/vo/AgeBand.java @@ -1,5 +1,5 @@ package chaeso.zip.server.channel.domain.vo; public enum AgeBand { - AGE_10S, AGE_20S, AGE_30S, AGE_40S, AGE_50S_PLUS + AGE_10S, AGE_20S, AGE_30S, AGE_40S, AGE_50S_PLUS, UNDECIDED } diff --git a/src/main/java/chaeso/zip/server/onboarding/application/OnboardingServiceImpl.java b/src/main/java/chaeso/zip/server/onboarding/application/OnboardingServiceImpl.java index 80165db..cfedb13 100644 --- a/src/main/java/chaeso/zip/server/onboarding/application/OnboardingServiceImpl.java +++ b/src/main/java/chaeso/zip/server/onboarding/application/OnboardingServiceImpl.java @@ -3,6 +3,7 @@ import chaeso.zip.server.channel.domain.ChannelNotFoundException; import chaeso.zip.server.channel.domain.entity.Channel; import chaeso.zip.server.channel.domain.repository.ChannelRepository; +import chaeso.zip.server.channel.domain.vo.AgeBand; import chaeso.zip.server.onboarding.application.dto.AdHistoryCommand; import chaeso.zip.server.onboarding.application.dto.OnboardingSubmitResponse; import chaeso.zip.server.onboarding.application.dto.PresignPerformanceFileCommand; @@ -53,7 +54,8 @@ public class OnboardingServiceImpl implements OnboardingService { @Override @Transactional(propagation = Propagation.NOT_SUPPORTED) public OnboardingSubmitResponse submit(UUID userId, SubmitOnboardingCommand command) { - validateSubmission(command.adExperience(), command.adHistory(), command.rawFileKeys()); + validateSubmission(command.targetAgeBands(), command.adExperience(), command.adHistory(), + command.rawFileKeys()); List fileUrls = verifyPerformanceFiles(command.rawFileKeys()); @@ -89,9 +91,18 @@ private OnboardingSubmitResponse saveOnboarding(UUID userId, SubmitOnboardingCom Onboarding saved = saveResponse(response); adPerformanceRepository.saveAll(command.adHistory().stream() - .map(row -> AdPerformance.fromOnboarding(userId, PerfSource.MANUAL, row.channelId(), - row.channelNameRaw(), row.budgetWon(), row.impressions(), row.clicks(), - row.conversions(), row.startedAt(), row.endedAt(), null)) + .map(row -> AdPerformance.builder() + .userId(userId) + .sourceType(PerfSource.MANUAL) + .channelId(row.channelId()) + .externalChannelName(row.channelNameRaw()) + .budgetWon(row.budgetWon()) + .impressions(row.impressions()) + .clicks(row.clicks()) + .conversions(row.conversions()) + .startedAt(row.startedAt()) + .endedAt(row.endedAt()) + .build()) .toList()); onboardingAdHistorySnapshotRepository.saveAll(command.adHistory().stream() @@ -118,10 +129,13 @@ private Onboarding saveResponse(Onboarding response) { } /** - * adHistory/rawFileKeys의 관계 규칙을 검증한다. + * targetAgeBands/adHistory/rawFileKeys의 관계 규칙을 검증한다. */ - private void validateSubmission(AdExperience adExperience, List adHistory, - List rawFileKeys) { + private void validateSubmission(List targetAgeBands, AdExperience adExperience, + List adHistory, List rawFileKeys) { + if (targetAgeBands.contains(AgeBand.UNDECIDED) && targetAgeBands.size() > 1) { + throw new OnboardingBusinessException(OnboardingErrorCode.INVALID_AGE_BAND_SELECTION); + } boolean experienced = adExperience == AdExperience.EXPERIENCED; boolean hasAnyHistory = !adHistory.isEmpty() || !rawFileKeys.isEmpty(); if (experienced != hasAnyHistory) { diff --git a/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingErrorCode.java b/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingErrorCode.java index a00478b..354e951 100644 --- a/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingErrorCode.java +++ b/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingErrorCode.java @@ -18,7 +18,8 @@ public enum OnboardingErrorCode implements ErrorCode { CONCURRENT_SUBMISSION(HttpStatus.CONFLICT, "ONB-006", "동시에 제출된 요청이 있어 처리할 수 없습니다. 다시 시도해주세요."), ONBOARDING_NOT_FOUND(HttpStatus.NOT_FOUND, "ONB-007", "온보딩 정보가 없습니다."), PERFORMANCE_FILE_INVALID(HttpStatus.BAD_REQUEST, "ONB-008", "첨부한 성과파일을 확인할 수 없습니다."), - TOO_FEW_MANUAL_FIELDS(HttpStatus.BAD_REQUEST, "ONB-010", "직접 입력한 집행 내역은 예산/집행기간/노출수/클릭수/전환수 중 2개 이상을 입력해야 합니다."); + TOO_FEW_MANUAL_FIELDS(HttpStatus.BAD_REQUEST, "ONB-010", "직접 입력한 집행 내역은 예산/집행기간/노출수/클릭수/전환수 중 2개 이상을 입력해야 합니다."), + INVALID_AGE_BAND_SELECTION(HttpStatus.BAD_REQUEST, "ONB-011", "잘 모르겠어요는 다른 연령대와 함께 선택할 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingNotFoundException.java b/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingNotFoundException.java new file mode 100644 index 0000000..96f11d1 --- /dev/null +++ b/src/main/java/chaeso/zip/server/onboarding/domain/OnboardingNotFoundException.java @@ -0,0 +1,11 @@ +package chaeso.zip.server.onboarding.domain; + +import chaeso.zip.server.common.exception.BusinessException; +import java.util.UUID; + +public class OnboardingNotFoundException extends BusinessException { + + public OnboardingNotFoundException(UUID id) { + super(OnboardingErrorCode.ONBOARDING_NOT_FOUND, "온보딩 정보가 없습니다. id=" + id); + } +} diff --git a/src/main/java/chaeso/zip/server/onboarding/presentation/OnboardingApiDocs.java b/src/main/java/chaeso/zip/server/onboarding/presentation/OnboardingApiDocs.java index 4e25ce3..c596daa 100644 --- a/src/main/java/chaeso/zip/server/onboarding/presentation/OnboardingApiDocs.java +++ b/src/main/java/chaeso/zip/server/onboarding/presentation/OnboardingApiDocs.java @@ -141,6 +141,17 @@ public interface OnboardingApiDocs { } """; + String INVALID_AGE_BAND_SELECTION_EXAMPLE = """ + { + "success": false, + "error": { + "code": "ONB-011", + "message": "잘 모르겠어요는 다른 연령대와 함께 선택할 수 없습니다.", + "fieldErrors": [] + } + } + """; + @Operation(operationId = "submitOnboarding", summary = "온보딩 제출", description = """ 로그인 여부와 관계없이 제출할 수 있다. \ @@ -152,7 +163,7 @@ public interface OnboardingApiDocs { content = @Content( examples = @ExampleObject(name = "SUBMIT_SUCCESS", value = SUBMIT_SUCCESS_EXAMPLE))) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", - description = "형식 오류(C-001) 또는 비즈니스 규칙 위반(ONB-001~003, ONB-008, ONB-010)", + description = "형식 오류(C-001) 또는 비즈니스 규칙 위반(ONB-001~003, ONB-008, ONB-010~011)", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = { @ExampleObject(name = "VALIDATION_ERROR", value = VALIDATION_ERROR_EXAMPLE), @@ -160,7 +171,8 @@ public interface OnboardingApiDocs { @ExampleObject(name = "OBJECTIVE_NOT_ALLOWED", value = OBJECTIVE_NOT_ALLOWED_EXAMPLE), @ExampleObject(name = "AD_EXPERIENCE_MISMATCH", value = AD_EXPERIENCE_MISMATCH_EXAMPLE), @ExampleObject(name = "PERFORMANCE_FILE_INVALID", value = PERFORMANCE_FILE_INVALID_EXAMPLE), - @ExampleObject(name = "TOO_FEW_MANUAL_FIELDS", value = TOO_FEW_MANUAL_FIELDS_EXAMPLE) + @ExampleObject(name = "TOO_FEW_MANUAL_FIELDS", value = TOO_FEW_MANUAL_FIELDS_EXAMPLE), + @ExampleObject(name = "INVALID_AGE_BAND_SELECTION", value = INVALID_AGE_BAND_SELECTION_EXAMPLE) })) @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "존재하지 않는 채널(CH-001)", diff --git a/src/main/java/chaeso/zip/server/onboarding/presentation/dto/SubmitOnboardingRequest.java b/src/main/java/chaeso/zip/server/onboarding/presentation/dto/SubmitOnboardingRequest.java index 9e7e352..d9dde67 100644 --- a/src/main/java/chaeso/zip/server/onboarding/presentation/dto/SubmitOnboardingRequest.java +++ b/src/main/java/chaeso/zip/server/onboarding/presentation/dto/SubmitOnboardingRequest.java @@ -31,7 +31,8 @@ public record SubmitOnboardingRequest( @Schema(description = "서비스 형태", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull ServiceType serviceType, - @Schema(description = "주요 연령대. 1개 이상", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "주요 연령대. 1개 이상. 잘 모르겠어요는 UNDECIDED 단독 선택", + requiredMode = Schema.RequiredMode.REQUIRED) @NotEmpty List targetAgeBands, @Schema(description = "광고 목표(단일 선택). 앱이면 APP_INSTALL/IN_APP_ACTION도 가능", diff --git a/src/main/java/chaeso/zip/server/performance/domain/entity/AdPerformance.java b/src/main/java/chaeso/zip/server/performance/domain/entity/AdPerformance.java index cc1e64f..e8bc06f 100644 --- a/src/main/java/chaeso/zip/server/performance/domain/entity/AdPerformance.java +++ b/src/main/java/chaeso/zip/server/performance/domain/entity/AdPerformance.java @@ -14,6 +14,7 @@ import java.time.LocalDateTime; import java.util.UUID; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import org.hibernate.annotations.UuidGenerator; @@ -78,6 +79,7 @@ public class AdPerformance { @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @Builder private AdPerformance(UUID userId, PerfSource sourceType, UUID channelId, String externalChannelName, Long budgetWon, Long impressions, Long clicks, Long conversions, LocalDate startedAt, LocalDate endedAt, String rawFileUrl) { @@ -101,16 +103,6 @@ private AdPerformance(UUID userId, PerfSource sourceType, UUID channelId, this.cpaActual = ratio(budgetWon, conversions); } - /** - * 온보딩에서 입력한 과거 집행 실적. - */ - public static AdPerformance fromOnboarding(UUID userId, PerfSource sourceType, UUID channelId, - String channelName, Long budgetWon, Long impressions, Long clicks, Long conversions, - LocalDate startedAt, LocalDate endedAt, String rawFileUrl) { - return new AdPerformance(userId, sourceType, channelId, channelName, - budgetWon, impressions, clicks, conversions, startedAt, endedAt, rawFileUrl); - } - private static BigDecimal ratio(Long numerator, Long denominator) { if (numerator == null || denominator == null || denominator == 0L) { return null; diff --git a/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java b/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java index 2c4119c..e0e2ba0 100644 --- a/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java +++ b/src/main/java/chaeso/zip/server/recommendation/application/RecommendationServiceImpl.java @@ -14,6 +14,7 @@ import chaeso.zip.server.estimation.domain.vo.PeriodDaysPolicy; import chaeso.zip.server.onboarding.domain.OnboardingBusinessException; import chaeso.zip.server.onboarding.domain.OnboardingErrorCode; +import chaeso.zip.server.onboarding.domain.OnboardingNotFoundException; import chaeso.zip.server.onboarding.domain.entity.Onboarding; import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; @@ -95,16 +96,13 @@ public SavedRecommendationResponse save(UUID userId, UUID onboardingId) { private Onboarding findOnboarding(UUID onboardingId) { return onboardingRepository.findById(onboardingId) - .orElseThrow(() -> new OnboardingBusinessException( - OnboardingErrorCode.ONBOARDING_NOT_FOUND, - "온보딩 정보가 없습니다. id=" + onboardingId)); + .orElseThrow(() -> new OnboardingNotFoundException(onboardingId)); } private Onboarding findOwnedOnboarding(UUID userId, UUID onboardingId) { Onboarding onboarding = findOnboarding(onboardingId); if (!userId.equals(onboarding.getUserId())) { - throw new OnboardingBusinessException(OnboardingErrorCode.ONBOARDING_NOT_FOUND, - "온보딩 정보가 없습니다. id=" + onboardingId); + throw new OnboardingNotFoundException(onboardingId); } return onboarding; } diff --git a/src/test/java/chaeso/zip/server/common/config/CreatedAtAuditingTest.java b/src/test/java/chaeso/zip/server/common/config/CreatedAtAuditingTest.java index f127d99..8323e55 100644 --- a/src/test/java/chaeso/zip/server/common/config/CreatedAtAuditingTest.java +++ b/src/test/java/chaeso/zip/server/common/config/CreatedAtAuditingTest.java @@ -9,15 +9,16 @@ import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod; import chaeso.zip.server.performance.domain.entity.AdPerformance; import chaeso.zip.server.performance.domain.repository.AdPerformanceRepository; -import chaeso.zip.server.performance.domain.vo.PerfSource; import chaeso.zip.server.simulation.domain.entity.BudgetSimulation; import chaeso.zip.server.simulation.domain.entity.BudgetSimulationItem; import chaeso.zip.server.simulation.domain.repository.BudgetSimulationItemRepository; import chaeso.zip.server.simulation.domain.repository.BudgetSimulationRepository; +import chaeso.zip.server.support.AdPerformanceFixture; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.Month; import java.time.ZoneOffset; import java.util.UUID; import org.junit.jupiter.api.DisplayName; @@ -57,10 +58,7 @@ void fillsAuthIdentityCreatedAt() { @Test @DisplayName("광고 집행 실적 저장 시 생성 시각이 채워진다") void fillsAdPerformanceCreatedAt() { - AdPerformance saved = adPerformanceRepository.save(AdPerformance.fromOnboarding( - UUID.randomUUID(), PerfSource.MANUAL, UUID.randomUUID(), "11번가 광고", - 1_000_000L, 100_000L, 2_000L, 10L, LocalDate.of(2026, 7, 1), - LocalDate.of(2026, 7, 31), null)); + AdPerformance saved = adPerformanceRepository.save(AdPerformanceFixture.adPerformance()); assertUtcNow(saved.getCreatedAt()); } @@ -71,7 +69,7 @@ void fillsOnboardingAdHistorySnapshotCreatedAt() { OnboardingAdHistorySnapshot saved = onboardingAdHistorySnapshotRepository.save(OnboardingAdHistorySnapshot.snapshot( UUID.randomUUID(), UUID.randomUUID(), "11번가 광고", 1_000_000L, 100_000L, - 2_000L, 10L, LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 31))); + 2_000L, 10L, LocalDate.of(2026, Month.JULY, 1), LocalDate.of(2026, Month.JULY, 31))); assertUtcNow(saved.getCreatedAt()); } @@ -109,7 +107,8 @@ void fillsBudgetSimulationItemCreatedAt() { */ private static void assertUtcNow(LocalDateTime createdAt) { assertThat(createdAt).isNotNull(); - LocalDateTime utcNow = LocalDateTime.ofInstant(Instant.now(), ZoneOffset.UTC); - assertThat(Duration.between(utcNow, createdAt).abs()).isLessThan(Duration.ofSeconds(30)); + Instant createdAtInstant = createdAt.toInstant(ZoneOffset.UTC); + assertThat(Duration.between(Instant.now(), createdAtInstant).abs()) + .isLessThan(Duration.ofSeconds(30)); } } diff --git a/src/test/java/chaeso/zip/server/onboarding/application/OnboardingServiceSubmitTest.java b/src/test/java/chaeso/zip/server/onboarding/application/OnboardingServiceSubmitTest.java index 0e8d6ac..c1de22a 100644 --- a/src/test/java/chaeso/zip/server/onboarding/application/OnboardingServiceSubmitTest.java +++ b/src/test/java/chaeso/zip/server/onboarding/application/OnboardingServiceSubmitTest.java @@ -13,6 +13,7 @@ import chaeso.zip.server.channel.domain.ChannelNotFoundException; import chaeso.zip.server.channel.domain.entity.Channel; import chaeso.zip.server.channel.domain.repository.ChannelRepository; +import chaeso.zip.server.channel.domain.vo.AgeBand; import chaeso.zip.server.channel.domain.vo.CampaignObjective; import chaeso.zip.server.onboarding.application.dto.AdHistoryCommand; import chaeso.zip.server.onboarding.application.dto.SubmitOnboardingCommand; @@ -147,6 +148,40 @@ void rejectsConcurrentSubmission() { } } + @Nested + @DisplayName("연령대 선택을 검증한다") + class TargetAgeBands { + + @Test + @DisplayName("잘 모르겠어요를 다른 연령대와 함께 선택하면 ONB-011") + void rejectsUndecidedWithOtherAgeBands() { + SubmitOnboardingCommand command = OnboardingFixture.submitCommand(ServiceType.WEB, + List.of(AgeBand.UNDECIDED, AgeBand.AGE_20S), CampaignObjective.TRAFFIC, 1L, 2L, + AdExperience.NONE, List.of(), List.of()); + + assertThatThrownBy(() -> onboardingService.submit(USER_ID, command)) + .isInstanceOf(OnboardingBusinessException.class) + .extracting("errorCode") + .isEqualTo(OnboardingErrorCode.INVALID_AGE_BAND_SELECTION); + then(onboardingRepository).should(never()).saveAndFlush(any()); + } + + @Test + @DisplayName("잘 모르겠어요만 단독으로 선택하면 통과한다") + void allowsUndecidedAlone() { + SubmitOnboardingCommand command = OnboardingFixture.submitCommand(ServiceType.WEB, + List.of(AgeBand.UNDECIDED), CampaignObjective.TRAFFIC, 1L, 2L, AdExperience.NONE, + List.of(), List.of()); + given(onboardingRepository.findByUserIdAndIsActiveTrue(USER_ID)).willReturn(List.of()); + given(onboardingRepository.saveAndFlush(any(Onboarding.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + onboardingService.submit(USER_ID, command); + + then(onboardingRepository).should().saveAndFlush(any(Onboarding.class)); + } + } + @Nested @DisplayName("예산과 목표를 검증한다") class BudgetAndObjective { diff --git a/src/test/java/chaeso/zip/server/onboarding/domain/OnboardingRepositoryTest.java b/src/test/java/chaeso/zip/server/onboarding/domain/OnboardingRepositoryTest.java index 442aa1c..960812f 100644 --- a/src/test/java/chaeso/zip/server/onboarding/domain/OnboardingRepositoryTest.java +++ b/src/test/java/chaeso/zip/server/onboarding/domain/OnboardingRepositoryTest.java @@ -8,7 +8,7 @@ import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository; import chaeso.zip.server.performance.domain.entity.AdPerformance; import chaeso.zip.server.performance.domain.repository.AdPerformanceRepository; -import chaeso.zip.server.performance.domain.vo.PerfSource; +import chaeso.zip.server.support.AdPerformanceFixture; import chaeso.zip.server.support.OnboardingFixture; import chaeso.zip.server.support.PostgresDataJpaTest; import chaeso.zip.server.support.UserFixture; @@ -58,9 +58,17 @@ void savesAgeBands() { void savesAdPerformances() { UUID userId = persistUser(); - adPerformanceRepository.saveAndFlush(AdPerformance.fromOnboarding( - userId, PerfSource.MANUAL, null, "인스타그램", 3_000_000L, 250_000L, 3_000L, 120L, - LocalDate.of(2025, Month.MARCH, 1), LocalDate.of(2025, Month.MAY, 31), null)); + adPerformanceRepository.saveAndFlush(AdPerformanceFixture.builder() + .userId(userId) + .channelId(null) + .externalChannelName("인스타그램") + .budgetWon(3_000_000L) + .impressions(250_000L) + .clicks(3_000L) + .conversions(120L) + .startedAt(LocalDate.of(2025, Month.MARCH, 1)) + .endedAt(LocalDate.of(2025, Month.MAY, 31)) + .build()); List found = adPerformanceRepository.findByUserId(userId); assertThat(found).hasSize(1); diff --git a/src/test/java/chaeso/zip/server/performance/domain/entity/AdPerformanceTest.java b/src/test/java/chaeso/zip/server/performance/domain/entity/AdPerformanceTest.java new file mode 100644 index 0000000..26d3d31 --- /dev/null +++ b/src/test/java/chaeso/zip/server/performance/domain/entity/AdPerformanceTest.java @@ -0,0 +1,59 @@ +package chaeso.zip.server.performance.domain.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import chaeso.zip.server.support.AdPerformanceFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class AdPerformanceTest { + + @Test + @DisplayName("분자/분모가 모두 있으면 비율을 계산한다") + void computesRatiosWhenAllValuesPresent() { + AdPerformance performance = AdPerformanceFixture.builder().build(); + + assertThat(performance.getCtrActual()).isEqualByComparingTo("0.02"); + assertThat(performance.getCpcActual()).isEqualByComparingTo("500"); + assertThat(performance.getCpaActual()).isEqualByComparingTo("100000"); + } + + @Test + @DisplayName("분자가 없으면 비율은 null이다") + void returnsNullRatioWhenNumeratorIsNull() { + AdPerformance performance = AdPerformanceFixture.builder() + .budgetWon(null) + .build(); + + assertThat(performance.getCtrActual()).isEqualByComparingTo("0.02"); + assertThat(performance.getCpcActual()).isNull(); + assertThat(performance.getCpaActual()).isNull(); + } + + @Test + @DisplayName("분모가 없으면 비율은 null이다") + void returnsNullRatioWhenDenominatorIsNull() { + AdPerformance performance = AdPerformanceFixture.builder() + .impressions(null) + .conversions(null) + .build(); + + assertThat(performance.getCtrActual()).isNull(); + assertThat(performance.getCpcActual()).isEqualByComparingTo("500"); + assertThat(performance.getCpaActual()).isNull(); + } + + @Test + @DisplayName("분모가 0이면 비율은 null이다") + void returnsNullRatioWhenDenominatorIsZero() { + AdPerformance performance = AdPerformanceFixture.builder() + .impressions(0L) + .clicks(0L) + .conversions(0L) + .build(); + + assertThat(performance.getCtrActual()).isNull(); + assertThat(performance.getCpcActual()).isNull(); + assertThat(performance.getCpaActual()).isNull(); + } +} diff --git a/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java b/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java index 8f83e09..882b33c 100644 --- a/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java +++ b/src/test/java/chaeso/zip/server/recommendation/application/RecommendationServiceImplTest.java @@ -30,6 +30,7 @@ import chaeso.zip.server.estimation.application.dto.CountRangeResponse; import chaeso.zip.server.onboarding.domain.OnboardingBusinessException; import chaeso.zip.server.onboarding.domain.OnboardingErrorCode; +import chaeso.zip.server.onboarding.domain.OnboardingNotFoundException; import chaeso.zip.server.onboarding.domain.entity.Onboarding; import chaeso.zip.server.onboarding.domain.repository.OnboardingRepository; import chaeso.zip.server.onboarding.domain.vo.CampaignPeriod; @@ -328,7 +329,7 @@ void rejectsUnknownOnboarding() { given(onboardingRepository.findById(ONBOARDING_ID)).willReturn(Optional.empty()); assertThatThrownBy(() -> recommendationService.recommend(ONBOARDING_ID)) - .isInstanceOf(OnboardingBusinessException.class) + .isInstanceOf(OnboardingNotFoundException.class) .hasMessageContaining(ONBOARDING_ID.toString()); verifyNoInteractions(channelRepository, channelProductRepository, channelPricingRepository); @@ -478,7 +479,7 @@ void rejectsOtherUsersOnboarding() { Onboarding othersOnboarding = onboarding(UUID.randomUUID()); assertThatThrownBy(() -> save(othersOnboarding)) - .isInstanceOf(OnboardingBusinessException.class) + .isInstanceOf(OnboardingNotFoundException.class) .hasMessageContaining(ONBOARDING_ID.toString()); verifyNoInteractions(channelRecommendationRepository, channelRepository); @@ -490,7 +491,7 @@ void rejectsAnonymousOnboarding() { Onboarding anonymous = onboarding(null); assertThatThrownBy(() -> save(anonymous)) - .isInstanceOf(OnboardingBusinessException.class); + .isInstanceOf(OnboardingNotFoundException.class); verifyNoInteractions(channelRecommendationRepository, channelRepository); } @@ -501,7 +502,7 @@ void rejectsUnknownOnboarding() { given(onboardingRepository.findById(ONBOARDING_ID)).willReturn(Optional.empty()); assertThatThrownBy(() -> recommendationService.save(USER_ID, ONBOARDING_ID)) - .isInstanceOf(OnboardingBusinessException.class); + .isInstanceOf(OnboardingNotFoundException.class); verifyNoInteractions(channelRecommendationRepository, channelRepository); } diff --git a/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java b/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java index 25a256c..8dee6a4 100644 --- a/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java +++ b/src/test/java/chaeso/zip/server/recommendation/presentation/RecommendationControllerTest.java @@ -15,6 +15,7 @@ import chaeso.zip.server.estimation.application.dto.CountRangeResponse; import chaeso.zip.server.onboarding.domain.OnboardingBusinessException; import chaeso.zip.server.onboarding.domain.OnboardingErrorCode; +import chaeso.zip.server.onboarding.domain.OnboardingNotFoundException; import chaeso.zip.server.recommendation.application.RecommendationService; import chaeso.zip.server.recommendation.application.dto.RecommendationItemResponse; import chaeso.zip.server.recommendation.application.dto.SavedRecommendationResponse; @@ -108,8 +109,7 @@ void getRecommendations_empty() throws Exception { @Test @DisplayName("존재하지 않는 온보딩으로 조회하면 404 와 공통 에러 포맷을 반환한다") void getRecommendations_onboardingNotFound() throws Exception { - BDDMockito.willThrow(new OnboardingBusinessException( - OnboardingErrorCode.ONBOARDING_NOT_FOUND, "온보딩 정보가 없습니다. id=" + ONBOARDING_ID)) + BDDMockito.willThrow(new OnboardingNotFoundException(ONBOARDING_ID)) .given(recommendationService).recommend(ONBOARDING_ID); mockMvc.perform(get("/api/v1/recommendations").param("onboardingId", ONBOARDING_ID.toString())) @@ -177,8 +177,7 @@ void saveRecommendation_missingOnboardingId() throws Exception { @Test @DisplayName("없는 온보딩이나 남의 온보딩으로 저장하면 404 ONB-007 을 반환한다") void saveRecommendation_onboardingNotFound() throws Exception { - BDDMockito.willThrow(new OnboardingBusinessException( - OnboardingErrorCode.ONBOARDING_NOT_FOUND, "온보딩 정보가 없습니다. id=" + ONBOARDING_ID)) + BDDMockito.willThrow(new OnboardingNotFoundException(ONBOARDING_ID)) .given(recommendationService).save(USER_ID, ONBOARDING_ID); mockMvc.perform(post("/api/v1/recommendations") diff --git a/src/test/java/chaeso/zip/server/support/AdPerformanceFixture.java b/src/test/java/chaeso/zip/server/support/AdPerformanceFixture.java new file mode 100644 index 0000000..e3d8213 --- /dev/null +++ b/src/test/java/chaeso/zip/server/support/AdPerformanceFixture.java @@ -0,0 +1,30 @@ +package chaeso.zip.server.support; + +import chaeso.zip.server.performance.domain.entity.AdPerformance; +import chaeso.zip.server.performance.domain.entity.AdPerformance.AdPerformanceBuilder; +import chaeso.zip.server.performance.domain.vo.PerfSource; +import java.util.UUID; + +/** 테스트용 AdPerformance 생성 헬퍼.*/ +public final class AdPerformanceFixture { + + private AdPerformanceFixture() { + } + + /** MANUAL/예산 100만/노출 10만/클릭 2천/전환 10건, userId/channelId 랜덤. */ + public static AdPerformance adPerformance() { + return builder().build(); + } + + /** 기본값이 채워진 빌더. 필요한 필드만 덮어써서 쓴다. */ + public static AdPerformanceBuilder builder() { + return AdPerformance.builder() + .userId(UUID.randomUUID()) + .sourceType(PerfSource.MANUAL) + .channelId(UUID.randomUUID()) + .budgetWon(1_000_000L) + .impressions(100_000L) + .clicks(2_000L) + .conversions(10L); + } +} diff --git a/src/test/java/chaeso/zip/server/support/OnboardingFixture.java b/src/test/java/chaeso/zip/server/support/OnboardingFixture.java index ac34bad..aff9051 100644 --- a/src/test/java/chaeso/zip/server/support/OnboardingFixture.java +++ b/src/test/java/chaeso/zip/server/support/OnboardingFixture.java @@ -63,9 +63,18 @@ public static SubmitOnboardingCommand submitCommand(ServiceType serviceType, public static SubmitOnboardingCommand submitCommand(ServiceType serviceType, CampaignObjective campaignObjective, Long budgetMin, Long budgetMax, AdExperience adExperience, List adHistory, List rawFileKeys) { - return new SubmitOnboardingCommand(SERVICE_NAME, INDUSTRY, serviceType, - List.of(AgeBand.AGE_20S), campaignObjective, budgetMin, budgetMax, - CampaignPeriod.M1, adExperience, adHistory, rawFileKeys); + return submitCommand(serviceType, List.of(AgeBand.AGE_20S), campaignObjective, budgetMin, + budgetMax, adExperience, adHistory, rawFileKeys); + } + + /** 연령대를 직접 지정하는 온보딩 제출 커맨드. */ + public static SubmitOnboardingCommand submitCommand(ServiceType serviceType, + List targetAgeBands, CampaignObjective campaignObjective, Long budgetMin, + Long budgetMax, AdExperience adExperience, List adHistory, + List rawFileKeys) { + return new SubmitOnboardingCommand(SERVICE_NAME, INDUSTRY, serviceType, targetAgeBands, + campaignObjective, budgetMin, budgetMax, CampaignPeriod.M1, adExperience, adHistory, + rawFileKeys); } /** WEB/TRAFFIC/300만~1000만원/NONE, 20·30대 대상, 집행 내역 없음.*/