From c4c61e361529c5f8368d1b98ed7c9c277a38ec49 Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 3 Nov 2026 00:30:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(subbanner):=20=EC=96=B8=EC=96=B4=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EA=B3=BC=20=EA=B5=AD=EA=B0=80=EB=B3=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20261102_add_sub_banner_lang.sql | 42 ++++++++++++ .../create-sub-banner.sql | 1 + .../application/AudioRecommendationFacade.kt | 2 +- .../application/HomeRecommendationFacade.kt | 2 +- .../persistence/DefaultSubBannerRepository.kt | 8 ++- .../out/persistence/SubBannerEntity.kt | 10 ++- .../subbanner/application/SubBannerService.kt | 23 +++++-- .../sodalive/v2/subbanner/domain/SubBanner.kt | 4 +- .../subbanner/dto/AdminSubBannerResponse.kt | 7 +- .../v2/subbanner/dto/SubBannerRequest.kt | 4 +- .../subbanner/port/out/SubBannerRepository.kt | 3 +- .../AudioRecommendationFacadeTest.kt | 33 +++++++++- .../HomeRecommendationFacadeTest.kt | 31 ++++++++- ...AdminSubBannerControllerIntegrationTest.kt | 60 ++++++++++++++++- .../RecommendationSubBannerIntegrationTest.kt | 47 ++++++++++++-- .../persistence/SubBannerPersistenceTest.kt | 64 ++++++++++++++++--- .../v2/subbanner/domain/SubBannerTest.kt | 9 +++ 17 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql diff --git a/docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql b/docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql new file mode 100644 index 00000000..10ae4c4e --- /dev/null +++ b/docs/20261029_가로형서브배너/20261102_add_sub_banner_lang.sql @@ -0,0 +1,42 @@ +-- 서버 배포 전에 적용한다. KO는 임시 기본 분류이며 JA 소재와 연결 이벤트 언어는 운영 담당자가 수동 확인한다. +SET @schema_name := DATABASE(); + +SET @lang_column_exists := ( + SELECT COUNT(1) + FROM information_schema.columns + WHERE table_schema = @schema_name + AND table_name = 'sub_banner' + AND column_name = 'lang' +); + +SET @add_lang_column_sql := IF( + @lang_column_exists = 0, + 'ALTER TABLE sub_banner ADD COLUMN lang VARCHAR(10) NULL COMMENT ''서브 배너 노출 언어'' AFTER is_active', + 'SELECT ''sub_banner.lang already exists'' AS message' +); + +PREPARE add_lang_column_stmt FROM @add_lang_column_sql; +EXECUTE add_lang_column_stmt; +DEALLOCATE PREPARE add_lang_column_stmt; + +UPDATE sub_banner +SET lang = 'KO' +WHERE lang IS NULL; + +SET @lang_column_nullable := ( + SELECT IS_NULLABLE + FROM information_schema.columns + WHERE table_schema = @schema_name + AND table_name = 'sub_banner' + AND column_name = 'lang' +); + +SET @alter_lang_column_sql := IF( + @lang_column_nullable = 'YES', + 'ALTER TABLE sub_banner MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''서브 배너 노출 언어 (KO 기본, EN/JA 등록 가능)''', + 'SELECT ''sub_banner.lang already normalized'' AS message' +); + +PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql; +EXECUTE alter_lang_column_stmt; +DEALLOCATE PREPARE alter_lang_column_stmt; diff --git a/docs/20261029_가로형서브배너/create-sub-banner.sql b/docs/20261029_가로형서브배너/create-sub-banner.sql index 6b2247dd..7f0bdcef 100644 --- a/docs/20261029_가로형서브배너/create-sub-banner.sql +++ b/docs/20261029_가로형서브배너/create-sub-banner.sql @@ -8,6 +8,7 @@ CREATE TABLE sub_banner ( start_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 시작 시각 (UTC 초 단위, NULL이면 시작 제한 없음)', end_date TIMESTAMP NULL DEFAULT NULL COMMENT '노출 종료 시각 (UTC 초 단위, NULL이면 종료 제한 없음)', is_active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '활성 여부 (false이면 삭제)', + lang VARCHAR(10) NOT NULL DEFAULT 'KO' COMMENT '서브 배너 노출 언어 (KO 기본, EN/JA 등록 가능)', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각', updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각', PRIMARY KEY (id), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt index 04948252..2e429213 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacade.kt @@ -18,7 +18,7 @@ class AudioRecommendationFacade( ) { fun getRecommendations(member: Member?, lang: Lang): AudioRecommendationsResponse { val result = queryService.getRecommendations(member, lang) - val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION) + val subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION, member) .map { SubBannerResponse.from(it, cloudFrontHost) } return AudioRecommendationsResponse.from(result, subBanners) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt index d507354e..4b6b8a86 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt @@ -72,7 +72,7 @@ class HomeRecommendationFacade( lang = bannerLang ) .map { RecommendationBannerResponse.from(it.toBanner()) }, - subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION) + subBanners = subBannerService.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, member) .map { SubBannerResponse.from(it, cloudFrontHost) }, recentlyActiveCreators = queryService.findRecentlyActiveCreators( HOME_ACTIVE_CREATOR_LIMIT, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt index 75dc42f6..3f58b4a1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/DefaultSubBannerRepository.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence import com.querydsl.jpa.impl.JPAQueryFactory +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.QSubBannerEntity.subBannerEntity import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement @@ -25,12 +26,13 @@ class DefaultSubBannerRepository( .fetch() .map { it.toDomain() } - override fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List { + override fun findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List { val visibleAt = now.truncatedTo(ChronoUnit.SECONDS) return queryFactory .selectFrom(subBannerEntity) .where( subBannerEntity.placement.eq(placement), + subBannerEntity.lang.eq(lang), subBannerEntity.isActive.isTrue, subBannerEntity.startDate.isNull.or(subBannerEntity.startDate.loe(visibleAt)), subBannerEntity.endDate.isNull.or(subBannerEntity.endDate.goe(visibleAt)) @@ -51,7 +53,8 @@ class DefaultSubBannerRepository( subBanner.androidDeepLink, subBanner.iosDeepLink, startDate, - endDate + endDate, + lang = subBanner.lang ).also { entityManager.persist(it) } } else { entityManager.find(SubBannerEntity::class.java, subBanner.id).apply { @@ -62,6 +65,7 @@ class DefaultSubBannerRepository( iosDeepLink = subBanner.iosDeepLink this.startDate = startDate this.endDate = endDate + lang = subBanner.lang } } return entity.toDomain() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt index fbb32014..bc25385c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerEntity.kt @@ -1,6 +1,7 @@ package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence import kr.co.vividnext.sodalive.common.BaseEntity +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement import org.hibernate.annotations.DynamicUpdate @@ -39,7 +40,12 @@ class SubBannerEntity( var endDate: LocalDateTime?, @Column(name = "is_active", nullable = false) - var isActive: Boolean = true + var isActive: Boolean = true, + + @Enumerated(EnumType.STRING) + @Column(name = "lang", nullable = false, length = 10) + var lang: Lang = Lang.KO ) : BaseEntity() { - fun toDomain(): SubBanner = SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate) + fun toDomain(): SubBanner = + SubBanner(id, placement, imagePath, webUrl, androidDeepLink, iosDeepLink, startDate, endDate, lang) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt index 7d77e617..f9cb47e7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/application/SubBannerService.kt @@ -6,6 +6,9 @@ import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import kr.co.vividnext.sodalive.aws.s3.S3Uploader import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.utils.generateFileName import kr.co.vividnext.sodalive.utils.validateImage import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner @@ -33,6 +36,7 @@ class SubBannerService( private val repository: SubBannerRepository, private val s3Uploader: S3Uploader, private val objectMapper: ObjectMapper, + private val memberContentPreferenceService: MemberContentPreferenceService, @Value("\${cloud.aws.s3.bucket}") private val bucket: String, private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(ZoneOffset.UTC) } ) { @@ -40,9 +44,10 @@ class SubBannerService( fun findAllByPlacement(placement: SubBannerPlacement): List = repository.findAllByPlacement(placement) @Transactional(readOnly = true) - fun findVisibleByPlacement(placement: SubBannerPlacement): List { + fun findVisibleByPlacement(placement: SubBannerPlacement, member: Member?): List { val now = nowProvider().truncatedTo(ChronoUnit.SECONDS) - return repository.findVisibleByPlacement(placement, now) + val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO + return repository.findVisibleByPlacement(placement, lang, now) } @Transactional @@ -61,7 +66,8 @@ class SubBannerService( androidDeepLink = input.androidDeepLink, iosDeepLink = input.iosDeepLink, startDate = startDate, - endDate = endDate + endDate = endDate, + lang = input.lang ) ) } @@ -83,7 +89,8 @@ class SubBannerService( androidDeepLink = input.androidDeepLink, iosDeepLink = input.iosDeepLink, startDate = startDate, - endDate = endDate + endDate = endDate, + lang = if (fields.has("lang")) input.lang else existing.lang ) ) } @@ -118,6 +125,14 @@ class SubBannerService( val value = fields[field] if (value != null && !value.isNull && !value.isTextual) invalidRequest() } + fields["lang"]?.let { value -> + if (!value.isTextual) invalidRequest() + try { + Lang.fromCode(value.textValue()) + } catch (_: IllegalArgumentException) { + invalidRequest() + } + } return fields } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt index c60a081a..db6b4d2e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBanner.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.subbanner.domain +import kr.co.vividnext.sodalive.i18n.Lang import java.time.LocalDateTime data class SubBanner( @@ -10,7 +11,8 @@ data class SubBanner( val androidDeepLink: String?, val iosDeepLink: String?, val startDate: LocalDateTime?, - val endDate: LocalDateTime? + val endDate: LocalDateTime?, + val lang: Lang = Lang.KO ) { init { require(!webUrl.isNullOrBlank() || !androidDeepLink.isNullOrBlank() || !iosDeepLink.isNullOrBlank()) { diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt index 4febc4b5..54d51bdf 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/AdminSubBannerResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.subbanner.dto +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement import java.time.LocalDateTime @@ -15,7 +16,8 @@ data class AdminSubBannerResponse( val androidDeepLink: String?, val iosDeepLink: String?, val startDate: String?, - val endDate: String? + val endDate: String?, + val lang: Lang ) { companion object { fun from(banner: SubBanner, cloudFrontHost: String): AdminSubBannerResponse = AdminSubBannerResponse( @@ -26,7 +28,8 @@ data class AdminSubBannerResponse( androidDeepLink = banner.androidDeepLink, iosDeepLink = banner.iosDeepLink, startDate = formatDate(banner.startDate), - endDate = formatDate(banner.endDate) + endDate = formatDate(banner.endDate), + lang = banner.lang ) private fun formatDate(value: LocalDateTime?): String? = value?.atZone(ZoneOffset.UTC) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt index a87ad083..215face5 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/dto/SubBannerRequest.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.subbanner.dto +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement data class SubBannerRequest( @@ -8,5 +9,6 @@ data class SubBannerRequest( val androidDeepLink: String?, val iosDeepLink: String?, val startDate: String? = null, - val endDate: String? = null + val endDate: String? = null, + val lang: Lang = Lang.KO ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt index 7325b1ac..c56d74d8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/subbanner/port/out/SubBannerRepository.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.subbanner.port.out +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement import java.time.LocalDateTime @@ -7,7 +8,7 @@ import java.time.LocalDateTime interface SubBannerRepository { fun findById(id: Long): SubBanner? fun findAllByPlacement(placement: SubBannerPlacement): List - fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List + fun findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List fun save(subBanner: SubBanner): SubBanner fun deleteById(id: Long) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt index 3c24bdda..1abac6d9 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.api.content.recommendation.application import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.common.domain.RecommendationBanner import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard @@ -9,6 +10,8 @@ import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommenda import kr.co.vividnext.sodalive.v2.content.recommendation.domain.CommentedAudio import kr.co.vividnext.sodalive.v2.content.recommendation.domain.OriginalSeries import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -17,7 +20,35 @@ import org.mockito.Mockito class AudioRecommendationFacadeTest { private val objectMapper = jacksonObjectMapper() private val queryService = Mockito.mock(AudioRecommendationQueryService::class.java) - private val facade = AudioRecommendationFacade(queryService, Mockito.mock(SubBannerService::class.java), "https://cdn.test") + private val subBannerService = Mockito.mock(SubBannerService::class.java) + private val facade = AudioRecommendationFacade(queryService, subBannerService, "https://cdn.test") + + @Test + @DisplayName("콘텐츠 추천은 언어 context 대신 nullable 회원을 서브 배너 조회에 전달하고 공개 결과를 조립한다") + fun shouldComposeSubBannersUsingTheViewerRatherThanContextLanguage() { + val member = Member(password = "password", nickname = "viewer").apply { id = 900001L } + listOf(null, member).forEach { viewer -> + val banner = SubBanner( + id = 123L, + placement = SubBannerPlacement.CONTENT_RECOMMENDATION, + imagePath = "sub_banner/ja.png", + webUrl = "https://example.test/promo", + androidDeepLink = null, + iosDeepLink = null, + startDate = null, + endDate = null, + lang = Lang.JA + ) + Mockito.doReturn(domain()).`when`(queryService).getRecommendations(viewer, Lang.EN) + Mockito.doReturn(listOf(banner)).`when`(subBannerService) + .findVisibleByPlacement(SubBannerPlacement.CONTENT_RECOMMENDATION, viewer) + + val result = facade.getRecommendations(viewer, Lang.EN) + + assertEquals(listOf(123L), result.subBanners.map { it.id }) + assertEquals("https://cdn.test/sub_banner/ja.png", result.subBanners.single().imageUrl) + } + } @Test @DisplayName("facade는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt index 2c445dda..cf233ff3 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt @@ -23,6 +23,8 @@ import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecentlyActiveCreator import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner +import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement import org.junit.jupiter.api.Assertions.assertAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -44,13 +46,40 @@ class HomeRecommendationFacadeTest { creatorCommunityTranslationService = creatorCommunityTranslationService ) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val subBannerService = Mockito.mock(SubBannerService::class.java) private val facade = HomeRecommendationFacade( queryService, preferenceService, - Mockito.mock(SubBannerService::class.java), + subBannerService, "https://cdn.test" ) + @Test + @DisplayName("홈은 언어 context 대신 nullable 회원을 서브 배너 조회에 전달하고 공개 결과를 조립한다") + fun shouldComposeSubBannersUsingTheViewerRatherThanContextLanguage() { + val member = Member(password = "password", nickname = "viewer").apply { id = 900001L } + listOf(null, member).forEach { viewer -> + val banner = SubBanner( + id = 123L, + placement = SubBannerPlacement.HOME_RECOMMENDATION, + imagePath = "sub_banner/ja.png", + webUrl = "https://example.test/promo", + androidDeepLink = null, + iosDeepLink = null, + startDate = null, + endDate = null, + lang = Lang.JA + ) + Mockito.doReturn(listOf(banner)).`when`(subBannerService) + .findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, viewer) + + val result = facade.getHomeRecommendations(viewer, Lang.EN) + + assertEquals(listOf(123L), result.subBanners.map { it.id }) + assertEquals("https://cdn.test/sub_banner/ja.png", result.subBanners.single().imageUrl) + } + } + @Test @DisplayName("홈 추천은 AI 스냅샷과 상세 및 무작위 보충이나 fallback 조회 없이 빈 AI 목록을 반환한다") fun shouldReturnEmptyAiCharactersWithoutQueryingAiRecommendations() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt index 0ab3142e..23e6c1ae 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/AdminSubBannerControllerIntegrationTest.kt @@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence.DefaultSubBannerRepository import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository import org.junit.jupiter.api.AfterEach @@ -27,6 +28,7 @@ import org.mockito.Mockito import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.boot.test.mock.mockito.SpyBean import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.boot.test.web.server.LocalServerPort import org.springframework.core.io.ByteArrayResource @@ -78,6 +80,9 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor( @MockBean private lateinit var s3Uploader: S3Uploader + @SpyBean + private lateinit var writeRepository: DefaultSubBannerRepository + private lateinit var adminToken: String private val memberIds = mutableListOf() @@ -106,7 +111,7 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor( } @Test - @DisplayName("실제 JWT HTTP CRUD는 관리자 8필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다") + @DisplayName("실제 JWT HTTP CRUD는 관리자 9필드·삭제 null을 유지하며 소프트 삭제 후 수정·재삭제를 거부한다") fun shouldCreateListUpdateAndSoftDeleteWithExactAdminResponse() { val createdResponse = send(HttpMethod.POST) evidence("POST", createdResponse) @@ -163,6 +168,57 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor( } } + @Test + @DisplayName("언어 생략 생성은 KO이고 JA·EN 수정 뒤 생략은 보존하며 관리자 목록은 모든 언어다") + fun shouldDefaultCreateLanguageAndPreserveOmittedUpdateAcrossAllAdminLanguages() { + val defaultResponse = send(HttpMethod.POST) + evidence("language omitted POST", defaultResponse) + val default = success(defaultResponse) + assertEquals("KO", default.path("lang").asText()) + val created = listOf(" ja ", "en").map { lang -> + val response = send(HttpMethod.POST, fields = baseFields() + ("lang" to lang)) + evidence("language $lang POST", response) + success(response) + } + assertEquals(listOf("JA", "EN"), created.map { it.path("lang").asText() }) + created.forEach { original -> + val id = original["id"].asLong() + val omitted = send(HttpMethod.PUT, "/$id", image = null) + evidence("language omitted PUT", omitted) + assertEquals(original, success(omitted)) + assertEquals(original["lang"], objectMapper.valueToTree(reload(id))["lang"]) + } + val id = default["id"].asLong() + listOf("JA", "EN", "KO").forEach { lang -> + val updated = send(HttpMethod.PUT, "/$id", baseFields() + ("lang" to lang), image = null) + evidence("language $lang PUT", updated) + assertEquals(lang, success(updated).path("lang").asText()) + assertEquals(lang, objectMapper.valueToTree(reload(id)).path("lang").asText()) + } + val listing = send(HttpMethod.GET, "?placement=HOME_RECOMMENDATION") + evidence("language all admin GET", listing) + assertEquals(listOf(default) + created, success(listing).toList()) + } + + @ParameterizedTest + @ValueSource(strings = ["null", "0", "true", "[]", "{}", "\"\"", "\" \"", "\"FR\""]) + @DisplayName("명시적 null·비문자열·blank·알 수 없는 언어는 POST·PUT 업로드·저장 호출 전 400이다") + fun shouldRejectInvalidLanguageBeforeUploadOrWrite(raw: String) { + val id = createScheduled() + val existing = reload(id) + val before = storedRows() + Mockito.clearInvocations(s3Uploader, writeRepository) + val fields = baseFields() + ("lang" to objectMapper.readValue(raw, Any::class.java)) + listOf(HttpMethod.POST to "", HttpMethod.PUT to "/$id").forEach { (method, suffix) -> + val response = send(method, suffix, fields) + evidence("invalid language $raw $method", response) + assertStatus(response, HttpStatus.BAD_REQUEST) + assertEquals(before, storedRows()) + Mockito.verifyNoInteractions(s3Uploader) + Mockito.verify(writeRepository, Mockito.never()).save(Mockito.any(SubBanner::class.java) ?: existing) + } + } + @ParameterizedTest @ValueSource(ints = [0, 1, 2, 3, 4, 5, 6, 7]) @DisplayName("주소 8개 조합 중 모두 빈 조합만 거부하고 나머지는 생성·수정한다") @@ -519,7 +575,7 @@ class AdminSubBannerControllerIntegrationTest @Autowired constructor( private fun assertAdminFields(data: JsonNode) { assertEquals( - setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"), + setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate", "lang"), data.fieldNames().asSequence().toSet() ) } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt index 7fb3b9dc..c0971668 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/in/web/RecommendationSubBannerIntegrationTest.kt @@ -6,10 +6,12 @@ import kr.co.vividnext.sodalive.aws.s3.S3Uploader 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.i18n.Lang import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRepository import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService import kr.co.vividnext.sodalive.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.subbanner.application.SubBannerService @@ -86,6 +88,29 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor( private val memberIds = mutableListOf() + @ParameterizedTest + @EnumSource(SubBannerPlacement::class) + @DisplayName("두 공개 추천은 JP·KR·US·국가 누락·강제 매핑에 맞는 언어만 공개 6필드로 반환한다") + fun shouldFilterPublicSubBannersByCountryLanguage(placement: SubBannerPlacement) { + val korean = save(banner(placement)) + val response = send(recommendationPath(placement), country = "JP") + evidence("language JP $placement", response) + assertEquals(emptyList(), successData(response)["subBanners"].toList()) + val japanese = save(banner(placement).copy(lang = Lang.JA)) + save(banner(placement).copy(lang = Lang.EN)) + listOf("JP" to japanese, "KR" to korean, "US" to korean, null to korean).forEach { (country, expected) -> + val result = send(recommendationPath(placement), country = country) + evidence("language country=$country $placement", result) + assertEquals(listOf(publicTree(expected)), successData(result)["subBanners"].toList()) + } + listOf(29721L to japanese, 16L to korean).forEach { (memberId, expected) -> + val country = if (memberId == 29721L) "KR" else "JP" + val result = send(recommendationPath(placement), token = tokenFor(MemberRole.USER, memberId), country = country) + evidence("language forced=$memberId country=$country $placement", result) + assertEquals(listOf(publicTree(expected)), successData(result)["subBanners"].toList()) + } + } + @BeforeEach fun setUp() { jdbcTemplate.update("delete from sub_banner") @@ -107,6 +132,7 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor( jdbcTemplate.update("delete from content_banner") jdbcTemplate.update("delete from audio_content_main_tab") tokenRepository.deleteAll() + memberIds.forEach { jdbcTemplate.update("delete from member_content_preference where member_id = ?", it) } memberRepository.deleteAllById(memberIds) memberIds.clear() } @@ -189,7 +215,7 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor( assertEquals(active.map { it.id }, ids(admin)) admin.forEach { data -> assertEquals( - setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate"), + setOf("id", "placement", "imageUrl", "webUrl", "androidDeepLink", "iosDeepLink", "startDate", "endDate", "lang"), data.fieldNames().asSequence().toSet() ) } @@ -358,13 +384,15 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor( path: String, method: HttpMethod = HttpMethod.GET, token: String? = null, - userAgent: String = "sub-banner-http-test" + userAgent: String = "sub-banner-http-test", + country: String? = null ): ResponseEntity = restTemplate.exchange( "http://127.0.0.1:$port$path", method, HttpEntity( HttpHeaders().apply { token?.let { setBearerAuth(it) } + country?.let { set("CloudFront-Viewer-Country", it) } set(HttpHeaders.ACCEPT_LANGUAGE, "en") set(HttpHeaders.USER_AGENT, userAgent) accept = listOf(MediaType.APPLICATION_JSON) @@ -472,11 +500,12 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor( private fun storedRows(): List> = jdbcTemplate.queryForList("select * from sub_banner order by id") - private fun tokenFor(role: MemberRole): String { + private fun tokenFor(role: MemberRole, forcedId: Long? = null): String { val member = memberRepository.saveAndFlush( Member(email = "${UUID.randomUUID()}@test.com", password = "password", nickname = "test", role = role) ) - val id = requireNotNull(member.id) + val id = forcedId ?: requireNotNull(member.id) + if (forcedId != null) jdbcTemplate.update("update member set id = ? where id = ?", forcedId, member.id) memberIds.add(id) return tokenProvider.createToken( UsernamePasswordAuthenticationToken(member.email, "", listOf(SimpleGrantedAuthority("ROLE_${role.name}"))), @@ -514,8 +543,16 @@ class RecommendationSubBannerIntegrationTest @Autowired constructor( repository: SubBannerRepository, s3Uploader: S3Uploader, objectMapper: ObjectMapper, + memberContentPreferenceService: MemberContentPreferenceService, time: TestTime - ): SubBannerService = SubBannerService(repository, s3Uploader, objectMapper, "test-bucket", time::now) + ): SubBannerService = SubBannerService( + repository, + s3Uploader, + objectMapper, + memberContentPreferenceService, + "test-bucket", + time::now + ) } companion object { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt index d351194e..3cf2a58a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/adapter/out/persistence/SubBannerPersistenceTest.kt @@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.v2.subbanner.adapter.out.persistence import com.querydsl.jpa.impl.JPAQueryFactory import kr.co.vividnext.sodalive.configs.QueryDslConfig +import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBanner import kr.co.vividnext.sodalive.v2.subbanner.domain.SubBannerPlacement import kr.co.vividnext.sodalive.v2.subbanner.port.out.SubBannerRepository @@ -50,6 +51,44 @@ class SubBannerPersistenceTest @Autowired constructor( jdbcTemplate.execute("alter table sub_banner alter column end_date timestamp(0)") } + @ParameterizedTest + @EnumSource(Lang::class) + @DisplayName("KO 기본값과 모든 언어는 문자열로 왕복하고 공개 조회만 언어·기간·활성을 함께 제한한다") + fun shouldPersistAndUpdateLanguageWhileFilteringOnlyPublicRows(lang: Lang) { + assertEquals(Lang.KO, banner().lang) + val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59) + val placement = SubBannerPlacement.HOME_RECOMMENDATION + val visible = Lang.values().map { repository.save(banner().copy(lang = it)) } + val future = repository.save(banner().copy(lang = lang, startDate = now.plusSeconds(1))) + val expired = repository.save(banner().copy(lang = lang, endDate = now.minusSeconds(1))) + val deleted = repository.save(banner().copy(lang = lang)) + repository.deleteById(requireNotNull(deleted.id)) + flushAndClear() + + visible.forEach { expected -> + assertEquals(expected, repository.findById(requireNotNull(expected.id))) + assertEquals( + expected.lang.name, + entityManager.createNativeQuery("select lang from sub_banner where id = :id") + .setParameter("id", expected.id).singleResult + ) + } + assertEquals(visible + future + expired, repository.findAllByPlacement(placement)) + val selected = visible.single { it.lang == lang } + assertEquals(listOf(selected), repository.findVisibleByPlacement(placement, lang, now)) + val changed = selected.copy(lang = Lang.values().first { it != lang }) + assertEquals(changed, repository.save(changed)) + flushAndClear() + assertEquals(changed, repository.findById(requireNotNull(changed.id))) + assertEquals(emptyList(), repository.findVisibleByPlacement(placement, lang, now)) + val columns = jdbcTemplate.queryForMap( + "select IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH from INFORMATION_SCHEMA.COLUMNS " + + "where TABLE_NAME = 'sub_banner' and COLUMN_NAME = 'lang'" + ) + assertEquals("NO", columns["IS_NULLABLE"]) + assertEquals(10L, (columns["CHARACTER_MAXIMUM_LENGTH"] as Number).toLong()) + } + @ParameterizedTest @ValueSource(ints = [0, 123456789, 999999999]) @DisplayName("nullable UTC 기간은 저장 결과와 DB 재조회 모두 초 정밀도를 정확히 보존한다") @@ -102,7 +141,7 @@ class SubBannerPersistenceTest @Autowired constructor( fun shouldReturnAllPlacementRowsInIdOrderWithoutLimit(placement: SubBannerPlacement) { val now = LocalDateTime.of(2026, 10, 29, 23, 59, 59) assertEquals(emptyList(), repository.findAllByPlacement(placement)) - assertEquals(emptyList(), repository.findVisibleByPlacement(placement, now)) + assertEquals(emptyList(), repository.findVisibleByPlacement(placement, Lang.KO, now)) val otherPlacement = SubBannerPlacement.values().first { it != placement } val saved = (1..25).map { index -> repository.save(banner(otherPlacement)) @@ -113,7 +152,7 @@ class SubBannerPersistenceTest @Autowired constructor( flushAndClear() assertEquals(saved + future + expired, repository.findAllByPlacement(placement)) - assertEquals(saved, repository.findVisibleByPlacement(placement, now)) + assertEquals(saved, repository.findVisibleByPlacement(placement, Lang.KO, now)) } @ParameterizedTest(name = "{0}") @@ -132,7 +171,7 @@ class SubBannerPersistenceTest @Autowired constructor( assertEquals( if (visible) listOf(candidate) else emptyList(), - repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, now), + repository.findVisibleByPlacement(SubBannerPlacement.HOME_RECOMMENDATION, Lang.KO, now), name ) } @@ -217,10 +256,10 @@ class SubBannerPersistenceTest @Autowired constructor( } active.forEach { assertEquals(it, repository.findById(requireNotNull(it.id))) } assertEquals(active, repository.findAllByPlacement(placement)) - assertEquals(active.take(2), repository.findVisibleByPlacement(placement, now)) + assertEquals(active.take(2), repository.findVisibleByPlacement(placement, Lang.KO, now)) assertEquals(other, repository.findById(requireNotNull(other.id))) assertEquals(listOf(other), repository.findAllByPlacement(other.placement)) - assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, now)) + assertEquals(listOf(other), repository.findVisibleByPlacement(other.placement, Lang.KO, now)) } @Test @@ -243,7 +282,7 @@ class SubBannerPersistenceTest @Autowired constructor( ) assertNull(repository.findById(id)) assertEquals(emptyList(), repository.findAllByPlacement(saved.placement)) - assertEquals(emptyList(), repository.findVisibleByPlacement(saved.placement, LocalDateTime.now())) + assertEquals(emptyList(), repository.findVisibleByPlacement(saved.placement, Lang.KO, LocalDateTime.now())) } @Test @@ -297,7 +336,7 @@ class SubBannerPersistenceTest @Autowired constructor( val readRepository = DefaultSubBannerRepository(readContext, JPAQueryFactory(readContext)) assertNull(readRepository.findById(id)) assertEquals(emptyList(), readRepository.findAllByPlacement(saved.placement)) - assertEquals(emptyList(), readRepository.findVisibleByPlacement(saved.placement, now)) + assertEquals(emptyList(), readRepository.findVisibleByPlacement(saved.placement, Lang.KO, now)) readContext.transaction.commit() } finally { listOf(updateContext, deleteContext, readContext).forEach { context -> @@ -341,17 +380,26 @@ class SubBannerPersistenceTest @Autowired constructor( assertTrue(columns.next(), "CREATE DDL must contain is_active") assertEquals("NO", columns.getString("IS_NULLABLE")) } + connection.metaData.getColumns(null, null, "sub_banner", "lang").use { columns -> + assertTrue(columns.next(), "CREATE DDL must contain lang") + assertEquals("NO", columns.getString("IS_NULLABLE")) + assertEquals(10, columns.getInt("COLUMN_SIZE")) + } statement.executeUpdate( "insert into sub_banner (placement, image_path, web_url) " + "values ('HOME_RECOMMENDATION', 'sub-banner/image.png', 'https://example.com/banner')" ) - statement.executeQuery("select is_active from sub_banner").use { rows -> + statement.executeQuery("select is_active, lang from sub_banner").use { rows -> assertTrue(rows.next()) assertTrue(rows.getBoolean("is_active")) + assertEquals("KO", rows.getString("lang")) } assertThrows(SQLException::class.java) { statement.executeUpdate("update sub_banner set is_active = null") } + assertThrows(SQLException::class.java) { + statement.executeUpdate("update sub_banner set lang = null") + } } } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt index e7905a8c..ff5ce0c6 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/subbanner/domain/SubBannerTest.kt @@ -1,11 +1,20 @@ package kr.co.vividnext.sodalive.v2.subbanner.domain +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test class SubBannerTest { + @Test + @DisplayName("기존 생성자는 KO 언어를 기본으로 가진다") + fun shouldDefaultLanguageToKorean() { + val stored = jacksonObjectMapper().valueToTree(banner("https://example.com", null, null)) + assertEquals("KO", stored.path("lang").asText()) + } + @Test @DisplayName("세 링크 중 하나 이상이 있는 7개 조합은 허용하고 모두 null이면 거부한다") fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations() {