diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 33fb1e81..459d0a1a 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -10,6 +10,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.content.QAudioContent.audioContent import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType import kr.co.vividnext.sodalive.content.main.banner.QAudioContentBanner.audioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.isVisibleAt import kr.co.vividnext.sodalive.content.theme.QAudioContentTheme.audioContentTheme import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series @@ -34,6 +35,7 @@ import org.springframework.stereotype.Repository import java.math.BigDecimal import java.math.BigInteger import java.time.LocalDateTime +import java.time.ZoneOffset import javax.persistence.EntityManager @Repository @@ -42,7 +44,8 @@ class DefaultAudioRecommendationQueryRepository( private val entityManager: EntityManager, private val contentTranslationRepository: ContentTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") - private val cloudFrontHost: String + private val cloudFrontHost: String, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } ) : AudioRecommendationQueryRepository { override fun findBanners( limit: Int, @@ -74,6 +77,7 @@ class DefaultAudioRecommendationQueryRepository( audioContentBanner.isActive.isTrue, audioContentBanner.tab.id.eq(2L), audioContentBanner.lang.eq(lang), + audioContentBanner.isVisibleAt(nowProvider()), adultBannerCondition(canViewAdultContent), activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index 09285d47..22b0a8e5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -7,7 +7,9 @@ import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.like.AudioContentLike import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner +import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType +import kr.co.vividnext.sodalive.content.main.banner.BannerReservationCase import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.translation.ContentTranslation @@ -26,6 +28,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import @@ -44,12 +48,55 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( queryFactory: JPAQueryFactory, contentTranslationRepository: ContentTranslationRepository ) { + private var bannerNow = AudioContentBannerReservationFixtures.now private val repository = DefaultAudioRecommendationQueryRepository( queryFactory, entityManager, contentTranslationRepository, "https://cdn.test" - ) + ) { bannerNow } + + @Test + @DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다") + fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() { + val now = bannerNow + val tab = saveContentRecommendationTab() + listOf("future", "expired", "visible-1", "visible-2").forEachIndexed { index, name -> + saveBanner(name, AudioContentBannerType.LINK, index, tab = tab).apply { + if (name == "future") startDate = now.plusYears(1).withNano(0) + if (name == "expired") endDate = now.minusYears(1).withNano(0) + } + } + flushAndClear() + + assertEquals( + listOf("https://cdn.test/visible-1", "https://cdn.test/visible-2"), + repository.findBanners(2, null, false).map { it.imageUrl } + ) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerReservationFixtures#cases") + @DisplayName("콘텐츠 추천 배너는 UTC 초 정밀도의 예약 경계를 포함한다") + fun shouldApplyReservationBoundaries(case: BannerReservationCase) { + bannerNow = case.now + entityManager.createNativeQuery("alter table content_banner alter column start_date timestamp(0)").executeUpdate() + entityManager.createNativeQuery("alter table content_banner alter column end_date timestamp(0)").executeUpdate() + val tab = saveContentRecommendationTab() + val banner = saveBanner("candidate", AudioContentBannerType.LINK, 1, tab = tab).apply { + startDate = case.startDate + endDate = case.endDate + } + flushAndClear() + val stored = entityManager.find(AudioContentBanner::class.java, banner.id) + assertEquals(case.startDate, stored.startDate) + assertEquals(case.endDate, stored.endDate) + + assertEquals( + if (case.visible) listOf("https://cdn.test/candidate") else emptyList(), + repository.findBanners(2, null, false).map { it.imageUrl } + ) + } @Test @DisplayName("배너는 콘텐츠 추천 탭과 성인 조회 가능 여부를 적용한다") @@ -57,6 +104,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( val viewer = saveMember("viewer", MemberRole.USER) val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR) val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR) + val reverseBlockedCreator = saveMember("reverse-blocked-creator", MemberRole.CREATOR) + val inactiveCreator = saveMember("inactive-banner-creator", MemberRole.CREATOR, isActive = false) val recommendationTab = saveContentRecommendationTab() val visibleBanner = saveBanner( "visible.png", @@ -89,7 +138,16 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( tab = recommendationTab ) saveBanner("home.png", AudioContentBannerType.LINK, 1, link = "https://home.test") + saveBanner("inactive-target.png", AudioContentBannerType.CREATOR, 0, creator = inactiveCreator, tab = recommendationTab) + saveBanner( + "reverse-blocked.png", + AudioContentBannerType.CREATOR, + 0, + creator = reverseBlockedCreator, + tab = recommendationTab + ) saveBlock(viewer, blockedCreator) + saveBlock(reverseBlockedCreator, viewer) flushAndClear() val safeBanners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)