diff --git a/docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql b/docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql new file mode 100644 index 00000000..421c3690 --- /dev/null +++ b/docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql @@ -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; diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt index 5c45ef2c..5d6ace52 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt @@ -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), "등록되었습니다." ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt index bdc750d6..87825008 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt @@ -33,6 +33,7 @@ class AdminEventBannerQueryRepositoryImpl( QGetAdminEventResponse( event.id, event.title, + event.lang, getProcessedUrlExpression(event.thumbnailImage), getProcessedUrlExpression(event.detailImage), getProcessedUrlExpression(event.popupImage), diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt index 7aa7bacb..250fc4bd 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt index 23b10b6e..efdc7851 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt index 83f8e602..850f0319 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt @@ -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) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt index 6b30874a..3b950b49 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt @@ -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')") diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt index 2a0df4b1..b232add0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt @@ -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, EventQueryRepository interface EventQueryRepository { - fun getEventList(isAdult: Boolean? = null): List - fun getMainEventPopup(isAdult: Boolean): EventItem? + fun getEventList(isAdult: Boolean? = null, lang: Lang? = null): List + fun getMainEventPopup(isAdult: Boolean, lang: Lang? = null): EventItem? } @Repository class EventQueryRepositoryImpl(private val queryFactory: JPAQueryFactory) : EventQueryRepository { - override fun getEventList(isAdult: Boolean?): List { + override fun getEventList(isAdult: Boolean?, lang: Lang?): List { 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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt index 94b25278..ff4ea05f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt @@ -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, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt new file mode 100644 index 00000000..154b545e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt @@ -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'" + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt new file mode 100644 index 00000000..98f20c63 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt @@ -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) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt new file mode 100644 index 00000000..2bba179a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt @@ -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() + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt index 6e9d4feb..6f736a4a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt @@ -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 } + } }