feat(home): 크리에이터 채널 홈 정책을 보강한다

This commit is contained in:
2026-07-31 01:46:39 +09:00
parent f1c2e6c50b
commit 13e0b42375
20 changed files with 1811 additions and 51 deletions
@@ -0,0 +1,275 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.common.CountryContext
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.creator.admin.content.series.Series
import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
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.mockito.Mockito
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.boot.test.mock.mockito.MockBean
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 java.time.ZoneOffset
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-home-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelHomeEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@MockBean
private lateinit var countryContext: CountryContext
@Test
@DisplayName("크리에이터 채널 홈 API는 단일 인증 요청으로 전체 홈 섹션을 조립한다")
fun shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest() {
Mockito.doReturn("US").`when`(countryContext).countryCode
val fixture = createFixture()
mockMvc.perform(get("/api/v2/creator-channels/${fixture.creatorId}/home").with(user(MemberAdapter(fixture.viewer))))
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.creator.creatorId").value(fixture.creatorId))
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.schedules[0].targetId").value(fixture.liveScheduleId))
.andExpect(jsonPath("$.data.schedules[1].targetId").value(fixture.audioScheduleId))
.andExpect(jsonPath("$.data.audioContents[0].audioContentId").value(fixture.listAudioId))
.andExpect(jsonPath("$.data.audioContents[1].audioContentId").value(fixture.firstAudioId))
.andExpect(jsonPath("$.data.series[0].seriesId").value(fixture.seriesId))
.andExpect(jsonPath("$.data.communities[0].postId").value(fixture.communityId))
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk.fanTalkId").value(fixture.fanTalkId))
.andExpect(jsonPath("$.data.introduce").value("e2e introduce"))
.andExpect(jsonPath("$.data.activity.audioContentCount").value(3))
.andExpect(jsonPath("$.data.sns.instagramUrl").value("e2e-instagram"))
}
private fun createFixture(): Fixture {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
val viewer = saveMember("home-e2e-viewer", MemberRole.USER)
val creator = saveMember("home-e2e-creator", MemberRole.CREATOR)
creator.introduce = "e2e introduce"
creator.instagramUrl = "e2e-instagram"
creator.fancimmUrl = "e2e-fancimm"
creator.xUrl = "e2e-x"
creator.youtubeUrl = "e2e-youtube"
creator.websiteUrl = "e2e-kakao"
val donor = saveMember("home-e2e-donor", MemberRole.USER)
val fan = saveMember("home-e2e-fan", MemberRole.USER)
val currentLive = saveLiveRoom(creator, now.minusHours(2), channelName = "e2e-live")
val liveSchedule = saveLiveRoom(creator, now.plusHours(1), channelName = null)
val audioSchedule = saveAudioContent(creator, now.plusHours(2), title = "schedule-audio")
val firstAudio = saveAudioContent(creator, now.minusDays(3), title = "first-audio")
val listAudio = saveAudioContent(creator, now.minusDays(2), title = "list-audio")
val latestAudio = saveAudioContent(creator, now.minusDays(1), title = "latest-audio")
val series = saveSeries("home-e2e-series", creator)
saveSeriesContent(series, listAudio)
saveOrder(viewer, creator, latestAudio, OrderType.KEEP)
saveDonation(creator, donor, now.minusHours(3))
val notice = saveCommunityPost(creator, "e2e notice", isFixed = true, fixedAt = now.minusHours(4))
val community = saveCommunityPost(creator, "e2e community", isFixed = false, fixedAt = null)
val fanTalk = saveCheers(fan, creator, "e2e fan talk", now.minusMinutes(30))
saveVisit(currentLive, viewer)
entityManager.flush()
Fixture(
viewer = viewer,
creatorId = creator.id!!,
currentLiveId = currentLive.id!!,
latestAudioId = latestAudio.id!!,
liveScheduleId = liveSchedule.id!!,
audioScheduleId = audioSchedule.id!!,
firstAudioId = firstAudio.id!!,
listAudioId = listAudio.id!!,
seriesId = series.id!!,
noticeId = notice.id!!,
communityId = community.id!!,
fanTalkId = fanTalk.id!!
)
} ?: throw IllegalStateException("fixture creation failed")
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role
)
entityManager.persist(member)
return member
}
private fun saveLiveRoom(creator: Member, beginDateTime: LocalDateTime, channelName: String?): LiveRoom {
val liveRoom = LiveRoom(
title = "live-$channelName",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
coverImage = "live.png",
isAdult = false,
price = 50
)
liveRoom.member = creator
liveRoom.channelName = channelName
liveRoom.isActive = true
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveAudioContent(creator: Member, releaseDate: LocalDateTime, title: String): AudioContent {
val theme = AudioContentTheme(theme = "theme-$title", image = "$title.png", isActive = true)
entityManager.persist(theme)
val content = AudioContent(
title = title,
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate,
price = 100,
isAdult = false
)
content.member = creator
content.theme = theme
content.isActive = true
content.coverImage = "$title.png"
content.duration = "00:10:00"
entityManager.persist(content)
return content
}
private fun saveSeries(title: String, creator: Member): Series {
val series = Series(title = title, introduction = "intro", languageCode = "ko", isOriginal = true, isActive = true)
series.member = creator
series.genre = saveSeriesGenre(title)
entityManager.persist(series)
return series
}
private fun saveSeriesGenre(name: String): SeriesGenre {
val genre = SeriesGenre(genre = "genre-$name", isAdult = false, isActive = true)
entityManager.persist(genre)
return genre
}
private fun saveSeriesContent(series: Series, content: AudioContent): SeriesContent {
val seriesContent = SeriesContent()
seriesContent.series = series
seriesContent.content = content
entityManager.persist(seriesContent)
return seriesContent
}
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 fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks")
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
entityManager.flush()
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
return donation
}
private fun saveCommunityPost(
creator: Member,
content: String,
isFixed: Boolean,
fixedAt: LocalDateTime?
): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
imagePath = "$content.png",
isFixed = isFixed,
fixedAt = fixedAt
)
post.member = creator
entityManager.persist(post)
return post
}
private fun saveCheers(member: Member, creator: Member, cheers: String, createdAt: LocalDateTime): CreatorCheers {
val creatorCheers = CreatorCheers(cheers = cheers, languageCode = "ko", isActive = true)
creatorCheers.member = member
creatorCheers.creator = creator
entityManager.persist(creatorCheers)
entityManager.flush()
updateCreatedAt("CreatorCheers", creatorCheers.id!!, createdAt)
return creatorCheers
}
private fun saveVisit(room: LiveRoom, member: Member): LiveRoomVisit {
val visit = LiveRoomVisit()
visit.room = room
visit.member = member
entityManager.persist(visit)
return visit
}
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
.setParameter("createdAt", createdAt)
.setParameter("id", id)
.executeUpdate()
}
private data class Fixture(
val viewer: Member,
val creatorId: Long,
val currentLiveId: Long,
val latestAudioId: Long,
val liveScheduleId: Long,
val audioScheduleId: Long,
val firstAudioId: Long,
val listAudioId: Long,
val seriesId: Long,
val noticeId: Long,
val communityId: Long,
val fanTalkId: Long
)
}
@@ -147,6 +147,33 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
)
}
@Test
@DisplayName("크리에이터 채널 홈 빈 섹션은 단건 null과 목록 빈 배열로 응답한다")
fun shouldReturnNullSinglesAndEmptyArraysForEmptyCreatorChannelHomeSections() {
val viewer = createMember(id = 10L)
Mockito.doReturn(CreatorChannelHomeResponse.from(createEmptyHome())).`when`(facade).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.currentLive").value(null as String?))
.andExpect(jsonPath("$.data.latestAudioContent").value(null as String?))
.andExpect(jsonPath("$.data.fanTalk.latestFanTalk").value(null as String?))
.andExpect(jsonPath("$.data.channelDonations").isEmpty)
.andExpect(jsonPath("$.data.notices").isEmpty)
.andExpect(jsonPath("$.data.schedules").isEmpty)
.andExpect(jsonPath("$.data.audioContents").isEmpty)
.andExpect(jsonPath("$.data.series").isEmpty)
.andExpect(jsonPath("$.data.communities").isEmpty)
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",
@@ -288,4 +315,18 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
)
)
}
private fun createEmptyHome(): CreatorChannelHome {
return createHome().copy(
currentLive = null,
latestAudioContent = null,
channelDonations = emptyList(),
notices = emptyList(),
schedules = emptyList(),
audioContents = emptyList(),
series = emptyList(),
communities = emptyList(),
fanTalk = CreatorChannelFanTalkSummary(totalCount = 0, latestFanTalk = null)
)
}
}
@@ -22,8 +22,11 @@ import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito
import java.time.LocalDateTime
import java.time.ZoneOffset
import java.util.TimeZone
class CreatorChannelHomeFacadeTest {
@Test
@@ -77,6 +80,40 @@ class CreatorChannelHomeFacadeTest {
assertEquals("instagram", response.sns.instagramUrl)
}
@Test
@DisplayName("크리에이터 채널 홈 facade 기본 now는 JVM timezone과 무관하게 UTC 기준으로 전달한다")
fun shouldUseUtcNowWhenNowIsNotProvided() {
val service = Mockito.mock(CreatorChannelHomeQueryService::class.java)
val facade = CreatorChannelHomeFacade(service)
val viewer = createMember(id = 10L)
val previousTimeZone = TimeZone.getDefault()
Mockito.doReturn(createHome()).`when`(service).getHome(
Mockito.eq(1L),
Mockito.eq(viewer) ?: viewer,
Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now()
)
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1)
facade.getHome(creatorId = 1L, viewer = viewer)
val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1)
val nowCaptor = ArgumentCaptor.forClass(LocalDateTime::class.java)
Mockito.verify(service).getHome(
Mockito.eq(1L),
Mockito.eq(viewer) ?: viewer,
nowCaptor.capture() ?: LocalDateTime.MIN
)
val capturedNow = nowCaptor.value
assertTrue(!capturedNow.isBefore(beforeUtc), "captured now should be after UTC lower bound")
assertTrue(!capturedNow.isAfter(afterUtc), "captured now should be before UTC upper bound")
} finally {
TimeZone.setDefault(previousTimeZone)
}
}
private fun createMember(id: Long): Member {
return Member(
email = "viewer$id@test.com",