From da73b983741fc21a0b07e636d5d4e51be0ecead8 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 27 Oct 2026 20:48:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(banner):=20=EA=B5=AC=ED=98=95=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=98=88=EC=95=BD=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/AudioContentRepository.kt | 7 +- .../banner/AudioContentBannerRepository.kt | 7 +- .../banner/AudioContentBannerVisibility.kt | 11 +++ .../AudioContentRepositoryBannerTest.kt | 90 +++++++++++++++++++ .../AudioContentBannerRepositoryTest.kt | 90 ++++++++++++++++++- .../AudioContentBannerReservationFixtures.kt | 42 +++++++++ 6 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt index 26905664..8e000c85 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepository.kt @@ -16,6 +16,7 @@ import kr.co.vividnext.sodalive.content.main.QGetAudioContentMainItem import kr.co.vividnext.sodalive.content.main.QGetAudioContentRankingItem import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner 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.main.curation.AudioContentCuration import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem @@ -38,6 +39,7 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository import java.time.LocalDateTime +import java.time.ZoneOffset @Repository interface AudioContentRepository : JpaRepository, AudioContentQueryRepository @@ -215,7 +217,8 @@ class AudioContentQueryRepositoryImpl( private val queryFactory: JPAQueryFactory, @Value("\${cloud.aws.cloud-front.host}") - private val imageHost: String + private val imageHost: String, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } ) : AudioContentQueryRepository { override fun findByIdAndActive(contentId: Long): AudioContent? { return queryFactory @@ -904,7 +907,7 @@ class AudioContentQueryRepositoryImpl( .leftJoin(audioContentBanner.tab, audioContentMainTab) .leftJoin(audioContentBanner.event, event) .leftJoin(audioContentBanner.creator, member) - .where(where) + .where(where, audioContentBanner.isVisibleAt(nowProvider())) .orderBy(audioContentBanner.orders.asc()) .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt index 3f125d97..003b96be 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepository.kt @@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.event.QEvent.event import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.QMember.member import org.springframework.data.jpa.repository.JpaRepository +import java.time.LocalDateTime +import java.time.ZoneOffset interface AudioContentBannerRepository : JpaRepository, AudioContentBannerQueryRepository @@ -15,7 +17,8 @@ interface AudioContentBannerQueryRepository { } class AudioContentBannerQueryRepositoryImpl( - private val queryFactory: JPAQueryFactory + private val queryFactory: JPAQueryFactory, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } ) : AudioContentBannerQueryRepository { override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List { var where = audioContentBanner.isActive.isTrue @@ -39,7 +42,7 @@ class AudioContentBannerQueryRepositoryImpl( .leftJoin(audioContentBanner.tab, audioContentMainTab) .leftJoin(audioContentBanner.event, event) .leftJoin(audioContentBanner.creator, member) - .where(where) + .where(where, audioContentBanner.isVisibleAt(nowProvider())) .orderBy(audioContentBanner.orders.asc()) .fetch() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt new file mode 100644 index 00000000..19b69028 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerVisibility.kt @@ -0,0 +1,11 @@ +package kr.co.vividnext.sodalive.content.main.banner + +import com.querydsl.core.types.dsl.BooleanExpression +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +fun QAudioContentBanner.isVisibleAt(now: LocalDateTime): BooleanExpression { + val currentSecond = now.truncatedTo(ChronoUnit.SECONDS) + return startDate.isNull.or(startDate.loe(currentSecond)) + .and(endDate.isNull.or(endDate.goe(currentSecond))) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt new file mode 100644 index 00000000..3c3ba207 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentRepositoryBannerTest.kt @@ -0,0 +1,90 @@ +package kr.co.vividnext.sodalive.content + +import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.configs.QueryDslConfig +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.i18n.Lang +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 +import javax.persistence.EntityManager + +@DataJpaTest(properties = ["spring.cache.type=none"]) +@Import(QueryDslConfig::class) +class AudioContentRepositoryBannerTest @Autowired constructor( + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory +) { + private var bannerNow = AudioContentBannerReservationFixtures.now + private val repository = AudioContentQueryRepositoryImpl(queryFactory, "https://cdn.test") { bannerNow } + + @Test + @DisplayName("구형 공개 배너는 미래 및 만료 예약을 제외한다") + fun shouldExcludeFutureAndExpiredReservations() { + val now = bannerNow + listOf("future", "expired", "visible").forEachIndexed { index, name -> + entityManager.persist( + AudioContentBanner(thumbnailImage = name, type = AudioContentBannerType.LINK, orders = index).apply { + if (name == "future") startDate = now.plusYears(1).withNano(0) + if (name == "expired") endDate = now.minusYears(1).withNano(0) + } + ) + } + entityManager.flush() + entityManager.clear() + + assertEquals(listOf("visible"), repository.getAudioContentMainBannerList(false).map { it.thumbnailImage }) + } + + @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 banner = AudioContentBanner(thumbnailImage = "candidate", type = AudioContentBannerType.LINK).apply { + startDate = case.startDate + endDate = case.endDate + } + entityManager.persist(banner) + entityManager.flush() + entityManager.clear() + val stored = entityManager.find(AudioContentBanner::class.java, banner.id) + assertEquals(case.startDate, stored.startDate) + assertEquals(case.endDate, stored.endDate) + + assertEquals( + if (case.visible) listOf("candidate") else emptyList(), + repository.getAudioContentMainBannerList(false).map { it.thumbnailImage } + ) + } + + @Test + @DisplayName("구형 공개 배너는 언어 제한 없이 기존 활성 성인 홈 탭 조건과 정렬을 유지한다") + fun shouldPreserveActiveAdultHomeTabAndOrderFilters() { + val tab = AudioContentMainTab(title = "tab", isActive = true) + entityManager.persist(tab) + listOf( + AudioContentBanner("ko", AudioContentBannerType.LINK, Lang.KO, orders = 2), + AudioContentBanner("ja", AudioContentBannerType.LINK, Lang.JA, orders = 1), + AudioContentBanner("adult", AudioContentBannerType.LINK, isAdult = true, orders = 0), + AudioContentBanner("inactive", AudioContentBannerType.LINK, isActive = false, orders = 0), + AudioContentBanner("tab", AudioContentBannerType.LINK, orders = 0).apply { this.tab = tab } + ).forEach { entityManager.persist(it) } + entityManager.flush() + entityManager.clear() + + assertEquals(listOf("ja", "ko"), repository.getAudioContentMainBannerList(false).map { it.thumbnailImage }) + assertEquals(listOf("adult", "ja", "ko"), repository.getAudioContentMainBannerList(true).map { it.thumbnailImage }) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt index ddf5792b..b834ed49 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerRepositoryTest.kt @@ -1,19 +1,105 @@ package kr.co.vividnext.sodalive.content.main.banner +import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab import kr.co.vividnext.sodalive.i18n.Lang 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 +import javax.persistence.EntityManager -@DataJpaTest +@DataJpaTest(properties = ["spring.cache.type=none"]) @Import(QueryDslConfig::class) class AudioContentBannerRepositoryTest @Autowired constructor( - private val repository: AudioContentBannerRepository + private val repository: AudioContentBannerRepository, + private val entityManager: EntityManager, + queryFactory: JPAQueryFactory ) { + private var bannerNow = AudioContentBannerReservationFixtures.now + private val queryRepository = AudioContentBannerQueryRepositoryImpl(queryFactory) { bannerNow } + + @Test + @DisplayName("공개 메인 배너는 미래 및 만료 예약을 제외한다") + fun shouldExcludeFutureAndExpiredReservations() { + val now = bannerNow + listOf("future", "expired", "visible").forEachIndexed { index, name -> + repository.save( + AudioContentBanner(thumbnailImage = name, type = AudioContentBannerType.LINK, orders = index).apply { + if (name == "future") startDate = now.plusYears(1).withNano(0) + if (name == "expired") endDate = now.minusYears(1).withNano(0) + } + ) + } + entityManager.flush() + entityManager.clear() + + assertEquals( + listOf("visible"), + queryRepository.getAudioContentMainBannerList(1L, false).map { it.thumbnailImage } + ) + } + + @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 banner = repository.save( + AudioContentBanner(thumbnailImage = "candidate", type = AudioContentBannerType.LINK).apply { + startDate = case.startDate + endDate = case.endDate + } + ) + entityManager.flush() + entityManager.clear() + val stored = entityManager.find(AudioContentBanner::class.java, banner.id) + assertEquals(case.startDate, stored.startDate) + assertEquals(case.endDate, stored.endDate) + + assertEquals( + if (case.visible) listOf("candidate") else emptyList(), + queryRepository.getAudioContentMainBannerList(1L, false).map { it.thumbnailImage } + ) + } + + @Test + @DisplayName("공개 메인 배너는 기존 탭 성인 활성 언어 조건과 정렬을 유지한다") + fun shouldPreserveTabAdultActiveLanguageAndOrderFilters() { + entityManager.createNativeQuery( + "insert into audio_content_main_tab (id, title, is_active) values (2, 'tab', true)" + ).executeUpdate() + val tab = entityManager.getReference(AudioContentMainTab::class.java, 2L) + listOf( + AudioContentBanner("ko", AudioContentBannerType.LINK, Lang.KO, orders = 3), + AudioContentBanner("ja", AudioContentBannerType.LINK, Lang.JA, orders = 2), + AudioContentBanner("adult", AudioContentBannerType.LINK, Lang.JA, isAdult = true, orders = 0), + AudioContentBanner("inactive", AudioContentBannerType.LINK, Lang.JA, isActive = false, orders = 0), + AudioContentBanner("tab", AudioContentBannerType.LINK, Lang.JA).apply { this.tab = tab }, + AudioContentBanner("future-tab", AudioContentBannerType.LINK, Lang.JA).apply { + this.tab = tab + startDate = bannerNow.plusDays(1) + } + ).forEach { repository.save(it) } + entityManager.flush() + entityManager.clear() + + assertEquals(listOf("ja"), queryRepository.getAudioContentMainBannerList(1L, false, Lang.JA).map { it.thumbnailImage }) + assertEquals( + listOf("adult", "ja"), + queryRepository.getAudioContentMainBannerList(1L, true, Lang.JA).map { it.thumbnailImage } + ) + assertEquals(listOf("ja", "ko"), queryRepository.getAudioContentMainBannerList(1L, false).map { it.thumbnailImage }) + assertEquals(listOf("tab"), queryRepository.getAudioContentMainBannerList(2L, false, Lang.JA).map { it.thumbnailImage }) + } + @Test @DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다") fun shouldReturnOnlyRequestedLanguageBanners() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt new file mode 100644 index 00000000..1913f1e7 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/content/main/banner/AudioContentBannerReservationFixtures.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.content.main.banner + +import java.time.LocalDateTime +import java.util.stream.Stream + +data class BannerReservationCase( + val name: String, + val now: LocalDateTime, + val startDate: LocalDateTime?, + val endDate: LocalDateTime?, + val visible: Boolean +) { + override fun toString(): String = name +} + +object AudioContentBannerReservationFixtures { + val now: LocalDateTime = LocalDateTime.of(2026, 10, 27, 23, 59, 59) + + @JvmStatic + fun cases(): Stream { + val before = now.minusSeconds(1) + val after = now.plusSeconds(1) + return Stream.of( + BannerReservationCase("unbounded", now, null, null, true), + BannerReservationCase("start-before", now, before, null, true), + BannerReservationCase("start-exact", now, now, null, true), + BannerReservationCase("start-after", now, after, null, false), + BannerReservationCase("end-before", now, null, before, false), + BannerReservationCase("end-exact", now, null, now, true), + BannerReservationCase("end-after", now, null, after, true), + BannerReservationCase("inside", now, before, after, true), + BannerReservationCase("before-range", now, after, after.plusSeconds(1), false), + BannerReservationCase("after-range", now, before.minusSeconds(1), before, false), + BannerReservationCase("both-exact", now, now, now, true), + BannerReservationCase("end-second-123456789", now.withNano(123456789), before, now, true), + BannerReservationCase("end-second-999999999", now.withNano(999999999), before, now, true), + BannerReservationCase("next-second-not-started", now.withNano(999999999), after, null, false), + BannerReservationCase("midnight-start-exact", after, after, null, true), + BannerReservationCase("midnight-expired", after, null, now, false) + ) + } +}