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

View File

@@ -36,6 +36,7 @@ import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationEvent
import kr.co.vividnext.sodalive.i18n.translation.LanguageTranslationTargetType
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy
import kr.co.vividnext.sodalive.utils.generateFileName
@@ -57,6 +58,8 @@ import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private const val MAX_PIN_CONTENT_COUNT = 9
@Service
@Transactional(readOnly = true)
class AudioContentService(
@@ -72,6 +75,7 @@ class AudioContentService(
private val commentRepository: AudioContentCommentRepository,
private val audioContentLikeRepository: AudioContentLikeRepository,
private val pinContentRepository: PinContentRepository,
private val memberRepository: MemberRepository,
private val resourceTranslationJobScheduler: ResourceTranslationJobScheduler,
private val contentTranslationRepository: ContentTranslationRepository,
@@ -795,7 +799,7 @@ class AudioContentService(
val pinContentListCount = pinContentRepository.getPinContentList(memberId = member.id!!, active = true).size
val isAvailablePin = if (member.id!! == audioContent.member!!.id!!) {
pinContentListCount < 3
pinContentListCount < MAX_PIN_CONTENT_COUNT
} else {
false
}
@@ -1227,7 +1231,9 @@ class AudioContentService(
@Transactional
fun pinToTheTop(contentId: Long, member: Member) {
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = member.id!!)
val creator = memberRepository.findByIdForUpdate(member.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
val audioContent = repository.findByIdAndCreatorId(contentId = contentId, creatorId = creator.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
if (audioContent.releaseDate != null && audioContent.releaseDate!! >= LocalDateTime.now()) {
@@ -1236,21 +1242,24 @@ class AudioContentService(
var pinContent = pinContentRepository.findByContentIdAndMemberId(
contentId = contentId,
memberId = member.id!!
memberId = creator.id!!
)
val activePinContentList = pinContentRepository.getPinContentList(memberId = creator.id!!, active = true)
if (pinContent != null) {
if (!pinContent.isActive && activePinContentList.size >= MAX_PIN_CONTENT_COUNT) {
activePinContentList[0].isActive = false
}
pinContent.isActive = true
} else {
val pinContentList = pinContentRepository.getPinContentList(memberId = member.id!!)
pinContent = if (pinContentList.size >= 3) {
pinContentList[0]
pinContent = if (activePinContentList.size >= MAX_PIN_CONTENT_COUNT) {
activePinContentList[0]
} else {
PinContent()
}
pinContent.isActive = true
pinContent.member = member
pinContent.member = creator
pinContent.content = audioContent
pinContentRepository.save(pinContent)
}
@@ -1258,9 +1267,11 @@ class AudioContentService(
@Transactional
fun unpinAtTheTop(contentId: Long, member: Member) {
val creator = memberRepository.findByIdForUpdate(member.id!!)
?: throw SodaException(messageKey = "content.error.invalid_content_retry")
val pinContent = pinContentRepository.findByContentIdAndMemberId(
contentId = contentId,
memberId = member.id!!
memberId = creator.id!!
) ?: throw SodaException(messageKey = "content.error.invalid_content_retry")
pinContent.isActive = false

View File

@@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChann
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
@@ -15,7 +16,7 @@ class CreatorChannelHomeFacade(
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): CreatorChannelHomeResponse {
return CreatorChannelHomeResponse.from(
creatorChannelHomeQueryService.getHome(

View File

@@ -9,6 +9,7 @@ import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.order.QOrder.order
import kr.co.vividnext.sodalive.content.pin.QPinContent.pinContent
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeries.series
import kr.co.vividnext.sodalive.creator.admin.content.series.QSeriesContent.seriesContent
import kr.co.vividnext.sodalive.explorer.profile.QCreatorCheers.creatorCheers
@@ -269,7 +270,7 @@ class DefaultCreatorChannelHomeQueryRepository(
viewerId: Long?,
limit: Int
): List<CreatorChannelAudioContentRecord> {
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit)
val rows = findAudioContentRows(creatorId, now, latestAudioContentId, canViewAdultContent, limit, pinFirst = true)
val contentIds = rows.map { itAudioId(it) }
val firstContentId = firstAudioContentId(creatorId, now, canViewAdultContent)
val seriesByContentId = audioSeriesByContentIds(contentIds)
@@ -435,21 +436,33 @@ class DefaultCreatorChannelHomeQueryRepository(
now: LocalDateTime,
excludedContentId: Long?,
canViewAdultContent: Boolean,
limit: Int
) = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate,
audioContent.createdAt
)
.from(audioContent)
.where(
limit: Int,
pinFirst: Boolean = false
): List<com.querydsl.core.Tuple> {
val query = queryFactory
.select(
audioContent.id,
audioContent.title,
audioContent.duration,
audioContent.coverImage,
audioContent.price,
audioContent.isAdult,
audioContent.isPointAvailable,
audioContent.releaseDate,
audioContent.createdAt
)
.from(audioContent)
if (pinFirst) {
query.leftJoin(pinContent)
.on(
pinContent.member.id.eq(creatorId),
pinContent.content.id.eq(audioContent.id),
pinContent.isActive.isTrue
)
}
query.where(
audioContent.member.id.eq(creatorId),
audioContent.member.isActive.isTrue,
audioContent.isActive.isTrue,
@@ -459,9 +472,22 @@ class DefaultCreatorChannelHomeQueryRepository(
excludedContentId?.let { audioContent.id.ne(it) },
adultAudioCondition(canViewAdultContent)
)
.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
.limit(limit.toLong())
.fetch()
if (pinFirst) {
query.orderBy(
pinContent.isActive.desc(),
pinContent.updatedAt.desc(),
audioContent.releaseDate.desc(),
audioContent.id.desc()
)
} else {
query.orderBy(audioContent.releaseDate.desc(), audioContent.id.desc())
}
return query
.limit(limit.toLong())
.fetch()
}
private fun itAudioId(row: com.querydsl.core.Tuple): Long = row.get(audioContent.id)!!

View File

@@ -1,6 +1,7 @@
package kr.co.vividnext.sodalive.v2.creator.channel.home.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Gender
@@ -36,6 +37,7 @@ import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.ZoneOffset
@Service
@Transactional(readOnly = true)
@@ -52,7 +54,7 @@ class CreatorChannelHomeQueryService(
fun getHome(
creatorId: Long,
viewer: Member,
now: LocalDateTime = LocalDateTime.now()
now: LocalDateTime = LocalDateTime.now(ZoneOffset.UTC)
): CreatorChannelHome {
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
@@ -68,8 +70,8 @@ class CreatorChannelHomeQueryService(
validateCreatorRole(creator)
val preference = memberContentPreferenceService.getStoredPreference(viewer)
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(viewer)
val isViewerCreator = viewerId == creatorId
val canViewAdultContent = preference.isAdult
val isViewerCreator = viewer.role == MemberRole.CREATOR
val effectiveViewerGender = viewer.effectiveGender()
val latestAudioContent = queryPort
.findLatestAudioContent(creatorId, now, canViewAdultContent, viewerId)
@@ -187,7 +189,7 @@ class CreatorChannelHomeQueryService(
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
nickname = nickname,
nickname = nickname.removeDeletedNicknamePrefix(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message,

View File

@@ -0,0 +1,127 @@
package kr.co.vividnext.sodalive.content
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ContextConfiguration
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.persistence.EntityManager
@SpringBootTest(properties = ["cloud.aws.cloud-front.host=https://test.cloudfront.net"])
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
@Transactional
class AudioContentPinConcurrencyTest @Autowired constructor(
private val service: AudioContentService,
private val entityManager: EntityManager,
private val transactionManager: PlatformTransactionManager
) {
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DisplayName("동시 상단 고정 요청에서도 활성 고정은 9개를 넘지 않는다")
fun shouldKeepNineActivePinsWhenPinRequestsRace() {
val transactionTemplate = TransactionTemplate(transactionManager)
val fixture = transactionTemplate.execute {
val creator = saveMember("pin-race-creator", MemberRole.CREATOR)
val existingContents = (1..8).map { saveAudioContent(creator, "existing-$it") }
existingContents.forEach { savePinContent(creator, it) }
val first = saveAudioContent(creator, "first-race")
val second = saveAudioContent(creator, "second-race")
entityManager.flush()
PinRaceFixture(creator.id!!, first.id!!, second.id!!)
} ?: throw IllegalStateException("fixture creation failed")
val start = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
try {
val first = executor.submit {
start.await()
service.pinToTheTop(fixture.firstContentId, memberRef(fixture.creatorId))
}
val second = executor.submit {
start.await()
service.pinToTheTop(fixture.secondContentId, memberRef(fixture.creatorId))
}
start.countDown()
first.get(5, TimeUnit.SECONDS)
second.get(5, TimeUnit.SECONDS)
} finally {
executor.shutdownNow()
}
val activeContentIds = transactionTemplate.execute { activePinnedContentIds(fixture.creatorId) }.orEmpty()
assertEquals(9, activeContentIds.size)
assertEquals(activeContentIds.size, activeContentIds.distinct().size)
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
role = role
)
entityManager.persist(member)
return member
}
private fun saveAudioContent(creator: Member, 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 = LocalDateTime.now().minusDays(1),
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 savePinContent(creator: Member, content: AudioContent): PinContent {
val pinContent = PinContent(isActive = true)
pinContent.member = creator
pinContent.content = content
entityManager.persist(pinContent)
return pinContent
}
private fun memberRef(memberId: Long): Member {
val member = Member(email = "creator-$memberId@test.com", password = "password", nickname = "creator-$memberId")
member.id = memberId
return member
}
private fun activePinnedContentIds(creatorId: Long): List<Long> {
return entityManager.createQuery(
"select p.content.id from PinContent p where p.member.id = :creatorId and p.isActive = true",
java.lang.Long::class.java
).setParameter("creatorId", creatorId).resultList.map { it.toLong() }
}
private data class PinRaceFixture(
val creatorId: Long,
val firstContentId: Long,
val secondContentId: Long
)
}

View File

@@ -10,6 +10,7 @@ import kr.co.vividnext.sodalive.content.like.AudioContentLikeRepository
import kr.co.vividnext.sodalive.content.order.LimitedEditionOrderRepository
import kr.co.vividnext.sodalive.content.order.OrderRepository
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent
import kr.co.vividnext.sodalive.content.pin.PinContentRepository
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
import kr.co.vividnext.sodalive.content.theme.AudioContentThemeQueryRepository
@@ -20,6 +21,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.i18n.translation.ResourceTranslationJobScheduler
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRepository
import kr.co.vividnext.sodalive.member.block.BlockMemberRepository
import kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishService
import kr.co.vividnext.sodalive.v2.recommendation.application.CreatorContentViewHistoryService
@@ -52,6 +54,7 @@ class AudioContentServiceTest {
private lateinit var commentRepository: AudioContentCommentRepository
private lateinit var audioContentLikeRepository: AudioContentLikeRepository
private lateinit var pinContentRepository: PinContentRepository
private lateinit var memberRepository: MemberRepository
private lateinit var resourceTranslationJobScheduler: ResourceTranslationJobScheduler
private lateinit var contentTranslationRepository: ContentTranslationRepository
private lateinit var s3Uploader: S3Uploader
@@ -76,6 +79,7 @@ class AudioContentServiceTest {
commentRepository = Mockito.mock(AudioContentCommentRepository::class.java)
audioContentLikeRepository = Mockito.mock(AudioContentLikeRepository::class.java)
pinContentRepository = Mockito.mock(PinContentRepository::class.java)
memberRepository = Mockito.mock(MemberRepository::class.java)
resourceTranslationJobScheduler = Mockito.mock(ResourceTranslationJobScheduler::class.java)
contentTranslationRepository = Mockito.mock(ContentTranslationRepository::class.java)
s3Uploader = Mockito.mock(S3Uploader::class.java)
@@ -97,6 +101,7 @@ class AudioContentServiceTest {
commentRepository = commentRepository,
audioContentLikeRepository = audioContentLikeRepository,
pinContentRepository = pinContentRepository,
memberRepository = memberRepository,
resourceTranslationJobScheduler = resourceTranslationJobScheduler,
contentTranslationRepository = contentTranslationRepository,
s3Uploader = s3Uploader,
@@ -450,6 +455,123 @@ class AudioContentServiceTest {
)
}
@Test
@DisplayName("오디오 상세의 상단 고정 가능 여부는 활성 고정 9개 상한을 따른다")
fun shouldExposeAvailablePinByNineActivePinLimit() {
val creator = createMember(id = 2400L, nickname = "pin-limit-creator")
val audioContent = createAudioContent(creator = creator)
stubSuccessfulDetailDependencies(creator, creator, audioContent)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
.thenReturn(createPinContents(8))
.thenReturn(createPinContents(9))
val availableResponse = service.getDetail(
id = audioContent.id!!,
member = creator,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
val unavailableResponse = service.getDetail(
id = audioContent.id!!,
member = creator,
isAdultContentVisible = false,
timezone = "Asia/Seoul"
)
assertTrue(availableResponse.isAvailablePin)
assertEquals(false, unavailableResponse.isAvailablePin)
}
@Test
@DisplayName("10번째 상단 고정은 가장 오래된 활성 고정을 새 콘텐츠로 교체한다")
fun shouldReuseOldestActivePinWhenPinningTenthContent() {
val creator = createMember(id = 2410L, nickname = "pin-reuse-creator")
val audioContent = createAudioContent(creator = creator)
val oldestPinContent = createPinContents(1).single()
Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!))
.thenReturn(audioContent)
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(null)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
.thenReturn(listOf(oldestPinContent) + createPinContents(8))
service.pinToTheTop(contentId = audioContent.id!!, member = creator)
Mockito.inOrder(memberRepository, repository, pinContentRepository).apply {
verify(memberRepository).findByIdForUpdate(creator.id!!)
verify(repository).findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!)
verify(pinContentRepository).findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
verify(pinContentRepository).getPinContentList(memberId = creator.id!!, active = true)
}
assertTrue(oldestPinContent.isActive)
assertEquals(creator, oldestPinContent.member)
assertEquals(audioContent, oldestPinContent.content)
Mockito.verify(pinContentRepository).save(oldestPinContent)
}
@Test
@DisplayName("상단 고정 해제는 고정 행 조회 전에 크리에이터를 잠근다")
fun shouldLockCreatorBeforeFindingPinWhenUnpinningContent() {
val creator = createMember(id = 2415L, nickname = "pin-unpin-lock-creator")
val audioContent = createAudioContent(creator = creator)
val pinContent = PinContent(isActive = true)
pinContent.member = creator
pinContent.content = audioContent
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(pinContent)
service.unpinAtTheTop(contentId = audioContent.id!!, member = creator)
Mockito.inOrder(memberRepository, pinContentRepository).apply {
verify(memberRepository).findByIdForUpdate(creator.id!!)
verify(pinContentRepository).findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
}
assertEquals(false, pinContent.isActive)
}
@Test
@DisplayName("비활성 고정을 다시 활성화할 때도 활성 고정은 9개를 넘지 않는다")
fun shouldKeepNineActivePinsWhenReactivatingInactivePin() {
val creator = createMember(id = 2420L, nickname = "pin-reactivate-creator")
val audioContent = createAudioContent(creator = creator)
val inactivePinContent = PinContent(isActive = false)
inactivePinContent.member = creator
inactivePinContent.content = audioContent
val oldestActivePinContent = createPinContents(1).single()
Mockito.`when`(repository.findByIdAndCreatorId(contentId = audioContent.id!!, creatorId = creator.id!!))
.thenReturn(audioContent)
Mockito.`when`(memberRepository.findByIdForUpdate(creator.id!!)).thenReturn(creator)
Mockito.`when`(
pinContentRepository.findByContentIdAndMemberId(
contentId = audioContent.id!!,
memberId = creator.id!!
)
).thenReturn(inactivePinContent)
Mockito.`when`(pinContentRepository.getPinContentList(memberId = creator.id!!, active = true))
.thenReturn(listOf(oldestActivePinContent) + createPinContents(8))
service.pinToTheTop(contentId = audioContent.id!!, member = creator)
assertEquals(false, oldestActivePinContent.isActive)
assertTrue(inactivePinContent.isActive)
}
private fun createMember(id: Long, nickname: String): Member {
val member = Member(
email = "$nickname@test.com",
@@ -496,6 +618,10 @@ class AudioContentServiceTest {
return audioContent
}
private fun createPinContents(count: Int): List<PinContent> {
return (1..count).map { PinContent() }
}
private fun stubSuccessfulDetailDependencies(viewer: Member, creator: Member, audioContent: AudioContent) {
Mockito.`when`(repository.findById(audioContent.id!!)).thenReturn(Optional.of(audioContent))
Mockito.`when`(explorerQueryRepository.getMember(creator.id!!)).thenReturn(creator)

View File

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

View File

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

View File

@@ -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",

View File

@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.content.AudioContent
import kr.co.vividnext.sodalive.content.ContentType
import kr.co.vividnext.sodalive.content.order.Order
import kr.co.vividnext.sodalive.content.order.OrderType
import kr.co.vividnext.sodalive.content.pin.PinContent
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
@@ -513,6 +514,66 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
assertTrue(records.last().isPointAvailable)
}
@Test
@DisplayName("홈 오디오 목록은 최신 오디오를 제외하고 활성 고정 콘텐츠를 최근 고정순으로 먼저 조회한다")
fun shouldSortHomeAudioContentsByActivePinBeforeReleaseDate() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("home-audio-pin-creator", MemberRole.CREATOR)
val pinnedRecent = saveAudioContent(creator, now.minusDays(5), isAdult = false)
val pinnedOlder = saveAudioContent(creator, now.minusDays(4), isAdult = false)
val normal = saveAudioContent(creator, now.minusDays(3), isAdult = false)
val inactivePinned = saveAudioContent(creator, now.minusDays(2), isAdult = false)
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
savePinContent(creator, latest, isActive = true, updatedAt = now.minusMinutes(5))
savePinContent(creator, pinnedRecent, isActive = true, updatedAt = now.minusMinutes(10))
savePinContent(creator, pinnedOlder, isActive = true, updatedAt = now.minusMinutes(20))
savePinContent(creator, inactivePinned, isActive = false, updatedAt = now.minusMinutes(1))
flushAndClear()
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
val records = repository.findAudioContents(
creator.id!!,
now,
latestAudioContentId = latestRecord!!.audioContentId,
canViewAdultContent = false,
limit = 9
)
assertEquals(latest.id, latestRecord.audioContentId)
assertEquals(
listOf(pinnedRecent.id, pinnedOlder.id, inactivePinned.id, normal.id),
records.map { it.audioContentId }
)
}
@Test
@DisplayName("홈 오디오 목록은 활성 고정 후보가 9개를 넘어도 최대 9개만 조회한다")
fun shouldLimitHomeAudioContentsToNineWhenActivePinnedCandidatesExceedLimit() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("home-audio-pin-limit-creator", MemberRole.CREATOR)
val pinnedContents = (1..10).map { index ->
saveAudioContent(creator, now.minusDays(index.toLong() + 1), isAdult = false)
}
val latest = saveAudioContent(creator, now.minusDays(1), isAdult = false)
pinnedContents.forEachIndexed { index, content ->
savePinContent(creator, content, isActive = true, updatedAt = now.minusMinutes(index.toLong()))
}
flushAndClear()
val latestRecord = repository.findLatestAudioContent(creator.id!!, now, canViewAdultContent = false)
val records = repository.findAudioContents(
creator.id!!,
now,
latestAudioContentId = latestRecord!!.audioContentId,
canViewAdultContent = false,
limit = 9
)
assertEquals(latest.id, latestRecord.audioContentId)
assertEquals(9, records.size)
assertEquals(pinnedContents.take(9).map { it.id }, records.map { it.audioContentId })
}
@Test
@DisplayName("최신 오디오와 오디오 목록은 조회자의 유효한 소장/대여 주문 상태를 함께 반환한다")
fun shouldFindAudioContentOwnershipFlagsByViewerOrders() {
@@ -1154,6 +1215,21 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
return seriesContent
}
private fun savePinContent(
creator: Member,
content: AudioContent,
isActive: Boolean,
updatedAt: LocalDateTime
): PinContent {
val pinContent = PinContent(isActive = isActive)
pinContent.member = creator
pinContent.content = content
entityManager.persist(pinContent)
entityManager.flush()
updateUpdatedAt("PinContent", pinContent.id!!, updatedAt)
return pinContent
}
private fun saveDonation(
creator: Member,
donor: Member,

View File

@@ -108,7 +108,7 @@ class CreatorChannelHomeQueryServiceTest {
fun shouldPassViewerCreatorFlagToLivePolicyQueries() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null)
val viewer = createMember(id = 1L, gender = Gender.FEMALE, authGender = null, role = MemberRole.CREATOR)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
@@ -118,6 +118,64 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals(Gender.FEMALE, port.schedulesEffectiveViewerGender)
}
@Test
@DisplayName("조회자가 다른 크리에이터여도 라이브 조회 정책 컨텍스트에 크리에이터 여부를 전달한다")
fun shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 10L, role = MemberRole.CREATOR)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertTrue(port.currentLiveIsViewerCreator == true)
assertTrue(port.schedulesIsViewerCreator == true)
}
@Test
@DisplayName("일반 회원 조회자는 라이브 조회 정책 컨텍스트에 크리에이터가 아닌 것으로 전달한다")
fun shouldPassNonCreatorFlagWhenViewerIsUser() {
val port = FakeCreatorChannelHomeQueryPort()
val service = createService(port)
val viewer = createMember(id = 10L, role = MemberRole.USER)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertFalse(port.currentLiveIsViewerCreator == true)
assertFalse(port.schedulesIsViewerCreator == true)
}
@Test
@DisplayName("홈 조회는 저장된 콘텐츠 선호를 한 번만 읽어 성인 필터에 재사용한다")
fun shouldReuseStoredPreferenceAdultFlagForAdultFilters() {
val port = FakeCreatorChannelHomeQueryPort()
val communityPort = FakeCreatorChannelCommunityQueryPort()
val preferenceService = createPreferenceService(canViewAdultContent = false)
val service = createService(port, communityPort, preferenceService = preferenceService)
val viewer = createMember(id = 10L)
service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertEquals(false, port.currentLiveCanViewAdultContent)
assertEquals(false, port.schedulesCanViewAdultContent)
assertEquals(listOf(false, false), communityPort.homeCanViewAdultContentValues)
Mockito.verify(preferenceService).getStoredPreference(viewer)
Mockito.verify(preferenceService, Mockito.never()).canViewAdultContent(viewer)
}
@Test
@DisplayName("홈 채널 후원자는 삭제 회원 닉네임 prefix를 제거해 조립한다")
fun shouldRemoveDeletedNicknamePrefixFromChannelDonation() {
val port = FakeCreatorChannelHomeQueryPort().apply {
channelDonationNickname = "deleted_donor"
}
val service = createService(port)
val viewer = createMember(id = 10L)
val home = service.getHome(creatorId = 1L, viewer = viewer, now = LocalDateTime.of(2026, 6, 13, 10, 0))
assertEquals("donor", home.channelDonations.first().nickname)
}
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
@@ -409,22 +467,9 @@ class CreatorChannelHomeQueryServiceTest {
private fun createService(
port: FakeCreatorChannelHomeQueryPort,
communityPort: FakeCreatorChannelCommunityQueryPort = FakeCreatorChannelCommunityQueryPort(),
canViewAdultContent: Boolean = true
canViewAdultContent: Boolean = true,
preferenceService: MemberContentPreferenceService = createPreferenceService(canViewAdultContent)
): CreatorChannelHomeQueryService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = canViewAdultContent,
contentType = ContentType.ALL,
isAdult = canViewAdultContent
)
)
Mockito.`when`(
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(canViewAdultContent)
val messageSource = SodaMessageSource()
val langContext = LangContext()
langContext.setLang(Lang.KO)
@@ -450,17 +495,37 @@ class CreatorChannelHomeQueryServiceTest {
)
}
private fun createPreferenceService(canViewAdultContent: Boolean): MemberContentPreferenceService {
val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
Mockito.`when`(
preferenceService.getStoredPreference(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(
ViewerContentPreference(
countryCode = "US",
isAdultContentVisible = canViewAdultContent,
contentType = ContentType.ALL,
isAdult = canViewAdultContent
)
)
Mockito.`when`(
preferenceService.canViewAdultContent(Mockito.any(Member::class.java) ?: createMember(id = 0L))
).thenReturn(canViewAdultContent)
return preferenceService
}
private fun createMember(
id: Long,
gender: Gender = Gender.NONE,
authGender: Int? = null
authGender: Int? = null,
role: MemberRole = MemberRole.USER
): Member {
val member = Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL,
gender = gender
gender = gender,
role = role
)
member.id = id
authGender?.let {
@@ -500,6 +565,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var schedulesEffectiveViewerGender: Gender? = null
var schedulesCanViewAdultContent: Boolean? = null
var audioContentsLatestAudioContentId: Long? = null
var channelDonationNickname: String = "fan"
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? = creator
@@ -541,7 +607,7 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
limit: Int
): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord(
nickname = "fan",
nickname = channelDonationNickname,
profileImagePath = "profile/fan.png",
can = 30,
message = "thanks",