From 107e6de3eb56e271a9d4f7e4654c96fc8b828f0f Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 27 Jun 2026 00:47:10 +0900 Subject: [PATCH] =?UTF-8?q?fix(home-live):=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=A4=91=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EC=B1=85=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sodalive/configs/SecurityConfig.kt | 1 + .../in/web/HomeOnAirLiveControllerTest.kt | 31 +-- .../in/web/HomeOnAirLiveEndToEndTest.kt | 202 ++++++++++++++++++ 3 files changed, 210 insertions(+), 24 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt index b8cb4fc8..99ff4572 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -107,6 +107,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/v2/audio/rankings").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/rankings/creators").permitAll() .antMatchers(HttpMethod.GET, "/api/v2/home/following").permitAll() + .antMatchers(HttpMethod.GET, "/api/v2/home/on-air-lives").authenticated() // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .anyRequest().authenticated() diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt index 44bfcec9..2330d108 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveControllerTest.kt @@ -1,8 +1,12 @@ package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.configs.SecurityConfig import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.jwt.JwtAccessDeniedHandler +import kr.co.vividnext.sodalive.jwt.JwtAuthenticationEntryPoint +import kr.co.vividnext.sodalive.jwt.TokenProvider import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberRole @@ -14,24 +18,17 @@ 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.WebMvcTest -import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Import -import org.springframework.http.HttpStatus -import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user -import org.springframework.security.web.SecurityFilterChain -import org.springframework.security.web.authentication.HttpStatusEntryPoint 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 javax.servlet.http.HttpServletResponse @WebMvcTest(HomeOnAirLiveController::class) -@Import(HomeOnAirLiveControllerTest.TestSecurityConfig::class) +@Import(SecurityConfig::class, JwtAuthenticationEntryPoint::class, JwtAccessDeniedHandler::class) class HomeOnAirLiveControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -47,22 +44,8 @@ class HomeOnAirLiveControllerTest @Autowired constructor( @MockBean private lateinit var sodaMessageSource: SodaMessageSource - @TestConfiguration - class TestSecurityConfig { - @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { - return http - .csrf().disable() - .authorizeRequests() - .anyRequest().authenticated() - .and() - .exceptionHandling() - .authenticationEntryPoint(HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)) - .accessDeniedHandler { _, response, _ -> response.sendError(HttpServletResponse.SC_FORBIDDEN) } - .and() - .build() - } - } + @MockBean + private lateinit var tokenProvider: TokenProvider @Test @DisplayName("현재 진행 중인 라이브 조회는 비회원 요청을 거부한다") diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt new file mode 100644 index 00000000..9704f4ac --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/adapter/in/web/HomeOnAirLiveEndToEndTest.kt @@ -0,0 +1,202 @@ +package kr.co.vividnext.sodalive.v2.api.home.live.adapter.`in`.web + +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.live.room.LiveRoom +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreference +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +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.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.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:home-on-air-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class HomeOnAirLiveEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("현재 진행 중인 라이브 조회 API는 인증 회원에게 최신순 라이브와 상세 필드를 반환한다") + fun shouldReturnAuthenticatedOnAirLivesWithTitlePriceAndBeginDateTimeUtc() { + val fixture = createOnAirLivesFixture() + + mockMvc.perform( + get("/api/v2/home/on-air-lives") + .param("page", "0") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.items.length()").value(2)) + .andExpect(jsonPath("$.data.items[0].roomId").value(fixture.newestLiveId)) + .andExpect(jsonPath("$.data.items[0].creatorNickname").value("on-air-e2e-creator")) + .andExpect(jsonPath("$.data.items[0].creatorProfileImage").value("https://cdn.test/on-air-e2e-creator.png")) + .andExpect(jsonPath("$.data.items[0].title").value("newest on air live")) + .andExpect(jsonPath("$.data.items[0].price").value(30)) + .andExpect(jsonPath("$.data.items[0].beginDateTimeUtc").value("2026-06-26T12:30:00Z")) + .andExpect(jsonPath("$.data.items[1].roomId").value(fixture.oldestLiveId)) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + @Test + @DisplayName("현재 진행 중인 라이브 조회 API는 성인 콘텐츠를 볼 수 없는 회원에게 성인 라이브를 제외한다") + fun shouldExcludeAdultLiveWhenViewerCannotViewAdultContent() { + val fixture = createAdultFilterFixture() + + mockMvc.perform( + get("/api/v2/home/on-air-lives") + .param("page", "0") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].roomId").value(fixture.visibleLiveId)) + .andExpect(jsonPath("$.data.items[?(@.roomId == ${fixture.adultLiveId})]").isEmpty) + } + + private fun createOnAirLivesFixture(): OnAirLivesFixture { + return transactionTemplate.execute { + val viewer = saveMember("on-air-e2e-viewer", MemberRole.USER) + val creator = saveMember("on-air-e2e-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = true) + val newest = saveLiveRoom( + creator = creator, + title = "newest on air live", + price = 30, + beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30), + channelName = "newest-on-air-channel", + isAdult = false + ) + val oldest = saveLiveRoom( + creator = creator, + title = "oldest on air live", + price = 10, + beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 0), + channelName = "oldest-on-air-channel", + isAdult = false + ) + entityManager.flush() + entityManager.clear() + + OnAirLivesFixture( + viewer = viewer, + newestLiveId = newest.id!!, + oldestLiveId = oldest.id!! + ) + }!! + } + + private fun createAdultFilterFixture(): AdultFilterFixture { + return transactionTemplate.execute { + val viewer = saveMember("on-air-adult-filter-viewer", MemberRole.USER) + val creator = saveMember("on-air-adult-filter-creator", MemberRole.CREATOR) + savePreference(viewer, isAdultContentVisible = false) + val adult = saveLiveRoom( + creator = creator, + title = "adult on air live", + price = 30, + beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30), + channelName = "adult-on-air-channel", + isAdult = true + ) + val visible = saveLiveRoom( + creator = creator, + title = "visible on air live", + price = 10, + beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 0), + channelName = "visible-on-air-channel", + isAdult = false + ) + entityManager.flush() + entityManager.clear() + + AdultFilterFixture( + viewer = viewer, + visibleLiveId = visible.id!!, + adultLiveId = adult.id!! + ) + }!! + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = "$nickname.png", + role = role + ) + entityManager.persist(member) + return member + } + + private fun savePreference(member: Member, isAdultContentVisible: Boolean): MemberContentPreference { + val preference = MemberContentPreference( + isAdultContentVisible = isAdultContentVisible, + contentType = ContentType.ALL + ) + preference.member = member + entityManager.persist(preference) + return preference + } + + private fun saveLiveRoom( + creator: Member, + title: String, + price: Int, + beginDateTime: LocalDateTime, + channelName: String, + isAdult: Boolean + ): LiveRoom { + val liveRoom = LiveRoom( + title = title, + notice = "notice", + beginDateTime = beginDateTime, + numberOfPeople = 0, + isAdult = isAdult, + price = price + ) + liveRoom.member = creator + liveRoom.channelName = channelName + liveRoom.isActive = true + entityManager.persist(liveRoom) + return liveRoom + } + + private data class OnAirLivesFixture( + val viewer: Member, + val newestLiveId: Long, + val oldestLiveId: Long + ) + + private data class AdultFilterFixture( + val viewer: Member, + val visibleLiveId: Long, + val adultLiveId: Long + ) +}