From 3e3642bb7fbf87ef48990cbe4e3a3043a5df322f Mon Sep 17 00:00:00 2001 From: Klaus Date: Wed, 17 Jun 2026 18:20:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator):=20=EC=B1=84=EB=84=90=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=ED=83=AD=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreatorChannelLiveQueryService.kt | 137 ++++++ .../port/out/CreatorChannelLiveQueryPort.kt | 68 +++ .../CreatorChannelLiveQueryServiceTest.kt | 397 ++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt new file mode 100644 index 00000000..a07e3afb --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt @@ -0,0 +1,137 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelAudioContent +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLive +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveTab +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord +import org.springframework.beans.factory.ObjectProvider +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelLiveQueryService( + private val queryPortProvider: ObjectProvider, + private val queryPolicy: CreatorChannelLiveReplayQueryPolicy, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getLiveTab( + creatorId: Long, + viewer: Member, + sort: ContentSort, + page: Int, + size: Int, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelLiveTab { + val livePage = queryPolicy.createPage(page, size) + val queryPort = queryPortProvider.getObject() + val viewerId = viewer.id!! + val creator = queryPort.findCreator(creatorId, viewerId) + ?: throw SodaException(messageKey = "member.validation.user_not_found") + + if (queryPort.existsBlockedBetween(viewerId, creatorId)) { + val messageTemplate = messageSource + .getMessage("explorer.creator.blocked_access", langContext.lang) + .orEmpty() + throw SodaException(message = String.format(messageTemplate, creator.nickname)) + } + + validateCreatorRole(creator) + + val preference = memberContentPreferenceService.getStoredPreference(viewer) + val canViewAdultContent = isAdultVisibleByPolicy(viewer, preference.isAdultContentVisible) + val isViewerCreator = viewerId == creatorId + val effectiveViewerGender = viewer.effectiveGender() + val fetchedContents = queryPort.findLiveReplayAudioContents( + creatorId = creatorId, + viewerId = viewerId, + now = now, + canViewAdultContent = canViewAdultContent, + sort = sort, + offset = livePage.offset, + limit = livePage.fetchLimit + ) + + return CreatorChannelLiveTab( + liveReplayContentCount = queryPort.countLiveReplayAudioContents( + creatorId = creatorId, + now = now, + canViewAdultContent = canViewAdultContent + ), + currentLive = queryPort.findCurrentLive( + creatorId = creatorId, + now = now, + canViewAdultContent = canViewAdultContent, + viewerId = viewerId, + isViewerCreator = isViewerCreator, + effectiveViewerGender = effectiveViewerGender + )?.toDomain(), + liveReplayContents = queryPolicy.limitItems(fetchedContents, livePage).map { it.toDomain() }, + sort = sort, + page = livePage, + hasNext = queryPolicy.hasNext(fetchedContents, livePage) + ) + } + + private fun validateCreatorRole(creator: CreatorChannelCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun Member.effectiveGender(): Gender { + auth?.let { return if (it.gender == 1) Gender.MALE else Gender.FEMALE } + return gender + } + + private fun CreatorChannelLiveRecord.toDomain() = CreatorChannelLive( + liveId = liveId, + title = title, + coverImageUrl = coverImagePath.toCdnUrl(), + beginDateTime = beginDateTime, + price = price, + isAdult = isAdult + ) + + private fun CreatorChannelAudioContentRecord.toDomain() = CreatorChannelAudioContent( + audioContentId = audioContentId, + title = title, + duration = duration, + imageUrl = imagePath.toCdnUrl(), + price = price, + isAdult = isAdult, + isPointAvailable = isPointAvailable, + isFirstContent = isFirstContent, + publishedAt = publishedAt, + seriesName = seriesName, + isOriginalSeries = isOriginalSeries, + isOwned = isOwned, + isRented = isRented + ) + + private fun String?.toCdnUrl(): String? { + if (isNullOrBlank()) return null + if (startsWith("https://") || startsWith("http://")) return this + return "$cloudFrontHost/$this" + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt new file mode 100644 index 00000000..c9fd3631 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/port/out/CreatorChannelLiveQueryPort.kt @@ -0,0 +1,68 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live.port.out + +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import java.time.LocalDateTime + +interface CreatorChannelLiveQueryPort { + fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? + + fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean + + fun findCurrentLive( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? + ): CreatorChannelLiveRecord? + + fun countLiveReplayAudioContents( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Int + + fun findLiveReplayAudioContents( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + sort: ContentSort, + offset: Long, + limit: Int + ): List +} + +data class CreatorChannelCreatorRecord( + val creatorId: Long, + val role: MemberRole, + val nickname: String +) + +data class CreatorChannelLiveRecord( + val liveId: Long, + val title: String, + val coverImagePath: String?, + val beginDateTime: LocalDateTime, + val price: Int, + val isAdult: Boolean +) + +data class CreatorChannelAudioContentRecord( + val audioContentId: Long, + val title: String, + val duration: String?, + val imagePath: String?, + val price: Int, + val isAdult: Boolean, + val isPointAvailable: Boolean, + val isFirstContent: Boolean, + val publishedAt: LocalDateTime, + val seriesName: String?, + val isOriginalSeries: Boolean?, + val isOwned: Boolean, + val isRented: Boolean +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt new file mode 100644 index 00000000..c251c287 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt @@ -0,0 +1,397 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.live.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.content.ContentType +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.i18n.SodaMessageSource +import kr.co.vividnext.sodalive.member.Gender +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberProvider +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.auth.Auth +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelLiveReplayQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelAudioContentRecord +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.live.port.out.CreatorChannelLiveRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import org.springframework.beans.factory.ObjectProvider +import java.time.LocalDateTime + +class CreatorChannelLiveQueryServiceTest { + @Test + @DisplayName("라이브 탭 서비스는 검증 후 현재 라이브와 다시듣기 조회에 필요한 정책 컨텍스트를 전달한다") + fun shouldPassLiveTabQueryContextToPort() { + val port = FakeCreatorChannelLiveQueryPort() + val service = createService(port, canViewAdultContent = false) + val viewer = createMember(id = 10L, gender = Gender.FEMALE, authGender = 1) + val now = LocalDateTime.of(2026, 6, 17, 10, 0) + + val tab = service.getLiveTab( + creatorId = 1L, + viewer = viewer, + sort = ContentSort.LATEST, + page = 0, + size = 20, + now = now + ) + + assertEquals(1L, port.findCreatorCreatorId) + assertEquals(10L, port.findCreatorViewerId) + assertEquals(10L, port.existsBlockedViewerId) + assertEquals(1L, port.existsBlockedCreatorId) + assertEquals(10L, port.currentLiveViewerId) + assertFalse(port.currentLiveIsViewerCreator == true) + assertEquals(Gender.MALE, port.currentLiveEffectiveViewerGender) + assertEquals(false, port.currentLiveCanViewAdultContent) + assertEquals(false, port.countCanViewAdultContent) + assertEquals(false, port.listCanViewAdultContent) + assertEquals(ContentSort.LATEST, port.listSort) + assertEquals(0L, port.listOffset) + assertEquals(21, port.listLimit) + assertEquals("https://cdn.test/live.png", tab.currentLive?.coverImageUrl) + assertEquals("https://cdn.test/audio/1.png", tab.liveReplayContents.first().imageUrl) + } + + @Test + @DisplayName("라이브 탭 서비스는 size + 1개 조회 결과를 응답 size로 제한하고 hasNext를 true로 반환한다") + fun shouldAssembleLiveTabWithHasNextWhenFetchedMoreThanRequestedSize() { + val port = FakeCreatorChannelLiveQueryPort().apply { + liveReplayContents = (1L..21L).map { audioContentRecord(it) } + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val tab = service.getLiveTab( + creatorId = 1L, + viewer = viewer, + sort = ContentSort.LATEST, + page = 0, + size = 20, + now = LocalDateTime.of(2026, 6, 17, 10, 0) + ) + + assertEquals(30, tab.liveReplayContentCount) + assertEquals(20, tab.liveReplayContents.size) + assertEquals((1L..20L).toList(), tab.liveReplayContents.map { it.audioContentId }) + assertTrue(tab.hasNext) + assertEquals(ContentSort.LATEST, tab.sort) + assertEquals(0, tab.page.page) + assertEquals(20, tab.page.size) + assertTrue(tab.liveReplayContents.first().isOwned) + assertFalse(tab.liveReplayContents.first().isRented) + assertTrue(tab.liveReplayContents[1].isRented) + } + + @Test + @DisplayName("라이브 탭 서비스는 빈 page에서도 count와 요청 page 정보를 유지한다") + fun shouldKeepCountAndPageWhenReplayContentsAreEmpty() { + val port = FakeCreatorChannelLiveQueryPort().apply { + liveReplayContents = emptyList() + currentLive = null + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val tab = service.getLiveTab( + creatorId = 1L, + viewer = viewer, + sort = ContentSort.PRICE_LOW, + page = 2, + size = 20, + now = LocalDateTime.of(2026, 6, 17, 10, 0) + ) + + assertEquals(30, tab.liveReplayContentCount) + assertNull(tab.currentLive) + assertEquals(emptyList(), tab.liveReplayContents) + assertFalse(tab.hasNext) + assertEquals(ContentSort.PRICE_LOW, tab.sort) + assertEquals(2, tab.page.page) + assertEquals(20, tab.page.size) + assertEquals(40L, port.listOffset) + assertEquals(21, port.listLimit) + } + + @Test + @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { + val port = FakeCreatorChannelLiveQueryPort().apply { + creator = null + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + } + + assertEquals("member.validation.user_not_found", exception.messageKey) + } + + @Test + @DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다") + fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() { + val port = FakeCreatorChannelLiveQueryPort().apply { + creator = creator?.copy(role = MemberRole.USER) + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + } + + assertEquals("member.validation.creator_not_found", exception.messageKey) + } + + @Test + @DisplayName("차단 관계가 있으면 대상 회원이 크리에이터가 아니어도 접근 차단 예외를 먼저 던진다") + fun shouldThrowBlockedAccessBeforeCreatorNotFoundWhenViewerAndTargetAreBlocked() { + val port = FakeCreatorChannelLiveQueryPort().apply { + creator = creator?.copy(role = MemberRole.USER) + blocked = true + } + val service = createService(port) + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + } + + assertNull(exception.messageKey) + assertEquals("creator님의 요청으로 채널 접근이 제한됩니다.", exception.message) + } + + @Test + @DisplayName("잘못된 page 요청이면 invalid request 예외를 던진다") + fun shouldThrowInvalidRequestWhenPageIsNegative() { + val service = createServiceWithMissingPort() + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getLiveTab(1L, viewer, ContentSort.LATEST, -1, 20, LocalDateTime.of(2026, 6, 17, 10, 0)) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + } + + @Test + @DisplayName("잘못된 size 요청이면 port 조회 전에 invalid request 예외를 던진다") + fun shouldThrowInvalidRequestBeforePortLookupWhenSizeIsOutOfRange() { + val service = createServiceWithMissingPort() + val viewer = createMember(id = 10L) + + val exception = assertThrows(SodaException::class.java) { + service.getLiveTab(1L, viewer, ContentSort.LATEST, 0, 51, LocalDateTime.of(2026, 6, 17, 10, 0)) + } + + assertEquals("common.error.invalid_request", exception.messageKey) + } + + private fun createService( + port: FakeCreatorChannelLiveQueryPort, + canViewAdultContent: Boolean = true + ): CreatorChannelLiveQueryService { + return createService( + portProvider = FixedCreatorChannelLiveQueryPortProvider(port), + canViewAdultContent = canViewAdultContent + ) + } + + private fun createServiceWithMissingPort(): CreatorChannelLiveQueryService { + return createService(portProvider = MissingCreatorChannelLiveQueryPortProvider()) + } + + private fun createService( + portProvider: ObjectProvider, + canViewAdultContent: Boolean = true + ): CreatorChannelLiveQueryService { + 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 + ) + ) + val messageSource = SodaMessageSource() + val langContext = LangContext() + langContext.setLang(Lang.KO) + return CreatorChannelLiveQueryService( + queryPortProvider = portProvider, + queryPolicy = CreatorChannelLiveReplayQueryPolicy(), + memberContentPreferenceService = preferenceService, + messageSource = messageSource, + langContext = langContext, + cloudFrontHost = "https://cdn.test" + ) + } + + private fun createMember( + id: Long, + gender: Gender = Gender.NONE, + authGender: Int? = null + ): Member { + val member = Member( + email = "member$id@test.com", + password = "password", + nickname = "member$id", + provider = MemberProvider.EMAIL, + gender = gender + ) + member.id = id + authGender?.let { + Auth( + name = "name", + birth = "19900101", + uniqueCi = "ci$id", + di = "di$id", + gender = it + ).member = member + } + return member + } +} + +private class FixedCreatorChannelLiveQueryPortProvider( + private val port: CreatorChannelLiveQueryPort +) : ObjectProvider { + override fun getObject(vararg args: Any?): CreatorChannelLiveQueryPort = port + + override fun getIfAvailable(): CreatorChannelLiveQueryPort = port + + override fun getIfUnique(): CreatorChannelLiveQueryPort = port + + override fun getObject(): CreatorChannelLiveQueryPort = port +} + +private class MissingCreatorChannelLiveQueryPortProvider : ObjectProvider { + override fun getObject(vararg args: Any?): CreatorChannelLiveQueryPort { + throw IllegalStateException("port should not be resolved before page validation") + } + + override fun getIfAvailable(): CreatorChannelLiveQueryPort? = null + + override fun getIfUnique(): CreatorChannelLiveQueryPort? = null + + override fun getObject(): CreatorChannelLiveQueryPort { + throw IllegalStateException("port should not be resolved before page validation") + } +} + +private class FakeCreatorChannelLiveQueryPort : CreatorChannelLiveQueryPort { + var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord( + creatorId = 1L, + role = MemberRole.CREATOR, + nickname = "creator" + ) + var blocked = false + var currentLive: CreatorChannelLiveRecord? = CreatorChannelLiveRecord( + liveId = 101L, + title = "live", + coverImagePath = "live.png", + beginDateTime = LocalDateTime.of(2026, 6, 17, 9, 0), + price = 10, + isAdult = false + ) + var liveReplayContentCount = 30 + var liveReplayContents = (1L..21L).map { audioContentRecord(it) } + var findCreatorCreatorId: Long? = null + var findCreatorViewerId: Long? = null + var existsBlockedViewerId: Long? = null + var existsBlockedCreatorId: Long? = null + var currentLiveViewerId: Long? = null + var currentLiveIsViewerCreator: Boolean? = null + var currentLiveEffectiveViewerGender: Gender? = null + var currentLiveCanViewAdultContent: Boolean? = null + var countCanViewAdultContent: Boolean? = null + var listCanViewAdultContent: Boolean? = null + var listSort: ContentSort? = null + var listOffset: Long? = null + var listLimit: Int? = null + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCreatorRecord? { + findCreatorCreatorId = creatorId + findCreatorViewerId = viewerId + return creator + } + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { + existsBlockedViewerId = viewerId + existsBlockedCreatorId = creatorId + return blocked + } + + override fun findCurrentLive( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean, + viewerId: Long?, + isViewerCreator: Boolean, + effectiveViewerGender: Gender? + ): CreatorChannelLiveRecord? { + currentLiveViewerId = viewerId + currentLiveIsViewerCreator = isViewerCreator + currentLiveEffectiveViewerGender = effectiveViewerGender + currentLiveCanViewAdultContent = canViewAdultContent + return currentLive + } + + override fun countLiveReplayAudioContents( + creatorId: Long, + now: LocalDateTime, + canViewAdultContent: Boolean + ): Int { + countCanViewAdultContent = canViewAdultContent + return liveReplayContentCount + } + + override fun findLiveReplayAudioContents( + creatorId: Long, + viewerId: Long?, + now: LocalDateTime, + canViewAdultContent: Boolean, + sort: ContentSort, + offset: Long, + limit: Int + ): List { + listCanViewAdultContent = canViewAdultContent + listSort = sort + listOffset = offset + listLimit = limit + return liveReplayContents + } +} + +private fun audioContentRecord(audioContentId: Long): CreatorChannelAudioContentRecord { + return CreatorChannelAudioContentRecord( + audioContentId = audioContentId, + title = "audio-$audioContentId", + duration = "00:10:00", + imagePath = "audio/$audioContentId.png", + price = 10, + isAdult = false, + isPointAvailable = true, + isFirstContent = audioContentId == 1L, + publishedAt = LocalDateTime.of(2026, 6, 16, 10, 0), + seriesName = "series", + isOriginalSeries = true, + isOwned = audioContentId == 1L, + isRented = audioContentId == 2L + ) +}