From 969f999ae9452f13ad81d967c1d213f6b409fe97 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 31 Oct 2026 20:59:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(audio-recommendation):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EB=85=B8=EC=B6=9C=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EB=B0=B0=EB=84=88=EB=A5=BC=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...faultAudioRecommendationQueryRepository.kt | 34 ++- .../in/web/AudioRecommendationEndToEndTest.kt | 128 +++++++++ ...tAudioRecommendationQueryRepositoryTest.kt | 244 +++++++++++++++++- 3 files changed, 401 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt index 459d0a1a..00ff72de 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt @@ -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.QEvent.event 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.member +import kr.co.vividnext.sodalive.member.auth.QAuth.auth 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.toCdnUrl @@ -45,6 +47,7 @@ class DefaultAudioRecommendationQueryRepository( private val contentTranslationRepository: ContentTranslationRepository, @Value("\${cloud.aws.cloud-front.host}") private val cloudFrontHost: String, + private val eventNowProvider: () -> LocalDateTime = { LocalDateTime.now() }, private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } ) : AudioRecommendationQueryRepository { override fun findBanners( @@ -79,7 +82,7 @@ class DefaultAudioRecommendationQueryRepository( audioContentBanner.lang.eq(lang), audioContentBanner.isVisibleAt(nowProvider()), adultBannerCondition(canViewAdultContent), - activeBannerTargetCondition(memberId, bannerCreator, seriesOwner) + activeBannerTargetCondition(memberId, bannerCreator, seriesOwner, lang) ) .orderBy(audioContentBanner.orders.asc(), randomTieBreaker.asc()) .limit(limit.toLong()) @@ -560,7 +563,19 @@ class DefaultAudioRecommendationQueryRepository( .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) .and(bannerCreator.isActive.isTrue) .withOptionalAnd(notBlockedCreatorCondition(memberId, bannerCreator.id)) @@ -570,11 +585,24 @@ class DefaultAudioRecommendationQueryRepository( .withOptionalAnd(notBlockedCreatorCondition(memberId, seriesOwner.id)) return audioContentBanner.type.eq(AudioContentBannerType.LINK) - .or(audioContentBanner.type.eq(AudioContentBannerType.EVENT).and(event.isActive.isTrue)) + .or(eventCondition) .or(creatorCondition) .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? { val eventId = get(event.id) ?: return null val thumbnailImage = get(event.thumbnailImage) ?: return null diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt index 39874698..9837f9ee 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -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.content.AudioContent 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.translation.ContentTranslation 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.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.MemberAdapter 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.v2.recommendation.adapter.out.persistence.RecommendationSnapshot import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType import org.junit.jupiter.api.DisplayName 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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc 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.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -41,6 +52,95 @@ class AudioRecommendationEndToEndTest @Autowired constructor( private val entityManager: EntityManager, 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() + 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 @DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다") 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 { return transactionTemplate.execute { val now = LocalDateTime.now().minusHours(1) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt index 22b0a8e5..e182cc88 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt @@ -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.creator.admin.content.series.Series 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.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth 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.AudioRecommendationVisibility @@ -29,6 +31,7 @@ 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.CsvSource import org.junit.jupiter.params.provider.MethodSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest @@ -49,13 +52,218 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( contentTranslationRepository: ContentTranslationRepository ) { private var bannerNow = AudioContentBannerReservationFixtures.now + private var eventNow = LocalDateTime.of(2026, 10, 31, 23, 59, 59, 123456789) private val repository = DefaultAudioRecommendationQueryRepository( queryFactory, entityManager, contentTranslationRepository, - "https://cdn.test" + "https://cdn.test", + eventNowProvider = { eventNow } ) { 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(), 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(), 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 @DisplayName("콘텐츠 추천 배너는 limit 전에 미래 및 만료 예약을 제외한다") 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 { val member = Member( email = "$nickname@test.com", @@ -621,7 +857,9 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( creator: Member? = null, link: String? = null, tab: AudioContentMainTab? = null, - lang: Lang = Lang.KO + lang: Lang = Lang.KO, + event: Event? = null, + series: Series? = null ): AudioContentBanner { val banner = AudioContentBanner( thumbnailImage = thumbnailImage, @@ -634,6 +872,8 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor( banner.creator = creator banner.link = link banner.tab = tab + banner.event = event + banner.series = series entityManager.persist(banner) return banner }