fix(subbanner): 언어 설정과 국가별 공개 필터를 추가한다

This commit is contained in:
2026-11-03 00:30:02 +09:00
parent 255da2b6aa
commit c4c61e3615
17 changed files with 318 additions and 32 deletions
@@ -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;
@@ -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),
@@ -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)
}
@@ -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,
@@ -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<SubBanner> {
override fun findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List<SubBanner> {
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()
@@ -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)
}
@@ -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<SubBanner> = repository.findAllByPlacement(placement)
@Transactional(readOnly = true)
fun findVisibleByPlacement(placement: SubBannerPlacement): List<SubBanner> {
fun findVisibleByPlacement(placement: SubBannerPlacement, member: Member?): List<SubBanner> {
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
}
@@ -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()) {
@@ -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)
@@ -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
)
@@ -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<SubBanner>
fun findVisibleByPlacement(placement: SubBannerPlacement, now: LocalDateTime): List<SubBanner>
fun findVisibleByPlacement(placement: SubBannerPlacement, lang: Lang, now: LocalDateTime): List<SubBanner>
fun save(subBanner: SubBanner): SubBanner
fun deleteById(id: Long)
}
@@ -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는 도메인 추천 결과를 모든 공개 응답 필드로 변환한다")
@@ -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() {
@@ -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<Long>()
@@ -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<JsonNode>(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<JsonNode>(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()
)
}
@@ -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<Long>()
@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<JsonNode>(), 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<String> = restTemplate.exchange(
"http://127.0.0.1:$port$path",
method,
HttpEntity<Any>(
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<Map<String, Any>> = 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 {
@@ -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<SubBanner>(), 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<SubBanner>(), repository.findAllByPlacement(placement))
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(placement, now))
assertEquals(emptyList<SubBanner>(), 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<SubBanner>(), repository.findAllByPlacement(saved.placement))
assertEquals(emptyList<SubBanner>(), repository.findVisibleByPlacement(saved.placement, LocalDateTime.now()))
assertEquals(emptyList<SubBanner>(), 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<SubBanner>(), readRepository.findAllByPlacement(saved.placement))
assertEquals(emptyList<SubBanner>(), readRepository.findVisibleByPlacement(saved.placement, now))
assertEquals(emptyList<SubBanner>(), 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")
}
}
}
}
@@ -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<JsonNode>(banner("https://example.com", null, null))
assertEquals("KO", stored.path("lang").asText())
}
@Test
@DisplayName("세 링크 중 하나 이상이 있는 7개 조합은 허용하고 모두 null이면 거부한다")
fun shouldRejectAllEmptyLinksAndAllowSevenNonEmptyCombinations() {