feat(creator): 채널 홈 조회 API를 추가한다

This commit is contained in:
2026-06-13 21:48:24 +09:00
parent 804a60756b
commit d14406bae7
2 changed files with 309 additions and 0 deletions

View File

@@ -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")
}
}

View File

@@ -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"
)
)
}
}