From 357d207fcc4fc4411c23996950d88e517c4cbc7f Mon Sep 17 00:00:00 2001 From: Klaus Date: Fri, 19 Jun 2026 19:05:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=98=A4=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=ED=83=AD=20controller=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/CreatorChannelAudioController.kt | 43 ++++ .../web/CreatorChannelAudioControllerTest.kt | 218 ++++++++++++++++++ .../in/web/CreatorChannelAudioEndToEndTest.kt | 148 ++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt new file mode 100644 index 00000000..fba0ce79 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioController.kt @@ -0,0 +1,43 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.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.audio.application.CreatorChannelAudioFacade +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 +@RequestMapping("/api/v2/creator-channels") +class CreatorChannelAudioController( + private val creatorChannelAudioFacade: CreatorChannelAudioFacade +) { + @GetMapping("/{creatorId}/audio") + fun getAudioTab( + @PathVariable creatorId: Long, + @RequestParam(required = false) sort: String?, + @RequestParam(required = false) themeId: Long?, + @RequestParam(required = false) page: Int?, + @RequestParam(required = false) size: Int?, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + creatorChannelAudioFacade.getAudioTab( + creatorId = creatorId, + viewer = requireMember(member), + sort = sort, + themeId = themeId, + 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/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt new file mode 100644 index 00000000..a817086a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioControllerTest.kt @@ -0,0 +1,218 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.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.audio.application.CreatorChannelAudioFacade +import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioTabResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.audio.dto.CreatorChannelAudioThemeResponse +import kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponse +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +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.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 java.time.LocalDateTime +import javax.servlet.http.HttpServletResponse + +@WebMvcTest(CreatorChannelAudioController::class) +@Import(CreatorChannelAudioControllerTest.TestSecurityConfig::class) +class CreatorChannelAudioControllerTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var facade: CreatorChannelAudioFacade + + @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("크리에이터 채널 오디오 탭 조회는 비회원 요청을 거부한다") + fun shouldRejectAnonymousCreatorChannelAudioRequest() { + mockMvc.perform( + get("/api/v2/creator-channels/1/audio") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("크리에이터 채널 오디오 탭 조회는 기본 요청값을 facade에 전달하고 성공 응답을 반환한다") + fun shouldReturnCreatorChannelAudioTabForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createResponse()).`when`(facade).getAudioTab( + eqValue(1L), + eqValue(viewer), + eqValue(null), + eqValue(null), + eqValue(null), + eqValue(null), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/1/audio") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.audioContentCount").value(3)) + .andExpect(jsonPath("$.data.paidAudioContentCount").value(2)) + .andExpect(jsonPath("$.data.purchasedAudioContentCount").value(1)) + .andExpect(jsonPath("$.data.purchasedAudioContentRate").value(50.0)) + .andExpect(jsonPath("$.data.themes").isArray) + .andExpect(jsonPath("$.data.audioContents").isArray) + .andExpect(jsonPath("$.data.sort").value("LATEST")) + .andExpect(jsonPath("$.data.themeId").doesNotExist()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.audioContents[0].isOwned").value(true)) + .andExpect(jsonPath("$.data.audioContents[0].isRented").value(false)) + + Mockito.verify(facade).getAudioTab( + eqValue(1L), + eqValue(viewer), + eqValue(null), + eqValue(null), + eqValue(null), + eqValue(null), + anyValue(LocalDateTime.now()) + ) + } + + @Test + @DisplayName("크리에이터 채널 오디오 탭 조회는 잘못된 query parameter도 controller에서 거부하지 않고 facade에 전달한다") + fun shouldPassInvalidQueryParametersToFacade() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createResponse(themeId = null, page = 0, size = 50)).`when`(facade).getAudioTab( + eqValue(1L), + eqValue(viewer), + eqValue("INVALID"), + eqValue(999L), + eqValue(-1), + eqValue(100), + anyValue(LocalDateTime.now()) + ) + + mockMvc.perform( + get("/api/v2/creator-channels/1/audio") + .param("sort", "INVALID") + .param("page", "-1") + .param("size", "100") + .param("themeId", "999") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.sort").value("LATEST")) + .andExpect(jsonPath("$.data.themeId").doesNotExist()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(50)) + + Mockito.verify(facade).getAudioTab( + eqValue(1L), + eqValue(viewer), + eqValue("INVALID"), + eqValue(999L), + eqValue(-1), + eqValue(100), + 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( + themeId: Long? = null, + page: Int = 0, + size: Int = 20 + ): CreatorChannelAudioTabResponse { + return CreatorChannelAudioTabResponse( + audioContentCount = 3, + paidAudioContentCount = 2, + purchasedAudioContentCount = 1, + purchasedAudioContentRate = 50.0, + themes = listOf(CreatorChannelAudioThemeResponse(themeId = 10L, themeName = "theme")), + audioContents = listOf( + CreatorChannelAudioContentResponse( + audioContentId = 201L, + title = "audio", + duration = "00:10:00", + imageUrl = "audio.png", + price = 30, + isAdult = false, + isPointAvailable = true, + isFirstContent = true, + seriesName = "series", + isOriginalSeries = true, + isOwned = true, + isRented = false + ) + ), + sort = ContentSort.LATEST, + themeId = themeId, + page = page, + size = size, + hasNext = false + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt new file mode 100644 index 00000000..5398907f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt @@ -0,0 +1,148 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.audio.adapter.`in`.web + +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.order.Order +import kr.co.vividnext.sodalive.content.order.OrderType +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +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.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.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:creator-channel-live-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class CreatorChannelAudioEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("오디오 탭 API는 controller-service-repository를 거쳐 fallback 적용 응답을 반환한다") + fun shouldReturnAudioTabWithFallbacksThroughControllerServiceAndRepository() { + val fixture = createFixture() + + mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/audio") + .param("sort", "INVALID") + .param("page", "-1") + .param("size", "100") + .param("themeId", "999") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.audioContentCount").value(1)) + .andExpect(jsonPath("$.data.paidAudioContentCount").value(1)) + .andExpect(jsonPath("$.data.purchasedAudioContentCount").value(1)) + .andExpect(jsonPath("$.data.purchasedAudioContentRate").value(100.0)) + .andExpect(jsonPath("$.data.themes").isArray) + .andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.audioContents[0].imageUrl").value("https://cdn.test/audio-e2e.png")) + .andExpect(jsonPath("$.data.audioContents[0].isOwned").value(true)) + .andExpect(jsonPath("$.data.audioContents[0].isRented").value(false)) + .andExpect(jsonPath("$.data.sort").value("LATEST")) + .andExpect(jsonPath("$.data.themeId").doesNotExist()) + .andExpect(jsonPath("$.data.page").value(0)) + .andExpect(jsonPath("$.data.size").value(50)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + } + + private fun createFixture(): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now() + val viewer = saveMember("audio-e2e-viewer", MemberRole.USER) + val creator = saveMember("audio-e2e-creator", MemberRole.CREATOR) + val theme = saveTheme("수면") + val content = saveAudioContent(creator, now.minusHours(1), theme) + saveOrder(viewer, creator, content, OrderType.KEEP) + entityManager.flush() + + Fixture( + viewer = viewer, + creatorId = creator.id!!, + audioContentId = content.id!! + ) + }!! + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveTheme(name: String): AudioContentTheme { + val theme = AudioContentTheme(theme = name, image = "$name.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveAudioContent( + creator: Member, + releaseDate: LocalDateTime, + theme: AudioContentTheme + ): AudioContent { + val content = AudioContent( + title = "audio-e2e", + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + isAdult = false, + price = 100, + isPointAvailable = true + ) + content.member = creator + content.theme = theme + content.isActive = true + content.coverImage = "audio-e2e.png" + content.duration = "00:10:00" + entityManager.persist(content) + return content + } + + private fun saveOrder( + member: Member, + creator: Member, + content: AudioContent, + type: OrderType + ): Order { + val order = Order(type = type, isActive = true) + order.member = member + order.creator = creator + order.audioContent = content + entityManager.persist(order) + return order + } + + private data class Fixture( + val viewer: Member, + val creatorId: Long, + val audioContentId: Long + ) +}