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
@@ -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() {