feat(event): 접속 국가별 이벤트 언어 필터를 적용한다
This commit is contained in:
41
docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql
Normal file
41
docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @lang_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'event'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @add_lang_column_sql := IF(
|
||||
@lang_column_exists = 0,
|
||||
'ALTER TABLE `event` ADD COLUMN lang VARCHAR(10) NULL COMMENT ''이벤트 노출 언어'' AFTER title',
|
||||
'SELECT ''event.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 `event`
|
||||
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 = 'event'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @alter_lang_column_sql := IF(
|
||||
@lang_column_nullable = 'YES',
|
||||
'ALTER TABLE `event` MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''이벤트 노출 언어 (KO 기본, EN/JA 등록 가능)''',
|
||||
'SELECT ''event.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;
|
||||
@@ -26,9 +26,10 @@ class AdminEventBannerController(private val service: AdminEventBannerService) {
|
||||
@RequestParam(value = "isAdult", required = false) isAdult: Boolean? = null,
|
||||
@RequestParam(value = "isPopup") isPopup: Boolean,
|
||||
@RequestParam(value = "startDate") startDate: String,
|
||||
@RequestParam(value = "endDate") endDate: String
|
||||
@RequestParam(value = "endDate") endDate: String,
|
||||
@RequestParam("lang") lang: String
|
||||
) = ApiResponse.ok(
|
||||
service.save(thumbnail, detail, popup, link, title, isAdult, isPopup, startDate, endDate),
|
||||
service.save(thumbnail, detail, popup, link, title, isAdult, isPopup, startDate, endDate, lang),
|
||||
"등록되었습니다."
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class AdminEventBannerQueryRepositoryImpl(
|
||||
QGetAdminEventResponse(
|
||||
event.id,
|
||||
event.title,
|
||||
event.lang,
|
||||
getProcessedUrlExpression(event.thumbnailImage),
|
||||
getProcessedUrlExpression(event.detailImage),
|
||||
getProcessedUrlExpression(event.popupImage),
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.event.Event
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -32,12 +33,19 @@ class AdminEventBannerService(
|
||||
isAdult: Boolean? = null,
|
||||
isPopup: Boolean,
|
||||
startDateString: String,
|
||||
endDateString: String
|
||||
endDateString: String,
|
||||
lang: String
|
||||
): Long {
|
||||
if (detail == null && link.isNullOrBlank()) {
|
||||
throw SodaException(messageKey = "admin.event.banner.detail_or_link_required")
|
||||
}
|
||||
|
||||
val eventLang = try {
|
||||
Lang.fromCode(lang)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
|
||||
val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val startDate = LocalDateTime.parse(startDateString, dateTimeFormatter)
|
||||
.atZone(ZoneId.of("Asia/Seoul"))
|
||||
@@ -56,6 +64,7 @@ class AdminEventBannerService(
|
||||
popupImage = null,
|
||||
link = link,
|
||||
title = title,
|
||||
lang = eventLang,
|
||||
isAdult = isAdult,
|
||||
isPopup = isPopup,
|
||||
startDate = startDate,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.admin.event.banner
|
||||
|
||||
import com.querydsl.core.annotations.QueryProjection
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
|
||||
data class GetAdminEventResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val lang: Lang,
|
||||
val thumbnailImageUrl: String,
|
||||
val detailImageUrl: String? = null,
|
||||
val popupImageUrl: String? = null,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package kr.co.vividnext.sodalive.event
|
||||
|
||||
import kr.co.vividnext.sodalive.common.BaseEntity
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.Column
|
||||
import javax.persistence.Entity
|
||||
import javax.persistence.EnumType
|
||||
import javax.persistence.Enumerated
|
||||
|
||||
@Entity
|
||||
data class Event(
|
||||
@@ -17,6 +20,9 @@ data class Event(
|
||||
var link: String?,
|
||||
@Column(nullable = true)
|
||||
var title: String?,
|
||||
@Column(nullable = false)
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var lang: Lang = Lang.KO,
|
||||
@Column(nullable = true)
|
||||
var isAdult: Boolean? = null,
|
||||
@Column(nullable = false)
|
||||
|
||||
@@ -22,17 +22,14 @@ class EventController(private val service: EventService) {
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = run {
|
||||
ApiResponse.ok(
|
||||
service.getEventList(
|
||||
memberId = member?.id,
|
||||
memberRole = member?.role
|
||||
)
|
||||
service.getEventList(member = member)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/popup")
|
||||
fun getEventPopup(
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = ApiResponse.ok(service.getEventPopup(memberId = member?.id))
|
||||
) = ApiResponse.ok(service.getEventPopup(member = member))
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
|
||||
@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.event
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.event.QEvent.event
|
||||
import kr.co.vividnext.sodalive.i18n.Lang
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
@@ -10,13 +11,13 @@ import java.time.LocalDateTime
|
||||
interface EventRepository : JpaRepository<Event, Long>, EventQueryRepository
|
||||
|
||||
interface EventQueryRepository {
|
||||
fun getEventList(isAdult: Boolean? = null): List<EventItem>
|
||||
fun getMainEventPopup(isAdult: Boolean): EventItem?
|
||||
fun getEventList(isAdult: Boolean? = null, lang: Lang? = null): List<EventItem>
|
||||
fun getMainEventPopup(isAdult: Boolean, lang: Lang? = null): EventItem?
|
||||
}
|
||||
|
||||
@Repository
|
||||
class EventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : EventQueryRepository {
|
||||
override fun getEventList(isAdult: Boolean?): List<EventItem> {
|
||||
override fun getEventList(isAdult: Boolean?, lang: Lang?): List<EventItem> {
|
||||
val now = LocalDateTime.now()
|
||||
var where = event.isActive.isTrue
|
||||
.and(event.startDate.loe(now))
|
||||
@@ -36,6 +37,10 @@ class EventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Even
|
||||
}
|
||||
}
|
||||
|
||||
if (lang != null) {
|
||||
where = where.and(event.lang.eq(lang))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
QEventItem(
|
||||
@@ -52,7 +57,7 @@ class EventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Even
|
||||
.fetch()
|
||||
}
|
||||
|
||||
override fun getMainEventPopup(isAdult: Boolean): EventItem? {
|
||||
override fun getMainEventPopup(isAdult: Boolean, lang: Lang?): EventItem? {
|
||||
val now = LocalDateTime.now()
|
||||
var where = event.isActive.isTrue
|
||||
.and(event.isPopup.isTrue)
|
||||
@@ -71,6 +76,9 @@ class EventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : Even
|
||||
.or(event.isAdult.isFalse)
|
||||
)
|
||||
}
|
||||
if (lang != null) {
|
||||
where = where.and(event.lang.eq(lang))
|
||||
}
|
||||
|
||||
return queryFactory
|
||||
.select(
|
||||
|
||||
@@ -3,8 +3,11 @@ package kr.co.vividnext.sodalive.event
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata
|
||||
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.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.utils.generateFileName
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
@@ -20,6 +23,7 @@ class EventService(
|
||||
private val repository: EventRepository,
|
||||
private val s3Uploader: S3Uploader,
|
||||
private val authRepository: AuthRepository,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
|
||||
@Value("\${cloud.aws.s3.bucket}")
|
||||
private val bucket: String,
|
||||
@@ -27,8 +31,8 @@ class EventService(
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventList(isAdult: Boolean? = null): GetEventResponse {
|
||||
val eventList = repository.getEventList(isAdult)
|
||||
fun getEventList(isAdult: Boolean? = null, lang: Lang? = null): GetEventResponse {
|
||||
val eventList = repository.getEventList(isAdult, lang)
|
||||
.map {
|
||||
if (!it.thumbnailImageUrl.startsWith("https://")) {
|
||||
it.thumbnailImageUrl = "$cloudFrontHost/${it.thumbnailImageUrl}"
|
||||
@@ -49,18 +53,18 @@ class EventService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventList(memberId: Long?, memberRole: MemberRole?): GetEventResponse {
|
||||
val isAdult = if (memberRole == MemberRole.ADMIN) {
|
||||
fun getEventList(member: Member?): GetEventResponse {
|
||||
val isAdult = if (member?.role == MemberRole.ADMIN) {
|
||||
null
|
||||
} else {
|
||||
memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
}
|
||||
return getEventList(isAdult)
|
||||
return getEventList(isAdult = isAdult, lang = resolveEventLang(member))
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventPopup(isAdult: Boolean): EventItem? {
|
||||
val eventPopup = repository.getMainEventPopup(isAdult)
|
||||
fun getEventPopup(isAdult: Boolean, lang: Lang? = null): EventItem? {
|
||||
val eventPopup = repository.getMainEventPopup(isAdult, lang)
|
||||
|
||||
if (eventPopup != null) {
|
||||
if (!eventPopup.thumbnailImageUrl.startsWith("https://")) {
|
||||
@@ -80,11 +84,14 @@ class EventService(
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventPopup(memberId: Long?): EventItem? {
|
||||
val isAdult = memberId?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
return getEventPopup(isAdult)
|
||||
fun getEventPopup(member: Member?): EventItem? {
|
||||
val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
return getEventPopup(isAdult = isAdult, lang = resolveEventLang(member))
|
||||
}
|
||||
|
||||
private fun resolveEventLang(member: Member?): Lang =
|
||||
if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||
|
||||
@Transactional
|
||||
fun save(
|
||||
thumbnail: MultipartFile,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user