From cbcd87875ca86a7d3a7a1f1b4de70c004ffd2445 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 25 Jun 2026 22:14:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(home-following):=20=ED=8C=94=EB=A1=9C?= =?UTF-8?q?=EC=9E=89=20=ED=83=AD=20=EA=B3=B5=EA=B0=9C=20endpoint=EB=A5=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=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 + .../adapter/in/web/HomeFollowingController.kt | 22 ++++ .../application/HomeFollowingFacade.kt | 23 ++++ .../in/web/HomeFollowingControllerTest.kt | 103 ++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.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 32e36d60..b8cb4fc8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt @@ -106,6 +106,7 @@ class SecurityConfig( .antMatchers(HttpMethod.GET, "/api/v2/audio/contents").permitAll() .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() // 페이지네이션 하위 경로(/lives, /debut-creators 등)는 인증 필수 .antMatchers(HttpMethod.GET, "/api/v2/home/recommendations/**").authenticated() .anyRequest().authenticated() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt new file mode 100644 index 00000000..f77ad8a1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingController.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/v2/home/following") +class HomeFollowingController( + private val facade: HomeFollowingFacade +) { + @GetMapping + fun getFollowingTab( + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok(facade.getFollowingTab(member)) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt new file mode 100644 index 00000000..bbef9bb9 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse +import org.springframework.stereotype.Component + +@Component +class HomeFollowingFacade { + fun getFollowingTab(member: Member?): HomeFollowingTabResponse { + if (member == null) { + return HomeFollowingTabResponse.loginRequired() + } + + return HomeFollowingTabResponse( + isLoginRequired = false, + followingCreators = emptyList(), + onAirLives = emptyList(), + recentChats = emptyList(), + monthlySchedules = emptyList(), + recentNews = emptyList() + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt new file mode 100644 index 00000000..853d8231 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingControllerTest.kt @@ -0,0 +1,103 @@ +package kr.co.vividnext.sodalive.v2.api.home.following.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 +import kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacade +import kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponse +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.WebMvcTest +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.context.annotation.Import +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +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 + +@WebMvcTest(HomeFollowingController::class) +@Import(SecurityConfig::class) +class HomeFollowingControllerTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var facade: HomeFollowingFacade + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var langContext: LangContext + + @MockBean + private lateinit var sodaMessageSource: SodaMessageSource + + @MockBean + private lateinit var tokenProvider: TokenProvider + + @MockBean + private lateinit var accessDeniedHandler: JwtAccessDeniedHandler + + @MockBean + private lateinit var authenticationEntryPoint: JwtAuthenticationEntryPoint + + @Test + @DisplayName("팔로잉 탭 조회는 비회원에게 200 OK와 로그인 필요 응답을 반환한다") + fun shouldReturnLoginRequiredForAnonymous() { + Mockito.doReturn(HomeFollowingTabResponse.loginRequired()).`when`(facade).getFollowingTab(null) + + mockMvc.perform(get("/api/v2/home/following")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.isLoginRequired").value(true)) + .andExpect(jsonPath("$.data.followingCreators").isArray) + .andExpect(jsonPath("$.data.onAirLives").isArray) + .andExpect(jsonPath("$.data.recentChats").isArray) + .andExpect(jsonPath("$.data.monthlySchedules").isArray) + .andExpect(jsonPath("$.data.recentNews").isArray) + } + + @Test + @DisplayName("팔로잉 탭 조회는 인증 회원을 facade에 전달하고 로그인 불필요 응답을 반환한다") + fun shouldPassAuthenticatedMemberToFacade() { + val member = Member( + email = "viewer@test.com", + password = "password", + nickname = "viewer", + role = MemberRole.USER + ).apply { id = 10L } + Mockito.doReturn(loggedInEmptyResponse()).`when`(facade).getFollowingTab(eqValue(member)) + + mockMvc.perform(get("/api/v2/home/following").with(user(MemberAdapter(member)))) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.isLoginRequired").value(false)) + + Mockito.verify(facade).getFollowingTab(eqValue(member)) + } + + private fun loggedInEmptyResponse(): HomeFollowingTabResponse { + return HomeFollowingTabResponse( + isLoginRequired = false, + followingCreators = emptyList(), + onAirLives = emptyList(), + recentChats = emptyList(), + monthlySchedules = emptyList(), + recentNews = emptyList() + ) + } + + private fun eqValue(value: T): T { + return Mockito.eq(value) ?: value + } +}