feat(creator): 채널 홈 조회 API를 추가한다
This commit is contained in:
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user