fix(audio-recommendation): 이벤트 상세 노출 조건으로 배너를 필터링한다
This commit is contained in:
+31
-3
@@ -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
|
||||||
|
|||||||
+128
@@ -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)
|
||||||
|
|||||||
+242
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user