feat(event): 접속 국가별 이벤트 언어 필터를 적용한다

This commit is contained in:
2026-08-20 13:49:21 +09:00
parent 2ad30f9069
commit 972bd8ca9d
13 changed files with 577 additions and 31 deletions
@@ -0,0 +1,158 @@
package kr.co.vividnext.sodalive.admin.event.banner
import com.amazonaws.services.s3.model.ObjectMetadata
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.event.Event
import kr.co.vividnext.sodalive.event.EventRepository
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.hamcrest.Matchers.containsInAnyOrder
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.mock.web.MockMultipartFile
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.io.InputStream
import java.time.LocalDateTime
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:admin-event-banner-controller-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class AdminEventBannerControllerIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val eventRepository: EventRepository,
private val jdbcTemplate: JdbcTemplate
) {
@MockBean
private lateinit var s3Uploader: S3Uploader
@BeforeEach
fun setUp() {
registerMysqlDateFunctions()
eventRepository.deleteAll()
Mockito.`when`(
s3Uploader.upload(
anyInputStream(),
anyStringValue(),
anyStringValue(),
anyObjectMetadata()
)
).thenAnswer { it.getArgument(2) }
}
@Test
@DisplayName("관리자 이벤트 등록은 소문자 언어 코드를 저장한다")
fun shouldPersistLowercaseLanguageWhenAdminCreatesEventBanner() {
mockMvc.perform(createRequest().param("lang", "en"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
assertEquals(Lang.EN, eventRepository.findAll().single().lang)
}
@Test
@DisplayName("관리자 이벤트 등록은 잘못된 언어를 저장하거나 업로드하지 않는다")
fun shouldRejectInvalidLanguageWithoutPersistenceOrUpload() {
mockMvc.perform(createRequest().param("lang", "fr"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, eventRepository.count())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("관리자 이벤트 등록은 언어 누락을 저장하거나 업로드하지 않는다")
fun shouldRejectMissingLanguageWithoutPersistenceOrUpload() {
mockMvc.perform(createRequest())
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(false))
assertEquals(0, eventRepository.count())
Mockito.verifyNoInteractions(s3Uploader)
}
@Test
@DisplayName("관리자 이벤트 목록은 활성 미만료 이벤트의 모든 언어를 반환한다")
fun shouldReturnAllActiveNonExpiredLanguagesForAdmin() {
eventRepository.saveAllAndFlush(
listOf(
activeEvent("ko", Lang.KO),
activeEvent("en", Lang.EN),
activeEvent("ja", Lang.JA),
activeEvent("inactive", Lang.KO).apply { isActive = false },
activeEvent("expired", Lang.JA).apply { endDate = LocalDateTime.now().minusMinutes(1) }
)
)
mockMvc.perform(
get("/admin/event/banner")
.with(user("admin").roles("ADMIN"))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.length()").value(3))
.andExpect(jsonPath("$.data[*].lang").value(containsInAnyOrder("KO", "EN", "JA")))
}
private fun createRequest() =
multipart("/admin/event/banner")
.file(MockMultipartFile("thumbnail", "thumbnail.png", "image/png", "thumbnail".toByteArray()))
.param("link", "https://event.test/banner")
.param("isPopup", "false")
.param("startDate", "2099-01-01 00:00")
.param("endDate", "2099-01-02 00:00")
.with(user("admin").roles("ADMIN"))
private fun activeEvent(seed: String, lang: Lang): Event {
return Event(
thumbnailImage = "$seed-thumbnail.png",
detailImage = "$seed-detail.png",
popupImage = null,
link = null,
title = seed,
lang = lang,
isAdult = false,
isPopup = false,
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1)
)
}
private fun anyInputStream(): InputStream = Mockito.any(InputStream::class.java) ?: byteArrayOf().inputStream()
private fun anyObjectMetadata(): ObjectMetadata = Mockito.any(ObjectMetadata::class.java) ?: ObjectMetadata()
private fun anyStringValue(): String = Mockito.anyString() ?: ""
private fun registerMysqlDateFunctions() {
jdbcTemplate.execute(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
)
jdbcTemplate.execute(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
)
}
}
@@ -0,0 +1,133 @@
package kr.co.vividnext.sodalive.event
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.ContextConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import java.time.LocalDateTime
@SpringBootTest(
properties = [
"cloud.aws.cloud-front.host=https://cdn.test",
"cloud.aws.s3.bucket=test-bucket",
"spring.datasource.url=jdbc:h2:mem:event-controller-integration;" +
"MODE=MySQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=VALUE;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class EventControllerIntegrationTest @Autowired constructor(
private val mockMvc: MockMvc,
private val eventRepository: EventRepository
) {
@MockBean
private lateinit var s3Uploader: S3Uploader
@BeforeEach
fun setUp() {
eventRepository.deleteAll()
}
@Test
@DisplayName("비로그인 JP 요청의 이벤트 목록은 JA만 반환한다")
fun shouldReturnOnlyJapaneseEventListForAnonymousJapanRequest() {
val japaneseEvent = saveControllerEvent("ja", Lang.JA)
saveControllerEvent("ko", Lang.KO)
mockMvc.perform(
get("/event")
.header("CloudFront-Viewer-Country", "JP")
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.eventList.length()").value(1))
.andExpect(jsonPath("$.data.eventList[0].id").value(japaneseEvent.id))
}
@Test
@DisplayName("인증 JP 요청의 이벤트 팝업은 JA만 반환한다")
fun shouldReturnOnlyJapanesePopupForAuthenticatedJapanRequest() {
val japaneseEvent = saveControllerEvent("ja", Lang.JA)
saveControllerEvent("newer-ko", Lang.KO)
mockMvc.perform(
get("/event/popup")
.header("CloudFront-Viewer-Country", "JP")
.with(authentication(memberAuthentication()))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.id").value(japaneseEvent.id))
}
@Test
@DisplayName("국가 헤더 없는 비로그인 이벤트 목록은 KO만 반환한다")
fun shouldReturnOnlyKoreanEventListWhenCountryHeaderIsMissing() {
saveControllerEvent("ja", Lang.JA)
val koreanEvent = saveControllerEvent("ko", Lang.KO)
mockMvc.perform(get("/event"))
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.eventList.length()").value(1))
.andExpect(jsonPath("$.data.eventList[0].id").value(koreanEvent.id))
}
@Test
@DisplayName("국가 헤더 없는 인증 요청의 이벤트 팝업은 KO만 반환한다")
fun shouldReturnOnlyKoreanPopupForAuthenticatedRequestWhenCountryHeaderIsMissing() {
saveControllerEvent("ja", Lang.JA)
val koreanEvent = saveControllerEvent("ko", Lang.KO)
mockMvc.perform(
get("/event/popup")
.with(authentication(memberAuthentication()))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.id").value(koreanEvent.id))
}
@Test
@DisplayName("비로그인 이벤트 팝업 요청은 인증이 필요하다")
fun shouldRequireAuthenticationForAnonymousEventPopupRequest() {
mockMvc.perform(get("/event/popup"))
.andExpect(status().isUnauthorized)
}
private fun saveControllerEvent(seed: String, lang: Lang): Event {
return eventRepository.saveAndFlush(
Event(
thumbnailImage = "$seed-thumbnail.png",
detailImage = "$seed-detail.png",
popupImage = "$seed-popup.png",
link = null,
title = seed,
lang = lang,
isAdult = false,
isPopup = true,
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1)
)
)
}
private fun memberAuthentication(): UsernamePasswordAuthenticationToken {
val member = Member(password = "password", nickname = "event-member").apply { id = 1000L }
val memberAdapter = MemberAdapter(member)
return UsernamePasswordAuthenticationToken(memberAdapter, "token", memberAdapter.authorities)
}
}
@@ -0,0 +1,147 @@
package kr.co.vividnext.sodalive.event
import kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerRepository
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.i18n.Lang
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(QueryDslConfig::class)
class EventRepositoryTest @Autowired constructor(
private val repository: EventRepository,
private val adminRepository: AdminEventBannerRepository,
private val entityManager: EntityManager
) {
@BeforeEach
fun setUp() {
registerMysqlDateFunctions()
}
@Test
@DisplayName("이벤트 언어는 명시적 값을 저장하고 누락 시 KO를 사용한다")
fun shouldPersistEventLanguageAndDefaultToKorean() {
val japanese = repository.saveAndFlush(activeEvent("ja").apply { lang = Lang.JA })
val korean = repository.saveAndFlush(activeEvent("ko"))
entityManager.clear()
assertEquals(Lang.JA, repository.findById(japanese.id!!).orElseThrow().lang)
assertEquals(Lang.KO, repository.findById(korean.id!!).orElseThrow().lang)
}
@Test
@DisplayName("관리자 이벤트 목록은 활성 미만료 이벤트의 모든 언어를 조회한다")
fun shouldReturnAllActiveNonExpiredLanguagesForAdmin() {
repository.saveAllAndFlush(
listOf(
activeEvent("ko"),
activeEvent("en").apply { lang = Lang.EN },
activeEvent("ja").apply { lang = Lang.JA },
activeEvent("inactive").apply { isActive = false },
activeEvent("expired").apply { endDate = LocalDateTime.now().minusMinutes(1) }
)
)
entityManager.clear()
val languages = adminRepository.getEventList().map { it.lang }
assertEquals(setOf(Lang.KO, Lang.EN, Lang.JA), languages.toSet())
assertEquals(3, languages.size)
}
@Test
@DisplayName("앱 이벤트 목록은 요청 언어와 기존 활성 기간 성인 조건을 모두 적용한다")
fun shouldFilterEventListByKoreanAndJapaneseLanguage() {
val korean = repository.saveAndFlush(activeEvent("ko"))
val japanese = repository.saveAndFlush(activeEvent("ja").apply { lang = Lang.JA })
repository.saveAndFlush(
activeEvent("inactive-ja").apply {
lang = Lang.JA
isActive = false
}
)
repository.saveAndFlush(
activeEvent("future-ja").apply {
lang = Lang.JA
startDate = LocalDateTime.now().plusDays(1)
}
)
repository.saveAndFlush(activeEvent("expired-ko").apply { endDate = LocalDateTime.now().minusDays(1) })
repository.saveAndFlush(activeEvent("adult-ko").apply { isAdult = true })
val koreanIds = repository.getEventList(isAdult = false, lang = Lang.KO).map { it.id }
val japaneseIds = repository.getEventList(isAdult = false, lang = Lang.JA).map { it.id }
assertEquals(listOf(korean.id), koreanIds)
assertEquals(listOf(japanese.id), japaneseIds)
}
@Test
@DisplayName("언어가 없으면 활성 이벤트의 KO EN JA를 모두 조회한다")
fun shouldReturnAllActiveLanguagesWhenLanguageIsNull() {
val events = repository.saveAllAndFlush(
listOf(
activeEvent("ko"),
activeEvent("en").apply { lang = Lang.EN },
activeEvent("ja").apply { lang = Lang.JA }
)
)
entityManager.clear()
val eventIds = repository.getEventList(isAdult = false, lang = null).map { it.id }
assertEquals(events.map { it.id }.toSet(), eventIds.toSet())
assertEquals(3, eventIds.size)
}
@Test
@DisplayName("팝업은 언어 필터 후 최신 이벤트를 선택한다")
fun shouldFilterPopupByLanguageBeforeSelectingNewestEvent() {
val japanesePopup = repository.saveAndFlush(activeEvent("ja-popup", isPopup = true).apply { lang = Lang.JA })
repository.saveAndFlush(activeEvent("newer-ko-popup", isPopup = true))
repository.saveAndFlush(
activeEvent("newer-adult-ja-popup", isPopup = true).apply {
lang = Lang.JA
isAdult = true
}
)
val result = repository.getMainEventPopup(isAdult = false, lang = Lang.JA)
assertEquals(japanesePopup.id, result?.id)
}
private fun activeEvent(
seed: String,
isPopup: Boolean = false
): Event {
return Event(
thumbnailImage = "$seed-thumbnail.png",
detailImage = "$seed-detail.png",
popupImage = if (isPopup) "$seed-popup.png" else null,
link = null,
title = seed,
isAdult = false,
isPopup = isPopup,
startDate = LocalDateTime.now().minusDays(1),
endDate = LocalDateTime.now().plusDays(1)
)
}
private fun registerMysqlDateFunctions() {
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS DATE_FORMAT FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.dateFormat'"
).executeUpdate()
entityManager.createNativeQuery(
"CREATE ALIAS IF NOT EXISTS CONVERT_TZ FOR 'kr.co.vividnext.sodalive.support.H2MysqlDateFunctions.convertTz'"
).executeUpdate()
}
}
@@ -1,39 +1,75 @@
package kr.co.vividnext.sodalive.event
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.AuthRepository
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
class EventServiceTest {
private val repository = Mockito.mock(EventRepository::class.java)
private val authRepository = Mockito.mock(AuthRepository::class.java)
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val service = EventService(
repository = repository,
s3Uploader = Mockito.mock(S3Uploader::class.java),
authRepository = authRepository,
memberContentPreferenceService = preferenceService,
bucket = "test-bucket",
cloudFrontHost = "https://cdn.test"
)
@Test
fun shouldResolveAdultFlagFromAuthRepositoryForMemberEventList() {
@DisplayName("JP 판정 회원의 이벤트 목록은 JA를 조회한다")
fun shouldQueryJapaneseEventListForJapan() {
val member = member(id = 10L)
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(100L)
Mockito.`when`(repository.getEventList(true)).thenReturn(emptyList())
Mockito.`when`(preferenceService.resolveCountryCode(member)).thenReturn("JP")
Mockito.`when`(repository.getEventList(true, Lang.JA)).thenReturn(emptyList())
service.getEventList(memberId = 10L, memberRole = MemberRole.USER)
service.getEventList(member)
Mockito.verify(repository).getEventList(true)
Mockito.verify(repository).getEventList(true, Lang.JA)
}
@Test
fun shouldKeepAdminEventListUnfiltered() {
Mockito.`when`(repository.getEventList(null)).thenReturn(emptyList())
@DisplayName("비로그인 KR 판정의 이벤트 팝업은 KO를 조회한다")
fun shouldQueryKoreanEventPopupForAnonymousKorea() {
Mockito.`when`(preferenceService.resolveCountryCode(null)).thenReturn("KR")
service.getEventList(memberId = 1L, memberRole = MemberRole.ADMIN)
service.getEventPopup(null as Member?)
Mockito.verify(repository).getEventList(null)
Mockito.verify(repository).getMainEventPopup(false, Lang.KO)
}
@Test
@DisplayName("관리자 이벤트 목록은 성인 조건 없이 판정 언어를 조회한다")
fun shouldKeepAdminAdultFilterNullWhileApplyingResolvedLanguage() {
val admin = member(id = 1L, role = MemberRole.ADMIN)
Mockito.`when`(preferenceService.resolveCountryCode(admin)).thenReturn("KR")
Mockito.`when`(repository.getEventList(null, Lang.KO)).thenReturn(emptyList())
service.getEventList(admin)
Mockito.verify(repository).getEventList(null, Lang.KO)
Mockito.verifyNoInteractions(authRepository)
}
@Test
@DisplayName("성인 제외 이벤트 목록은 언어 없이 조회한다")
fun shouldQueryNonAdultEventListWithoutLanguage() {
Mockito.`when`(repository.getEventList(false, null)).thenReturn(emptyList())
service.getEventList(isAdult = false)
Mockito.verify(repository).getEventList(false, null)
}
private fun member(id: Long, role: MemberRole = MemberRole.USER): Member {
return Member(password = "password", nickname = "member$id", role = role).apply { this.id = id }
}
}