fix(audio-recommendation): 이벤트 상세 노출 조건으로 배너를 필터링한다

This commit is contained in:
2026-10-31 20:59:52 +09:00
parent 1f24a4b703
commit 969f999ae9
3 changed files with 401 additions and 5 deletions
@@ -18,8 +18,10 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seri
import kr.co.vividnext.sodalive.event.EventItem import kr.co.vividnext.sodalive.event.EventItem
import kr.co.vividnext.sodalive.event.QEvent.event 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.MemberRole
import kr.co.vividnext.sodalive.member.QMember import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.QMember.member import kr.co.vividnext.sodalive.member.QMember.member
import kr.co.vividnext.sodalive.member.auth.QAuth.auth
import kr.co.vividnext.sodalive.member.block.QBlockMember import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
@@ -45,6 +47,7 @@ class DefaultAudioRecommendationQueryRepository(
private val contentTranslationRepository: ContentTranslationRepository, private val contentTranslationRepository: ContentTranslationRepository,
@Value("\${cloud.aws.cloud-front.host}") @Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String, private val cloudFrontHost: String,
private val eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() },
private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) }
) : AudioRecommendationQueryRepository { ) : AudioRecommendationQueryRepository {
override fun findBanners( override fun findBanners(
@@ -79,7 +82,7 @@ class DefaultAudioRecommendationQueryRepository(
audioContentBanner.lang.eq(lang), audioContentBanner.lang.eq(lang),
audioContentBanner.isVisibleAt(nowProvider()), audioContentBanner.isVisibleAt(nowProvider()),
adultBannerCondition(canViewAdultContent), adultBannerCondition(canViewAdultContent),
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) activeBannerTargetCondition(memberId, bannerCreator, seriesOwner, lang)
) )
.orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc())
.limit(limit.toLong()) .limit(limit.toLong())
@@ -560,7 +563,19 @@ class DefaultAudioRecommendationQueryRepository(
.withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id)) .withOptionalAnd(notBlockedCreatorCondition(memberId, audioContent.member.id))
} }
private fun activeBannerTargetCondition(memberId: Long?, bannerCreator: QMember, seriesOwner: QMember): BooleanExpression { private fun activeBannerTargetCondition(
memberId: Long?,
bannerCreator: QMember,
seriesOwner: QMember,
lang: Lang
): BooleanExpression {
val eventNow = eventNowProvider()
val eventCondition = audioContentBanner.type.eq(AudioContentBannerType.EVENT)
.and(event.isActive.isTrue)
.and(event.startDate.loe(eventNow))
.and(event.endDate.goe(eventNow))
.and(event.lang.eq(lang))
.and(eventAudienceCondition(memberId))
val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR) val creatorCondition = audioContentBanner.type.eq(AudioContentBannerType.CREATOR)
.and(bannerCreator.isActive.isTrue) .and(bannerCreator.isActive.isTrue)
.withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id)) .withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id))
@@ -570,11 +585,24 @@ class DefaultAudioRecommendationQueryRepository(
.withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id)) .withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id))
return audioContentBanner.type.eq(AudioContentBannerType.LINK) return audioContentBanner.type.eq(AudioContentBannerType.LINK)
.or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue)) .or(eventCondition)
.or(creatorCondition) .or(creatorCondition)
.or(seriesCondition) .or(seriesCondition)
} }
private fun eventAudienceCondition(memberId: Long?): BooleanExpression {
if (memberId == null) return event.isAdult.isNull.or(event.isAdult.isFalse)
val eventViewer = QMember("eventViewer")
val isAdmin = JPAExpressions.selectOne().from(eventViewer)
.where(eventViewer.id.eq(memberId), eventViewer.role.eq(MemberRole.ADMIN))
.exists()
val viewerAuth = JPAExpressions.selectOne().from(auth).where(auth.member.id.eq(memberId))
return event.isAdult.isNull
.or(isAdmin)
.or(event.isAdult.isTrue.and(viewerAuth.exists()))
.or(event.isAdult.isFalse.and(viewerAuth.notExists()))
}
private fun Tuple.toEventItem(): EventItem? { private fun Tuple.toEventItem(): EventItem? {
val eventId = get(event.id) ?: return null val eventId = get(event.id) ?: return null
val thumbnailImage = get(event.thumbnailImage) ?: return null val thumbnailImage = get(event.thumbnailImage) ?: return null
@@ -3,21 +3,32 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.`in`.web
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.comment.AudioContentComment import kr.co.vividnext.sodalive.content.comment.AudioContentComment
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
import kr.co.vividnext.sodalive.content.main.tab.AudioContentMainTab
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.translation.ContentTranslation import kr.co.vividnext.sodalive.content.translation.ContentTranslation
import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
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.CsvSource
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.context.SpringBootTest
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
@@ -41,6 +52,95 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate private val transactionTemplate: TransactionTemplate
) { ) {
@ParameterizedTest
@CsvSource(
"ANONYMOUS, KR, ja, KO, all|false",
"ANONYMOUS, JP, ko, JA, all|false",
"UNVERIFIED, KR, ja, KO, all|false",
"UNVERIFIED, JP, ko, JA, all|false",
"VERIFIED, KR, ja, KO, all|true",
"VERIFIED, JP, ko, JA, all|true",
"ADMIN, KR, ja, KO, all|false|true",
"ADMIN, JP, ko, JA, all|false|true"
)
@DisplayName("오디오 EVENT 응답은 요청 언어가 아닌 국가와 인증 대상 정책을 따르고 AI 필드를 추가하지 않는다")
fun shouldApplyEventBannerDetailPolicyAndKeepAudioResponseSchema(
viewerType: String,
country: String,
requestLanguage: String,
eventLang: Lang,
expected: String
) {
val fixture = transactionTemplate.execute {
entityManager.createQuery("delete from AudioContentBanner").executeUpdate()
if (entityManager.find(AudioContentMainTab::class.java, 2L) == null) {
entityManager.createNativeQuery(
"insert into audio_content_main_tab (id, title, is_active) values (2, '추천', true)"
).executeUpdate()
}
val tab = entityManager.getReference(AudioContentMainTab::class.java, 2L)
val viewer = if (viewerType == "ANONYMOUS") {
null
} else {
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
var candidate = saveMember("audio-event-viewer", role)
while (candidate.id in setOf(16L, 17L, 17958L, 44144L, 29721L, 32050L, 37543L, 40850L)) {
candidate = saveMember("audio-event-viewer-${candidate.id}", role)
}
candidate
}
if (viewerType == "VERIFIED") {
entityManager.persist(
Auth("verified", "19900101", "audio-event-ci-${viewer?.id}", "audio-event-di-${viewer?.id}", 1).apply {
member = viewer
}
)
}
viewer?.let {
entityManager.persist(
MemberContentPreference(isAdultContentVisible = viewerType == "UNVERIFIED").apply {
member = it
}
)
}
val events = mutableMapOf<String, Event>()
listOf(Lang.KO, Lang.JA).forEach { lang ->
listOf(null, false, true).forEachIndexed { index, audience ->
val name = audience?.toString() ?: "all"
events["$lang-$name"] = saveAudioEventBanner(name, lang, audience, index, tab)
}
}
saveAudioEventBanner("inactive", eventLang, null, 3, tab).isActive = false
saveAudioEventBanner("future", eventLang, null, 4, tab).startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
saveAudioEventBanner("expired", eventLang, null, 5, tab).endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
saveAudioEventBanner("wrong-event-language", eventLang, null, 6, tab).lang =
if (eventLang == Lang.KO) Lang.JA else Lang.KO
entityManager.persist(
AudioContentBanner("missing-event", AudioContentBannerType.EVENT, lang = eventLang, orders = 7).apply {
this.tab = tab
}
)
entityManager.flush()
entityManager.clear()
viewer to events
}!!
val request = get("/api/v2/audio/recommendations")
.header("CloudFront-Viewer-Country", country)
.header("Accept-Language", requestLanguage)
fixture.first?.let { request.with(user(MemberAdapter(it))) }
val response = mockMvc.perform(request)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.aiCharacters").doesNotHaveJsonPath())
.andExpect(jsonPath("$.data.banners.length()").value(expected.split("|").size))
expected.split("|").forEachIndexed { index, name ->
response.andExpect(
jsonPath("$.data.banners[$index].eventItem.id").value(fixture.second.getValue("$eventLang-$name").id)
)
}
}
@Test @Test
@DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다") @DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다")
fun shouldReturnRecommendationsThroughControllerServiceAndRepository() { fun shouldReturnRecommendationsThroughControllerServiceAndRepository() {
@@ -114,6 +214,34 @@ class AudioRecommendationEndToEndTest @Autowired constructor(
) )
} }
private fun saveAudioEventBanner(
name: String,
lang: Lang,
audience: Boolean?,
order: Int,
tab: AudioContentMainTab
): Event {
val event = Event(
thumbnailImage = "$lang-$name.png",
detailImage = null,
popupImage = null,
link = null,
title = name,
lang = lang,
isAdult = audience,
startDate = LocalDateTime.of(2000, 1, 1, 0, 0),
endDate = LocalDateTime.of(2100, 1, 1, 0, 0)
)
entityManager.persist(event)
entityManager.persist(
AudioContentBanner("$lang-$name", AudioContentBannerType.EVENT, lang = lang, orders = order).apply {
this.event = event
this.tab = tab
}
)
return event
}
private fun createFixture(): Fixture { private fun createFixture(): Fixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val now = LocalDateTime.now().minusHours(1) val now = LocalDateTime.now().minusHours(1)
@@ -17,9 +17,11 @@ import kr.co.vividnext.sodalive.content.translation.ContentTranslationPayload
import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository import kr.co.vividnext.sodalive.content.translation.ContentTranslationRepository
import kr.co.vividnext.sodalive.creator.admin.content.series.Series import kr.co.vividnext.sodalive.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.block.BlockMember import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
@@ -29,6 +31,7 @@ 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.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource 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
@@ -49,13 +52,218 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
contentTranslationRepository: ContentTranslationRepository contentTranslationRepository: ContentTranslationRepository
) { ) {
private var bannerNow = AudioContentBannerReservationFixtures.now private var bannerNow = AudioContentBannerReservationFixtures.now
private var eventNow = LocalDateTime.of(2026, 10, 31, 23, 59, 59, 123456789)
private val repository = DefaultAudioRecommendationQueryRepository( private val repository = DefaultAudioRecommendationQueryRepository(
queryFactory, queryFactory,
entityManager, entityManager,
contentTranslationRepository, contentTranslationRepository,
"https://cdn.test" "https://cdn.test",
eventNowProvider = { eventNow }
) { bannerNow } ) { bannerNow }
@ParameterizedTest
@CsvSource(
"ANONYMOUS, KO, false, all|false",
"ANONYMOUS, JA, false, all|false",
"UNVERIFIED, KO, true, all|false",
"UNVERIFIED, JA, true, all|false",
"VERIFIED, KO, false, all|true",
"VERIFIED, JA, false, all|true",
"ADMIN, KO, false, all|false|true",
"ADMIN, JA, false, all|false|true"
)
@DisplayName("오디오 EVENT 대상자는 배너 성인 선호와 무관하게 조회자의 인증 기록으로 판정한다")
fun shouldApplyAudioEventVisibilityPolicyBeforeLimit(
viewerType: String,
lang: Lang,
canViewAdultContent: Boolean,
expected: String
) {
val viewer = saveEventViewer(viewerType)
saveEventViewer("VERIFIED", "other-verified")
val tab = saveContentRecommendationTab()
listOf(null, false, true).forEachIndexed { index, audience ->
val name = audience?.toString() ?: "all"
val event = saveEvent(name).apply {
this.lang = lang
isAdult = audience
}
saveBanner(name, AudioContentBannerType.EVENT, index, tab = tab, lang = lang, event = event)
}
flushAndClear()
assertEquals(
expected.split("|").map { "https://cdn.test/$it" },
repository.findBanners(20, viewer?.id, canViewAdultContent, lang).map { it.imageUrl }
)
}
@ParameterizedTest
@CsvSource(
"ANONYMOUS, MISSING",
"ANONYMOUS, INACTIVE",
"ANONYMOUS, FUTURE",
"ANONYMOUS, EXPIRED",
"ANONYMOUS, WRONG_LANGUAGE",
"ADMIN, MISSING",
"ADMIN, INACTIVE",
"ADMIN, FUTURE",
"ADMIN, EXPIRED",
"ADMIN, WRONG_LANGUAGE"
)
@DisplayName("오디오 EVENT는 관리자도 누락·비활성·기간 밖·다른 언어의 이벤트를 볼 수 없다")
fun shouldExcludeIneligibleAudioEventsForAnonymousAndAdmin(viewerType: String, hiddenReason: String) {
val viewer = saveEventViewer(viewerType)
val tab = saveContentRecommendationTab()
val event = if (hiddenReason == "MISSING") {
null
} else {
saveEvent("hidden").apply {
when (hiddenReason) {
"INACTIVE" -> isActive = false
"FUTURE" -> startDate = LocalDateTime.of(2099, 1, 1, 0, 0)
"EXPIRED" -> endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
"WRONG_LANGUAGE" -> lang = Lang.JA
}
}
}
saveBanner("hidden", AudioContentBannerType.EVENT, 0, tab = tab, event = event)
flushAndClear()
assertEquals(emptyList<String>(), repository.findBanners(20, viewer?.id, false).map { it.imageUrl })
}
@Test
@DisplayName("오디오 EVENT는 부적격 첫 후보를 limit 전에 제외하고 적격 두 번째 후보를 반환한다")
fun shouldExcludeHiddenAudioEventBeforeLimit() {
val tab = saveContentRecommendationTab()
val hiddenEvent = saveEvent("hidden-first").apply { isAdult = true }
val visibleEvent = saveEvent("visible-second").apply { isAdult = false }
saveBanner("hidden-first", AudioContentBannerType.EVENT, 0, tab = tab, event = hiddenEvent)
saveBanner("visible-second", AudioContentBannerType.EVENT, 1, tab = tab, event = visibleEvent)
flushAndClear()
assertEquals(listOf("https://cdn.test/visible-second"), repository.findBanners(1, null, false).map { it.imageUrl })
}
@Test
@DisplayName("오디오 EVENT 후보가 모두 인증 대상에서 제외되면 빈 목록을 반환한다")
fun shouldReturnEmptyWhenAllAudioEventAudiencesAreHidden() {
val tab = saveContentRecommendationTab()
repeat(2) { index ->
val event = saveEvent("hidden-$index").apply { isAdult = true }
saveBanner("hidden-$index", AudioContentBannerType.EVENT, index, tab = tab, event = event)
}
flushAndClear()
assertEquals(emptyList<String>(), repository.findBanners(20, null, false).map { it.imageUrl })
}
@Test
@DisplayName("관리자의 EVENT 대상자 우회는 오디오 배너 자체 활성·탭·언어·예약·성인 조건을 우회하지 않는다")
fun shouldKeepBannerOwnFiltersForAdminAudioEvents() {
val admin = saveEventViewer("ADMIN")
val tab = saveContentRecommendationTab()
val event = saveEvent("admin-event").apply { isAdult = true }
listOf("inactive", "tab", "language", "future", "expired", "adult", "visible").forEachIndexed { index, name ->
saveBanner(name, AudioContentBannerType.EVENT, index, tab = tab, event = event).apply {
when (name) {
"inactive" -> isActive = false
"tab" -> this.tab = null
"language" -> lang = Lang.JA
"future" -> startDate = bannerNow.plusYears(1).withNano(0)
"expired" -> endDate = bannerNow.minusYears(1).withNano(0)
"adult" -> isAdult = true
}
}
}
flushAndClear()
assertEquals(listOf("https://cdn.test/visible"), repository.findBanners(20, admin?.id, false).map { it.imageUrl })
assertEquals(
listOf("https://cdn.test/adult", "https://cdn.test/visible"),
repository.findBanners(20, admin?.id, true).map { it.imageUrl }
)
}
@Test
@DisplayName("EVENT 상세 정책은 오디오 LINK·CREATOR·SERIES 배너에 적용하지 않는다")
fun shouldNotApplyEventPolicyToNonEventAudioBanners() {
val tab = saveContentRecommendationTab()
val creator = saveMember("non-event-creator", MemberRole.CREATOR)
val series = saveSeries("non-event-series", creator, true)
val event = saveEvent("non-event-hidden").apply {
isActive = false
isAdult = true
lang = Lang.JA
endDate = LocalDateTime.of(2001, 1, 1, 0, 0)
}
listOf(AudioContentBannerType.LINK, AudioContentBannerType.CREATOR, AudioContentBannerType.SERIES)
.forEachIndexed { index, type ->
saveBanner(type.name, type, index, creator = creator, tab = tab, event = event, series = series)
}
flushAndClear()
assertEquals(
listOf("LINK", "CREATOR", "SERIES").map { "https://cdn.test/$it" },
repository.findBanners(20, null, false).map { it.imageUrl }
)
}
@ParameterizedTest
@CsvSource(
"0, START, true",
"123456789, START, true",
"999999999, START, true",
"0, END, true",
"123456789, END, true",
"999999999, END, true",
"0, BOTH, true",
"123456789, BOTH, true",
"999999999, BOTH, true",
"0, BEFORE, false",
"123456789, BEFORE, false",
"999999999, BEFORE, false",
"0, AFTER, false",
"123456789, AFTER, false",
"999999999, AFTER, false"
)
@DisplayName("오디오 EVENT는 나노초 시작·종료를 포함하고 1ns 밖을 제외하며 배너 UTC 초 clock과 독립이다")
fun shouldApplyExactAudioEventBoundariesIndependentlyOfBannerClock(nano: Int, boundary: String, visible: Boolean) {
eventNow = eventNow.withNano(nano)
bannerNow = bannerNow.withNano(999999999)
entityManager.createNativeQuery("alter table event alter column start_date timestamp(9)").executeUpdate()
entityManager.createNativeQuery("alter table event alter column end_date timestamp(9)").executeUpdate()
val tab = saveContentRecommendationTab()
val start = when (boundary) {
"START", "BOTH" -> eventNow
"BEFORE" -> eventNow.plusNanos(1)
else -> eventNow.minusDays(1)
}
val end = when (boundary) {
"END", "BOTH" -> eventNow
"AFTER" -> eventNow.minusNanos(1)
else -> eventNow.plusDays(1)
}
val event = saveEvent("boundary").apply {
startDate = start
endDate = end
}
saveBanner("boundary", AudioContentBannerType.EVENT, 0, tab = tab, event = event).apply {
startDate = bannerNow.withNano(0)
endDate = bannerNow.withNano(0)
}
flushAndClear()
val stored = entityManager.find(Event::class.java, event.id)
assertEquals(start, stored.startDate)
assertEquals(end, stored.endDate)
assertEquals(
if (visible) listOf("https://cdn.test/boundary") else emptyList(),
repository.findBanners(1, null, false).map { it.imageUrl }
)
}
@Test @Test
@DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다") @DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다")
fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() { fun shouldExcludeFutureAndExpiredReservationsBeforeLimit() {
@@ -594,6 +802,34 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
) )
} }
private fun saveEventViewer(viewerType: String, seed: String = "event-viewer"): Member? {
if (viewerType == "ANONYMOUS") return null
val role = if (viewerType == "ADMIN") MemberRole.ADMIN else MemberRole.USER
val viewer = saveMember(seed, role)
if (viewerType == "VERIFIED") {
entityManager.persist(
Auth("verified", "19900101", "event-ci-${viewer.id}", "event-di-${viewer.id}", 1).apply {
member = viewer
}
)
}
return viewer
}
private fun saveEvent(title: String): Event {
val event = Event(
thumbnailImage = "$title.png",
detailImage = null,
popupImage = null,
link = null,
title = title,
startDate = eventNow.minusDays(1),
endDate = eventNow.plusDays(1)
)
entityManager.persist(event)
return event
}
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member { private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
val member = Member( val member = Member(
email = "$nickname@test.com", email = "$nickname@test.com",
@@ -621,7 +857,9 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
creator: Member? = null, creator: Member? = null,
link: String? = null, link: String? = null,
tab: AudioContentMainTab? = null, tab: AudioContentMainTab? = null,
lang: Lang = Lang.KO lang: Lang = Lang.KO,
event: Event? = null,
series: Series? = null
): AudioContentBanner { ): AudioContentBanner {
val banner = AudioContentBanner( val banner = AudioContentBanner(
thumbnailImage = thumbnailImage, thumbnailImage = thumbnailImage,
@@ -634,6 +872,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
banner.creator = creator banner.creator = creator
banner.link = link banner.link = link
banner.tab = tab banner.tab = tab
banner.event = event
banner.series = series
entityManager.persist(banner) entityManager.persist(banner)
return banner return banner
} }