feat(banner): 구형 공개 조회에 예약 기간 필터를 적용한다
This commit is contained in:
@@ -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.QGetAudioContentRankingItem
|
||||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
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.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.AudioContentCuration
|
||||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCuration.audioContentCuration
|
||||||
import kr.co.vividnext.sodalive.content.main.curation.QAudioContentCurationItem.audioContentCurationItem
|
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.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
interface AudioContentRepository : JpaRepository<AudioContent, Long>, AudioContentQueryRepository
|
interface AudioContentRepository : JpaRepository<AudioContent, Long>, AudioContentQueryRepository
|
||||||
@@ -215,7 +217,8 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
private val queryFactory: JPAQueryFactory,
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
|
||||||
@Value("\${cloud.aws.cloud-front.host}")
|
@Value("\${cloud.aws.cloud-front.host}")
|
||||||
private val imageHost: String
|
private val imageHost: String,
|
||||||
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
) : AudioContentQueryRepository {
|
) : AudioContentQueryRepository {
|
||||||
override fun findByIdAndActive(contentId: Long): AudioContent? {
|
override fun findByIdAndActive(contentId: Long): AudioContent? {
|
||||||
return queryFactory
|
return queryFactory
|
||||||
@@ -904,7 +907,7 @@ class AudioContentQueryRepositoryImpl(
|
|||||||
.leftJoin(audioContentBanner.tab, audioContentMainTab)
|
.leftJoin(audioContentBanner.tab, audioContentMainTab)
|
||||||
.leftJoin(audioContentBanner.event, event)
|
.leftJoin(audioContentBanner.event, event)
|
||||||
.leftJoin(audioContentBanner.creator, member)
|
.leftJoin(audioContentBanner.creator, member)
|
||||||
.where(where)
|
.where(where, audioContentBanner.isVisibleAt(nowProvider()))
|
||||||
.orderBy(audioContentBanner.orders.asc())
|
.orderBy(audioContentBanner.orders.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -7,6 +7,8 @@ import kr.co.vividnext.sodalive.event.QEvent.event
|
|||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import kr.co.vividnext.sodalive.member.QMember.member
|
import kr.co.vividnext.sodalive.member.QMember.member
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
interface AudioContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AudioContentBannerQueryRepository
|
interface AudioContentBannerRepository : JpaRepository<AudioContentBanner, Long>, AudioContentBannerQueryRepository
|
||||||
|
|
||||||
@@ -15,7 +17,8 @@ interface AudioContentBannerQueryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AudioContentBannerQueryRepositoryImpl(
|
class AudioContentBannerQueryRepositoryImpl(
|
||||||
private val queryFactory: JPAQueryFactory
|
private val queryFactory: JPAQueryFactory,
|
||||||
|
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
|
||||||
) : AudioContentBannerQueryRepository {
|
) : AudioContentBannerQueryRepository {
|
||||||
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List<AudioContentBanner> {
|
override fun getAudioContentMainBannerList(tabId: Long, isAdult: Boolean, lang: Lang?): List<AudioContentBanner> {
|
||||||
var where = audioContentBanner.isActive.isTrue
|
var where = audioContentBanner.isActive.isTrue
|
||||||
@@ -39,7 +42,7 @@ class AudioContentBannerQueryRepositoryImpl(
|
|||||||
.leftJoin(audioContentBanner.tab, audioContentMainTab)
|
.leftJoin(audioContentBanner.tab, audioContentMainTab)
|
||||||
.leftJoin(audioContentBanner.event, event)
|
.leftJoin(audioContentBanner.event, event)
|
||||||
.leftJoin(audioContentBanner.creator, member)
|
.leftJoin(audioContentBanner.creator, member)
|
||||||
.where(where)
|
.where(where, audioContentBanner.isVisibleAt(nowProvider()))
|
||||||
.orderBy(audioContentBanner.orders.asc())
|
.orderBy(audioContentBanner.orders.asc())
|
||||||
.fetch()
|
.fetch()
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-2
@@ -1,19 +1,105 @@
|
|||||||
package kr.co.vividnext.sodalive.content.main.banner
|
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.configs.QueryDslConfig
|
||||||
|
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
|
||||||
import kr.co.vividnext.sodalive.i18n.Lang
|
import kr.co.vividnext.sodalive.i18n.Lang
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
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.beans.factory.annotation.Autowired
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||||
import org.springframework.context.annotation.Import
|
import org.springframework.context.annotation.Import
|
||||||
|
import javax.persistence.EntityManager
|
||||||
|
|
||||||
@DataJpaTest
|
@DataJpaTest(properties = ["spring.cache.type=none"])
|
||||||
@Import(QueryDslConfig::class)
|
@Import(QueryDslConfig::class)
|
||||||
class AudioContentBannerRepositoryTest @Autowired constructor(
|
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
|
@Test
|
||||||
@DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다")
|
@DisplayName("사용자 배너 조회는 요청 언어와 일치하는 배너만 반환한다")
|
||||||
fun shouldReturnOnlyRequestedLanguageBanners() {
|
fun shouldReturnOnlyRequestedLanguageBanners() {
|
||||||
|
|||||||
+42
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user