feat(home): 크리에이터 채널 홈 정책을 보강한다
This commit is contained in:
+275
@@ -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
|
||||
)
|
||||
}
|
||||
+41
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user