From d14406bae7d2ff23f4a5920ab83a5ed3ef2529d2 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 13 Jun 2026 21:48:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=ED=99=88?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/CreatorChannelHomeController.kt | 37 +++ .../web/CreatorChannelHomeControllerTest.kt | 272 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt new file mode 100644 index 00000000..cc7965bf --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeController.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.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.creator.channel.application.CreatorChannelHomeQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.dto.CreatorChannelHomeResponse +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/creator-channels") +class CreatorChannelHomeController( + private val creatorChannelHomeQueryService: CreatorChannelHomeQueryService +) { + @GetMapping("/{creatorId}/home") + fun getHome( + @PathVariable creatorId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + ApiResponse.ok( + CreatorChannelHomeResponse.from( + creatorChannelHomeQueryService.getHome( + creatorId = creatorId, + viewer = requireMember(member) + ) + ) + ) + } + + 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/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt new file mode 100644 index 00000000..0215593b --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt @@ -0,0 +1,272 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.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.common.domain.CreatorActivityType +import kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelActivity +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelAudioContent +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelCreator +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelDonation +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalk +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelFanTalkSummary +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHome +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelLive +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSchedule +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSeries +import kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelSns +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(CreatorChannelHomeController::class) +@Import(CreatorChannelHomeControllerTest.TestSecurityConfig::class) +class CreatorChannelHomeControllerTest @Autowired constructor( + private val mockMvc: MockMvc +) { + @MockBean + private lateinit var service: CreatorChannelHomeQueryService + + @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 shouldRejectAnonymousCreatorChannelHomeRequest() { + mockMvc.perform( + get("/api/v2/creator-channels/1/home") + .with(anonymous()) + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("크리에이터 채널 홈 조회는 인증 회원과 creatorId를 서비스에 전달하고 성공 응답을 반환한다") + fun shouldReturnCreatorChannelHomeForAuthenticatedMember() { + val viewer = createMember(id = 10L) + Mockito.doReturn(createHome()).`when`(service).getHome( + Mockito.eq(1L), + Mockito.any(Member::class.java) ?: viewer, + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + + mockMvc.perform( + get("/api/v2/creator-channels/1/home") + .with(user(MemberAdapter(viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.creator").exists()) + .andExpect(jsonPath("$.data.currentLive").exists()) + .andExpect(jsonPath("$.data.latestAudioContent").exists()) + .andExpect(jsonPath("$.data.channelDonations").isArray) + .andExpect(jsonPath("$.data.notices").isArray) + .andExpect(jsonPath("$.data.schedules").isArray) + .andExpect(jsonPath("$.data.audioContents").isArray) + .andExpect(jsonPath("$.data.series").isArray) + .andExpect(jsonPath("$.data.communities").isArray) + .andExpect(jsonPath("$.data.fanTalk").exists()) + .andExpect(jsonPath("$.data.introduce").value("introduce")) + .andExpect(jsonPath("$.data.activity").exists()) + .andExpect(jsonPath("$.data.sns").exists()) + .andExpect(jsonPath("$.data.creator.creatorId").value(1L)) + .andExpect(jsonPath("$.data.creator.characterId").value(11L)) + .andExpect(jsonPath("$.data.creator.isAiChatAvailable").value(true)) + .andExpect(jsonPath("$.data.creator.isDmAvailable").value(false)) + .andExpect(jsonPath("$.data.latestAudioContent.isPointAvailable").value(true)) + .andExpect(jsonPath("$.data.latestAudioContent.isFirstContent").value(true)) + .andExpect(jsonPath("$.data.latestAudioContent.isOriginalSeries").value(true)) + .andExpect(jsonPath("$.data.currentLive.isAdult").value(true)) + .andExpect(jsonPath("$.data.series[0].isNew").value(true)) + .andExpect(jsonPath("$.data.series[0].isOriginal").value(true)) + + Mockito.verify(service).getHome( + Mockito.eq(1L), + Mockito.eq(viewer) ?: viewer, + Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + ) + } + + 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 createHome(): CreatorChannelHome { + val post = CreatorChannelCommunityPost( + postId = 301L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfileUrl = "profile.png", + imageUrl = "image.png", + audioUrl = "audio.mp3", + content = "notice", + price = 10, + date = LocalDateTime.of(2026, 6, 12, 4, 0), + existOrdered = true, + likeCount = 2, + commentCount = 3 + ) + + return CreatorChannelHome( + creator = CreatorChannelCreator( + creatorId = 1L, + characterId = 11L, + nickname = "creator", + profileImageUrl = "profile.png", + followerCount = 100, + isAiChatAvailable = true, + isDmAvailable = false, + isFollow = true, + isNotify = false + ), + currentLive = CreatorChannelLive( + liveId = 101L, + title = "live", + coverImageUrl = "live.png", + beginDateTime = LocalDateTime.of(2026, 6, 12, 1, 0), + price = 20, + isAdult = true + ), + latestAudioContent = CreatorChannelAudioContent( + audioContentId = 201L, + title = "audio", + duration = "00:10:00", + imageUrl = "audio.png", + price = 30, + isAdult = true, + isPointAvailable = true, + isFirstContent = true, + publishedAt = LocalDateTime.of(2026, 6, 11, 1, 0), + seriesName = "series", + isOriginalSeries = true + ), + channelDonations = listOf( + CreatorChannelDonation( + nickname = "fan", + profileImageUrl = "fan.png", + can = 50, + message = "thanks", + createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) + ) + ), + notices = listOf(post), + schedules = listOf( + CreatorChannelSchedule( + scheduledAt = LocalDateTime.of(2026, 6, 12, 3, 0), + title = "schedule", + type = CreatorActivityType.LIVE, + targetId = 501L, + isAdult = false + ) + ), + audioContents = listOf( + CreatorChannelAudioContent( + audioContentId = 202L, + title = "audio2", + duration = null, + imageUrl = null, + price = 0, + isAdult = false, + isPointAvailable = false, + isFirstContent = false, + publishedAt = LocalDateTime.of(2026, 6, 10, 1, 0), + seriesName = null, + isOriginalSeries = null + ) + ), + series = listOf( + CreatorChannelSeries( + seriesId = 601L, + title = "series", + coverImageUrl = "series.png", + numberOfContent = 3, + isNew = true, + isOriginal = true + ) + ), + communities = listOf(post.copy(postId = 302L, content = "community")), + fanTalk = CreatorChannelFanTalkSummary( + totalCount = 1, + latestFanTalk = CreatorChannelFanTalk( + fanTalkId = 701L, + memberId = 2L, + nickname = "fan", + profileImageUrl = "fan.png", + content = "hello", + languageCode = "ko", + createdAt = LocalDateTime.of(2026, 6, 12, 5, 0) + ) + ), + introduce = "introduce", + activity = CreatorChannelActivity( + debutDate = LocalDateTime.of(2026, 6, 12, 6, 0), + dDay = "D+1", + liveCount = 10, + liveDurationHours = 20, + liveContributorCount = 30, + audioContentCount = 40, + seriesCount = 50 + ), + sns = CreatorChannelSns( + instagramUrl = "instagram", + fancimmUrl = "fancimm", + xUrl = "x", + youtubeUrl = "youtube", + kakaoOpenChatUrl = "kakao" + ) + ) + } +}