feat(banner): 구형 공개 조회에 예약 기간 필터를 적용한다

This commit is contained in:
2026-10-27 20:48:30 +09:00
parent 98e68fc994
commit da73b98374
6 changed files with 241 additions and 6 deletions
@@ -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<AudioContent, Long>, 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()
}
@@ -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<AudioContentBanner, Long>, 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<AudioContentBanner> {
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()
}
@@ -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)))
}
@@ -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 })
}
}
@@ -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() {
@@ -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<BannerReservationCase> {
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)
)
}
}