feat(event): v2 일반 이벤트 상세 조회를 추가한다
This commit is contained in:
@@ -188,6 +188,7 @@ class SecurityConfig(
|
||||
.antMatchers("/live/recommend/channel").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/live/room").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/event").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/api/v2/events/*").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/live/recommend").permitAll()
|
||||
.antMatchers("/ad-tracking/app-launch").permitAll()
|
||||
.antMatchers(HttpMethod.GET, "/notice/latest").permitAll()
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.event.adapter.`in`.web
|
||||
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.v2.api.event.application.EventDetailService
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/events")
|
||||
class EventDetailController(private val service: EventDetailService) {
|
||||
@GetMapping("/{id}")
|
||||
fun getEvent(
|
||||
@PathVariable id: Long,
|
||||
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member?
|
||||
) = ApiResponse.ok(service.getEvent(id, member))
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.event.application
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.event.EventItem
|
||||
import kr.co.vividnext.sodalive.event.EventRepository
|
||||
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.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class EventDetailService(
|
||||
private val repository: EventRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val memberContentPreferenceService: MemberContentPreferenceService,
|
||||
@Value("\${cloud.aws.cloud-front.host}")
|
||||
private val cloudFrontHost: String
|
||||
) {
|
||||
@Transactional(readOnly = true)
|
||||
fun getEvent(id: Long, member: Member?): EventItem {
|
||||
if (id <= 0) throw SodaException(messageKey = "common.error.invalid_request")
|
||||
|
||||
val event = repository.findByIdOrNull(id)
|
||||
?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
val now = LocalDateTime.now()
|
||||
val lang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||
|
||||
if (!event.isActive || event.startDate > now || event.endDate < now || event.lang != lang) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
|
||||
if (member?.role != MemberRole.ADMIN) {
|
||||
val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
if (event.isAdult != null && event.isAdult != isAdult) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
|
||||
return EventItem(
|
||||
id = id,
|
||||
thumbnailImageUrl = imageUrl(event.thumbnailImage),
|
||||
detailImageUrl = event.detailImage?.let { imageUrl(it) },
|
||||
popupImageUrl = event.popupImage?.let { imageUrl(it) },
|
||||
link = event.link
|
||||
)
|
||||
}
|
||||
|
||||
private fun imageUrl(image: String): String = if (image.startsWith("https://")) image else "$cloudFrontHost/$image"
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package kr.co.vividnext.sodalive.v2.api.event
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import kr.co.vividnext.sodalive.aws.s3.S3Uploader
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.event.Event
|
||||
import kr.co.vividnext.sodalive.event.EventRepository
|
||||
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.member.MemberRepository
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||
import kr.co.vividnext.sodalive.member.auth.AuthRepository
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
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.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.CsvSource
|
||||
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.boot.test.web.client.TestRestTemplate
|
||||
import org.springframework.http.HttpEntity
|
||||
import org.springframework.http.HttpHeaders
|
||||
import org.springframework.http.HttpMethod
|
||||
import org.springframework.http.HttpStatus
|
||||
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.MvcResult
|
||||
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = [
|
||||
"cloud.aws.cloud-front.host=https://cdn.test",
|
||||
"cloud.aws.s3.bucket=test-bucket",
|
||||
"spring.datasource.url=jdbc:h2:mem:event-detail-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 EventDetailControllerIntegrationTest @Autowired constructor(
|
||||
private val restTemplate: TestRestTemplate,
|
||||
private val mockMvc: MockMvc,
|
||||
private val eventRepository: EventRepository,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val objectMapper: ObjectMapper
|
||||
) {
|
||||
@MockBean
|
||||
private lateinit var s3Uploader: S3Uploader
|
||||
|
||||
@BeforeEach
|
||||
@AfterEach
|
||||
fun cleanUp() {
|
||||
eventRepository.deleteAllInBatch()
|
||||
authRepository.deleteAllInBatch()
|
||||
memberRepository.deleteAllInBatch()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("익명 상세는 기존 목록과 같은 5개 필드와 외부 링크를 반환하고 엔티티를 변경하지 않는다")
|
||||
fun shouldReturnAnonymousEventDetailWithLegacyFieldsWithoutMutatingTheEntity() {
|
||||
val event = saveEvent(
|
||||
activeEvent().apply {
|
||||
detailImage = null
|
||||
popupImage = null
|
||||
link = "https://external.test/promotion?from=soda&target=event#detail"
|
||||
}
|
||||
)
|
||||
|
||||
val detail = httpGet("/api/v2/events/${event.id}")
|
||||
|
||||
assertEquals(
|
||||
objectMapper.readTree(
|
||||
"""{"success":true,"message":null,"data":{
|
||||
"id":${event.id},"thumbnailImageUrl":"https://cdn.test/thumbnail.png",
|
||||
"detailImageUrl":null,"popupImageUrl":null,
|
||||
"link":"https://external.test/promotion?from=soda&target=event#detail"
|
||||
},"errorProperty":null}"""
|
||||
),
|
||||
detail
|
||||
)
|
||||
assertEquals(httpGet("/event")["data"]["eventList"].single(), detail["data"])
|
||||
val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow()
|
||||
assertEquals("thumbnail.png", persisted.thumbnailImage)
|
||||
assertEquals(null, persisted.detailImage)
|
||||
assertEquals(null, persisted.popupImage)
|
||||
assertEquals(event.link, persisted.link)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"https://images.test/image.png, https://images.test/image.png",
|
||||
"relative/image.png, https://cdn.test/relative/image.png",
|
||||
"http://images.test/image.png, https://cdn.test/http://images.test/image.png"
|
||||
)
|
||||
@DisplayName("세 이미지 필드는 HTTPS만 보존하고 상대 경로와 HTTP는 기존 CDN 규칙을 따른다")
|
||||
fun shouldMapAllImageFieldsLikeTheLegacyList(image: String, expectedImage: String) {
|
||||
val event = saveEvent(
|
||||
activeEvent().apply {
|
||||
thumbnailImage = image
|
||||
detailImage = image
|
||||
popupImage = image
|
||||
link = null
|
||||
}
|
||||
)
|
||||
|
||||
val detail = httpGet("/api/v2/events/${event.id}")["data"]
|
||||
|
||||
assertEquals(5, detail.size())
|
||||
assertEquals(event.id, detail["id"].asLong())
|
||||
assertEquals(expectedImage, detail["thumbnailImageUrl"].asText())
|
||||
assertEquals(expectedImage, detail["detailImageUrl"].asText())
|
||||
assertEquals(expectedImage, detail["popupImageUrl"].asText())
|
||||
assertEquals(true, detail["link"].isNull)
|
||||
assertEquals(httpGet("/event")["data"]["eventList"].single(), detail)
|
||||
val persisted = eventRepository.findById(requireNotNull(event.id)).orElseThrow()
|
||||
assertEquals(image, persisted.thumbnailImage)
|
||||
assertEquals(image, persisted.detailImage)
|
||||
assertEquals(image, persisted.popupImage)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"DEFAULT, KO",
|
||||
"KR, KO",
|
||||
"JP, JA",
|
||||
"US, KO",
|
||||
nullValues = ["DEFAULT"]
|
||||
)
|
||||
@DisplayName("익명 상세의 국가별 언어와 비노출 응답은 기존 목록과 일치한다")
|
||||
fun shouldApplyTheSameCountryLanguageAsTheLegacyList(country: String?, expectedLang: Lang) {
|
||||
val events = listOf(Lang.KO, Lang.JA, Lang.EN).map { lang ->
|
||||
saveEvent(activeEvent().apply { this.lang = lang })
|
||||
}
|
||||
val expected = events.single { it.lang == expectedLang }
|
||||
val legacyItems = httpGet("/event", country)["data"]["eventList"]
|
||||
assertEquals(listOf(expected.id), legacyItems.map { it["id"].asLong() })
|
||||
|
||||
events.forEach { event ->
|
||||
val detail = httpGet("/api/v2/events/${event.id}", country)
|
||||
if (event.id == expected.id) {
|
||||
assertEquals(true, detail["success"].asBoolean())
|
||||
assertEquals(legacyItems.single(), detail["data"])
|
||||
} else {
|
||||
assertInvalidRequest(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("성인 null false true의 익명 미인증 인증 관리자 노출은 KO와 JA 모두 기존 목록과 같다")
|
||||
fun shouldMatchTheLegacyAdultMatrixForAnonymousUnverifiedVerifiedAndAdminViewers() {
|
||||
val unverified = saveMember("unverified")
|
||||
val verified = saveMember("verified", verified = true)
|
||||
val admin = saveMember("admin", role = MemberRole.ADMIN)
|
||||
assertEquals(null, authRepository.getAuthIdByMemberId(requireNotNull(unverified.id)))
|
||||
assertEquals(1L, authRepository.count())
|
||||
|
||||
listOf("KR" to Lang.KO, "JP" to Lang.JA).forEach { (country, lang) ->
|
||||
val general = saveEvent(activeEvent().apply { this.lang = lang })
|
||||
val nonAdult = saveEvent(
|
||||
activeEvent().apply {
|
||||
this.lang = lang
|
||||
isAdult = false
|
||||
}
|
||||
)
|
||||
val adult = saveEvent(
|
||||
activeEvent().apply {
|
||||
this.lang = lang
|
||||
isAdult = true
|
||||
}
|
||||
)
|
||||
val events = listOf(general, nonAdult, adult)
|
||||
val viewers = listOf(
|
||||
null to setOf(general.id, nonAdult.id),
|
||||
unverified to setOf(general.id, nonAdult.id),
|
||||
verified to setOf(general.id, adult.id),
|
||||
admin to setOf(general.id, nonAdult.id, adult.id)
|
||||
)
|
||||
|
||||
viewers.forEach { (member, expectedIds) ->
|
||||
val legacyItems = viewerGet("/event", member, country)["data"]["eventList"]
|
||||
assertEquals(expectedIds, legacyItems.map { it["id"].asLong() }.toSet())
|
||||
events.forEach { event ->
|
||||
val detail = viewerGet("/api/v2/events/${event.id}", member, country)
|
||||
if (event.id in expectedIds) {
|
||||
assertEquals(true, detail["success"].asBoolean())
|
||||
assertEquals(legacyItems.single { it["id"].asLong() == event.id }, detail["data"])
|
||||
} else {
|
||||
assertInvalidRequest(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("관리자도 활성 기간 언어 조건을 우회하지 못하고 비노출 상세는 미존재와 같다")
|
||||
fun shouldRejectAllHiddenStatesEvenForAdminWhileMatchingTheLegacyList() {
|
||||
val visible = saveEvent(activeEvent())
|
||||
val hidden = listOf(
|
||||
activeEvent().apply { isActive = false },
|
||||
activeEvent().apply {
|
||||
startDate = LocalDateTime.now().plusDays(1)
|
||||
endDate = startDate.plusDays(1)
|
||||
},
|
||||
activeEvent().apply {
|
||||
endDate = LocalDateTime.now().minusDays(1)
|
||||
startDate = endDate.minusDays(1)
|
||||
},
|
||||
activeEvent().apply { lang = Lang.JA },
|
||||
activeEvent().apply { lang = Lang.EN }
|
||||
).map { saveEvent(it) }
|
||||
val unverified = saveMember("hidden-unverified")
|
||||
val verified = saveMember("hidden-verified", verified = true)
|
||||
val admin = saveMember("hidden-admin", role = MemberRole.ADMIN)
|
||||
val missing = httpGet("/api/v2/events/${Long.MAX_VALUE}")
|
||||
assertInvalidRequest(missing)
|
||||
|
||||
listOf(null, unverified, verified, admin).forEach { member ->
|
||||
val legacyItems = viewerGet("/event", member)["data"]["eventList"]
|
||||
assertEquals(listOf(visible.id), legacyItems.map { it["id"].asLong() })
|
||||
hidden.forEach { event ->
|
||||
assertEquals(missing, viewerGet("/api/v2/events/${event.id}", member))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource("0", "-1", "9223372036854775807")
|
||||
@DisplayName("양수가 아니거나 미존재인 ID는 HTTP 200 invalid_request 실패 응답을 반환한다")
|
||||
fun shouldRejectNonpositiveAndMissingIdsWithTheCommonInvalidRequest(id: String) {
|
||||
assertInvalidRequest(httpGet("/api/v2/events/$id"))
|
||||
val result = mockGet("/api/v2/events/$id")
|
||||
assertEquals("common.error.invalid_request", (result.resolvedException as SodaException).messageKey)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource("not-a-number", "9223372036854775808")
|
||||
@DisplayName("숫자가 아니거나 Long 범위를 넘는 ID는 기존 바인딩과 공통 예외 응답을 유지한다")
|
||||
fun shouldKeepTheGlobalBindingErrorResponseForMalformedIds(id: String) {
|
||||
val response = httpGet("/api/v2/events/$id")
|
||||
assertEquals(
|
||||
objectMapper.readTree(
|
||||
"""{"success":false,"message":"알 수 없는 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||
"data":null,"errorProperty":null}"""
|
||||
),
|
||||
response
|
||||
)
|
||||
assertEquals(
|
||||
MethodArgumentTypeMismatchException::class.java,
|
||||
mockGet("/api/v2/events/$id").resolvedException?.javaClass
|
||||
)
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(
|
||||
"POST, /api/v2/events/1", "PUT, /api/v2/events/1", "DELETE, /api/v2/events/1",
|
||||
"GET, /api/v2/events", "POST, /api/v2/events", "GET, /api/v2/events/1/extra",
|
||||
"GET, /api/v2/events/1/extra/deeper", "GET, /event/popup", "GET, /admin/event/banner",
|
||||
"POST, /event", "PUT, /event", "DELETE, /event/1", "GET, /admin/event/banner/1"
|
||||
)
|
||||
@DisplayName("익명 쓰기 collection 하위 경로 기존 팝업 관리자 API는 계속 인증이 필요하다")
|
||||
fun shouldKeepAnonymousRequestsOutsideTheDetailGetProtected(method: HttpMethod, path: String) {
|
||||
val response = restTemplate.exchange(path, method, HttpEntity.EMPTY, String::class.java)
|
||||
println("HTTP $method $path -> ${response.statusCodeValue} ${response.body}")
|
||||
assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode)
|
||||
}
|
||||
|
||||
private fun httpGet(path: String, country: String? = null): JsonNode {
|
||||
val headers = HttpHeaders().apply {
|
||||
country?.let { set("CloudFront-Viewer-Country", it) }
|
||||
}
|
||||
val response = restTemplate.exchange(path, HttpMethod.GET, HttpEntity<Void>(headers), String::class.java)
|
||||
println("HTTP GET $path country=$country -> ${response.statusCodeValue} ${response.body}")
|
||||
assertEquals(HttpStatus.OK, response.statusCode)
|
||||
return objectMapper.readTree(response.body)
|
||||
}
|
||||
|
||||
private fun viewerGet(path: String, member: Member?, country: String? = null): JsonNode {
|
||||
if (member == null) return httpGet(path, country)
|
||||
return objectMapper.readTree(mockGet(path, member, country).response.contentAsByteArray)
|
||||
}
|
||||
|
||||
private fun mockGet(path: String, member: Member? = null, country: String? = null): MvcResult {
|
||||
val request = get(path).apply {
|
||||
country?.let { header("CloudFront-Viewer-Country", it) }
|
||||
member?.let {
|
||||
val adapter = MemberAdapter(it)
|
||||
with(authentication(UsernamePasswordAuthenticationToken(adapter, null, adapter.authorities)))
|
||||
}
|
||||
}
|
||||
val result = mockMvc.perform(request).andReturn()
|
||||
println(
|
||||
"MockMvc GET $path role=${member?.role} country=$country -> ${result.response.status} " +
|
||||
result.response.getContentAsString(Charsets.UTF_8)
|
||||
)
|
||||
assertEquals(200, result.response.status)
|
||||
return result
|
||||
}
|
||||
|
||||
private fun assertInvalidRequest(response: JsonNode) {
|
||||
assertEquals(
|
||||
objectMapper.readTree(
|
||||
"""{"success":false,"message":"잘못된 요청입니다.","data":null,"errorProperty":null}"""
|
||||
),
|
||||
response
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveMember(seed: String, role: MemberRole = MemberRole.USER, verified: Boolean = false): Member {
|
||||
val member = memberRepository.saveAndFlush(Member(password = "test-password", nickname = seed, role = role))
|
||||
if (verified) {
|
||||
val auth = authRepository.saveAndFlush(
|
||||
Auth("test-member", "19900101", "test-ci-$seed", "test-di-$seed", 1).apply { this.member = member }
|
||||
)
|
||||
assertEquals(auth.id, authRepository.getAuthIdByMemberId(requireNotNull(member.id)))
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveEvent(event: Event): Event = eventRepository.saveAndFlush(event)
|
||||
|
||||
private fun activeEvent(): Event = Event(
|
||||
thumbnailImage = "thumbnail.png",
|
||||
detailImage = "detail.png",
|
||||
popupImage = "popup.png",
|
||||
link = "https://external.test/event",
|
||||
title = "not-returned-title",
|
||||
isAdult = null,
|
||||
isPopup = false,
|
||||
startDate = LocalDateTime.now().minusDays(1),
|
||||
endDate = LocalDateTime.now().plusDays(1)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user