From 7e9e0aa3201c53d10e6776251a31ea1b0812784b Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 18:00:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=ED=9B=84=EC=9B=90=20?= =?UTF-8?q?=ED=83=AD=20endpoint=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/CreatorChannelDonationController.kt | 41 ++++ .../CreatorChannelDonationControllerTest.kt | 185 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt new file mode 100644 index 00000000..42348e08 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt @@ -0,0 +1,41 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true") +@RequestMapping("/api/v2/creator-channels") +class CreatorChannelDonationController( + private val creatorChannelDonationFacade: CreatorChannelDonationFacade +) { + @GetMapping("/{creatorId}/donations") + fun getDonationTab( + @PathVariable creatorId: Long, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelDonationFacade.getDonationTab( + creatorId = creatorId, + viewer = requireMember(member), + page = page, + size = size + ) + ) + } + + private fun requireMember(member: Member?): Member { + return member ?: throw SodaException(messageKey = "common.error.bad_credentials") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt new file mode 100644 index 00000000..124a8ea5 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt @@ -0,0 +1,185 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.CountryContext +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +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.creator.channel.donation.application.CreatorChannelDonationFacade +import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.MemberDonationRankingResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +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.autoconfigure.condition.ConditionalOnProperty +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.context.TestPropertySource +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 +import javax.servlet.http.HttpServletResponse + +@WebMvcTest(CreatorChannelDonationController::class) +@Import(CreatorChannelDonationControllerTest.TestSecurityConfig::class) +@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"]) +class CreatorChannelDonationControllerTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var facade: CreatorChannelDonationFacade + + @MockBean + private lateinit var countryContext: CountryContext + + @MockBean + private lateinit var langContext: LangContext + + @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() + } + } + + @Test + @DisplayName("크리에이터 채널 후원 탭 controller는 Phase 2 완료 전 기본 등록되지 않도록 property로 보호된다") + fun shouldProtectDonationControllerWithFeatureProperty() { + val condition = CreatorChannelDonationController::class.java.getAnnotation(ConditionalOnProperty::class.java) + + assertNotNull(condition) + assertEquals("creator-channel.donation-tab.enabled", condition.name.first()) + assertEquals("true", condition.havingValue) + assertEquals(false, condition.matchIfMissing) + } + + @Test + @DisplayName("크리에이터 채널 후원 탭 조회는 비회원 요청을 거부한다") + fun shouldRejectAnonymousCreatorChannelDonationRequest() { + mockMvc.perform( + get("/api/v2/creator-channels/1/donations") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다") + fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createResponse(page = 1, size = 20)).`when`(facade).getDonationTab( + eqValue(1L), + eqValue(viewer), + eqValue(1), + eqValue(20), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/1/donations") + .param("page", "1") + .param("size", "20") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.donationCount").value(3)) + .andExpect(jsonPath("$.data.rankings").isArray) + .andExpect(jsonPath("$.data.rankings[0].userId").value(10)) + .andExpect(jsonPath("$.data.rankings[0].nickname").value("fan")) + .andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/fan.png")) + .andExpect(jsonPath("$.data.rankings[0].donationCan").value(100)) + .andExpect(jsonPath("$.data.donations").isArray) + .andExpect(jsonPath("$.data.donations[0].nickname").value("donor")) + .andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donor.png")) + .andExpect(jsonPath("$.data.donations[0].can").value(50)) + .andExpect(jsonPath("$.data.donations[0].message").value("thanks")) + .andExpect(jsonPath("$.data.donations[0].createdAtUtc").value("2026-06-21T03:30:00Z")) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + + Mockito.verify(facade).getDonationTab( + eqValue(1L), + eqValue(viewer), + eqValue(1), + eqValue(20), + anyValue(LocalDateTime.now()) + ) + } + + private fun eqValue(value: T): T { + return Mockito.eq(value) ?: value + } + + private fun anyValue(fallback: T): T { + return Mockito.any() ?: fallback + } + + private fun createMember(id: Long): Member { + return Member( + email = "viewer$id@test.com", + password = "password", + nickname = "viewer$id", + role = MemberRole.USER + ).apply { this.id = id } + } + + private fun createResponse( + page: Int = 0, + size: Int = 20 + ): CreatorChannelDonationTabResponse { + return CreatorChannelDonationTabResponse( + donationCount = 3, + rankings = listOf( + MemberDonationRankingResponse( + userId = 10L, + nickname = "fan", + profileImage = "https://cdn.test/fan.png", + donationCan = 100 + ) + ), + donations = listOf( + CreatorChannelDonationResponse( + nickname = "donor", + profileImageUrl = "https://cdn.test/donor.png", + can = 50, + message = "thanks", + createdAtUtc = "2026-06-21T03:30:00Z" + ) + ), + page = page, + size = size, + hasNext = false + ) + } +}