diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt index 42348e08..ca0b8b42 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt @@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -13,7 +12,6 @@ import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @RestController -@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true") @RequestMapping("/api/v2/creator-channels") class CreatorChannelDonationController( private val creatorChannelDonationFacade: CreatorChannelDonationFacade diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt index e7527729..38ec62b8 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt @@ -1,13 +1,39 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation.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.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord +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 -class CreatorChannelDonationQueryService { +@Transactional(readOnly = true) +class CreatorChannelDonationQueryService( + private val queryPortProvider: ObjectProvider, + private val rankingPort: CreatorChannelDonationRankingPort, + private val queryPolicy: CreatorChannelDonationQueryPolicy, + private val messageSource: SodaMessageSource, + private val langContext: LangContext, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { fun getDonationTab( creatorId: Long, viewer: Member, @@ -15,6 +41,73 @@ class CreatorChannelDonationQueryService { size: Int?, now: LocalDateTime ): CreatorChannelDonationTab { - throw SodaException(messageKey = "common.error.invalid_request") + val donationPage = 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 fetchedDonations = queryPort.findChannelDonations( + creatorId = creatorId, + viewerId = viewerId, + now = now, + offset = donationPage.offset, + limit = donationPage.fetchLimit + ) + + return CreatorChannelDonationTab( + donationCount = queryPort.countChannelDonations(creatorId, viewerId, now), + rankings = findRankings(creator, viewerId), + donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain() }, + page = donationPage, + hasNext = queryPolicy.hasNext(fetchedDonations, donationPage) + ) } + + private fun validateCreatorRole(creator: CreatorChannelDonationCreatorRecord) { + when (creator.role) { + MemberRole.CREATOR -> return + else -> throw SodaException(messageKey = "member.validation.creator_not_found") + } + } + + private fun findRankings( + creator: CreatorChannelDonationCreatorRecord, + viewerId: Long + ): List { + val isViewerCreator = viewerId == creator.creatorId + if (!isViewerCreator && !creator.isVisibleDonationRank) return emptyList() + + return rankingPort.findTopRankings( + creatorId = creator.creatorId, + period = creator.donationRankingPeriod ?: DonationRankingPeriod.CUMULATIVE, + withDonationCan = isViewerCreator + ).map { it.toDomain() } + } + + private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( + nickname = nickname.removeDeletedNicknamePrefix(), + profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), + can = can, + message = message.orEmpty(), + createdAt = createdAt + ) + + private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking( + userId = userId, + nickname = nickname, + profileImage = profileImage, + donationCan = donationCan + ) + + private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png" } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt index 124a8ea5..addcd495 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt @@ -10,13 +10,10 @@ import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.Crea import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.MemberDonationRankingResponse -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull 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.autoconfigure.condition.ConditionalOnProperty import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.mock.mockito.MockBean @@ -28,7 +25,6 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.HttpStatusEntryPoint -import org.springframework.test.context.TestPropertySource 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 @@ -38,7 +34,6 @@ import javax.servlet.http.HttpServletResponse @WebMvcTest(CreatorChannelDonationController::class) @Import(CreatorChannelDonationControllerTest.TestSecurityConfig::class) -@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"]) class CreatorChannelDonationControllerTest @Autowired constructor( private val mockMvc: MockMvc ) { @@ -71,17 +66,6 @@ class CreatorChannelDonationControllerTest @Autowired constructor( } } - @Test - @DisplayName("크리에이터 채널 후원 탭 controller는 Phase 2 완료 전 기본 등록되지 않도록 property로 보호된다") - fun shouldProtectDonationControllerWithFeatureProperty() { - val condition = CreatorChannelDonationController::class.java.getAnnotation(ConditionalOnProperty::class.java) - - assertNotNull(condition) - assertEquals("creator-channel.donation-tab.enabled", condition.name.first()) - assertEquals("true", condition.havingValue) - assertEquals(false, condition.matchIfMissing) - } - @Test @DisplayName("크리에이터 채널 후원 탭 조회는 비회원 요청을 거부한다") fun shouldRejectAnonymousCreatorChannelDonationRequest() { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt index 203b79c5..a610c647 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt @@ -1,31 +1,302 @@ package kr.co.vividnext.sodalive.v2.creator.channel.donation.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.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingPort +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRankingRecord +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows 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 CreatorChannelDonationQueryServiceTest { @Test - @DisplayName("후원 탭 query service placeholder는 내부 예외 대신 명시적인 API 오류를 던진다") - fun shouldThrowSodaExceptionUntilPhase2Implementation() { - val service = CreatorChannelDonationQueryService() + @DisplayName("조회 대상 회원이 없으면 user_not_found 예외를 던진다") + fun shouldThrowUserNotFoundWhenCreatorMissing() { + val queryPort = FakeDonationQueryPort(creator = null) + val service = createService(queryPort = queryPort) val exception = assertThrows(SodaException::class.java) { service.getDonationTab( - creatorId = 1L, - viewer = createMember(id = 10L), + creatorId = CREATOR_ID, + viewer = createMember(VIEWER_ID), page = 0, size = 20, - now = LocalDateTime.of(2026, 6, 22, 3, 0) + now = NOW ) } - assertEquals("common.error.invalid_request", exception.messageKey) + assertEquals("member.validation.user_not_found", exception.messageKey) + } + + @Test + @DisplayName("조회 대상 회원이 크리에이터가 아니면 creator_not_found 예외를 던진다") + fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() { + val queryPort = FakeDonationQueryPort( + creator = createCreator(role = MemberRole.USER) + ) + val service = createService(queryPort = queryPort) + + val exception = assertThrows(SodaException::class.java) { + service.getDonationTab( + creatorId = CREATOR_ID, + viewer = createMember(VIEWER_ID), + page = 0, + size = 20, + now = NOW + ) + } + + assertEquals("member.validation.creator_not_found", exception.messageKey) + } + + @Test + @DisplayName("조회자와 크리에이터 사이 차단 관계가 있으면 차단 메시지 예외를 던진다") + fun shouldThrowBlockedAccessMessageWhenBlocked() { + val queryPort = FakeDonationQueryPort(blocked = true) + val service = createService(queryPort = queryPort) + + val exception = assertThrows(SodaException::class.java) { + service.getDonationTab( + creatorId = CREATOR_ID, + viewer = createMember(VIEWER_ID), + page = 0, + size = 20, + now = NOW + ) + } + + assertEquals("creator-nickname님의 요청으로 채널 접근이 제한됩니다.", exception.message) + } + + @Test + @DisplayName("페이지 보정값으로 목록을 조회하고 응답 목록과 hasNext를 조립한다") + fun shouldUseResolvedPageForDonationQueryAndLimitResponseItems() { + val queryPort = FakeDonationQueryPort( + donations = (1..21).map { + createDonationRecord(nickname = "donor$it", message = "message$it") + }, + donationCount = 30 + ) + val service = createService(queryPort = queryPort) + + val tab = service.getDonationTab( + creatorId = CREATOR_ID, + viewer = createMember(VIEWER_ID), + page = -1, + size = 10, + now = NOW + ) + + assertEquals(0L, queryPort.lastFindDonationRequest?.offset) + assertEquals(21, queryPort.lastFindDonationRequest?.limit) + assertEquals(CREATOR_ID, queryPort.lastCountDonationRequest?.creatorId) + assertEquals(VIEWER_ID, queryPort.lastCountDonationRequest?.viewerId) + assertEquals(NOW, queryPort.lastCountDonationRequest?.now) + assertEquals(30, tab.donationCount) + assertEquals(20, tab.donations.size) + assertEquals("donor1", tab.donations.first().nickname) + assertEquals("message1", tab.donations.first().message) + assertEquals(0, tab.page.page) + assertEquals(20, tab.page.size) + assertEquals(true, tab.hasNext) + } + + @Test + @DisplayName("후원 목록은 닉네임, 프로필 이미지, 메시지를 도메인 응답 값으로 변환한다") + fun shouldMapDonationRecordsToDomainValues() { + val queryPort = FakeDonationQueryPort( + donations = listOf( + createDonationRecord( + nickname = "deleted_donor", + profileImagePath = "profile/donor.png", + message = null + ), + createDonationRecord( + nickname = "default-image-donor", + profileImagePath = null, + message = "thanks" + ) + ) + ) + val service = createService(queryPort = queryPort) + + val tab = service.getDonationTab( + creatorId = CREATOR_ID, + viewer = createMember(VIEWER_ID), + page = 0, + size = 20, + now = NOW + ) + + assertEquals("donor", tab.donations[0].nickname) + assertEquals("https://cdn.test/profile/donor.png", tab.donations[0].profileImageUrl) + assertEquals("", tab.donations[0].message) + assertEquals("default-image-donor", tab.donations[1].nickname) + assertEquals("https://cdn.test/profile/default-profile.png", tab.donations[1].profileImageUrl) + assertEquals("thanks", tab.donations[1].message) + } + + @Test + @DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다") + fun shouldFetchRankingsWithDonationCanForCreatorViewer() { + val queryPort = FakeDonationQueryPort( + creator = createCreator(isVisibleDonationRank = false, donationRankingPeriod = DonationRankingPeriod.WEEKLY) + ) + val rankingPort = FakeDonationRankingPort() + val service = createService(queryPort = queryPort, rankingPort = rankingPort) + + val tab = service.getDonationTab( + creatorId = CREATOR_ID, + viewer = createMember(CREATOR_ID), + page = 0, + size = 20, + now = NOW + ) + + assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, true), rankingPort.requests.single()) + assertEquals(createRankingRecord(), rankingPort.records.single()) + assertEquals(tab.rankings.single().userId, rankingPort.records.single().userId) + assertEquals(tab.rankings.single().nickname, rankingPort.records.single().nickname) + assertEquals(tab.rankings.single().profileImage, rankingPort.records.single().profileImage) + assertEquals(tab.rankings.single().donationCan, rankingPort.records.single().donationCan) + } + + @Test + @DisplayName("일반 조회자는 공개 랭킹을 크리에이터 설정 기간과 donationCan 제외 조건으로 조회한다") + fun shouldFetchVisibleRankingsForNonCreatorViewerWithConfiguredPeriod() { + val weeklyRankingPort = FakeDonationRankingPort() + createService( + queryPort = FakeDonationQueryPort( + creator = createCreator( + isVisibleDonationRank = true, + donationRankingPeriod = DonationRankingPeriod.WEEKLY + ) + ), + rankingPort = weeklyRankingPort + ).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + + val cumulativeRankingPort = FakeDonationRankingPort() + createService( + queryPort = FakeDonationQueryPort( + creator = createCreator( + isVisibleDonationRank = true, + donationRankingPeriod = DonationRankingPeriod.CUMULATIVE + ) + ), + rankingPort = cumulativeRankingPort + ).getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + + assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.WEEKLY, false), weeklyRankingPort.requests.single()) + assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), cumulativeRankingPort.requests.single()) + } + + @Test + @DisplayName("크리에이터 랭킹 기간이 없으면 누적 랭킹으로 조회한다") + fun shouldUseCumulativeRankingPeriodWhenCreatorPeriodIsNull() { + val rankingPort = FakeDonationRankingPort() + val service = createService( + queryPort = FakeDonationQueryPort( + creator = createCreator(isVisibleDonationRank = true, donationRankingPeriod = null) + ), + rankingPort = rankingPort + ) + + service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + + assertEquals(RankingRequest(CREATOR_ID, DonationRankingPeriod.CUMULATIVE, false), rankingPort.requests.single()) + } + + @Test + @DisplayName("일반 조회자에게 랭킹이 비공개이면 랭킹 조회 없이 후원 탭 본문을 조립한다") + fun shouldSkipRankingsWhenHiddenFromNonCreatorViewer() { + val queryPort = FakeDonationQueryPort( + creator = createCreator(isVisibleDonationRank = false), + donationCount = 2, + donations = listOf( + createDonationRecord(nickname = "donor1"), + createDonationRecord(nickname = "donor2") + ) + ) + val rankingPort = FakeDonationRankingPort() + val service = createService(queryPort = queryPort, rankingPort = rankingPort) + + val tab = service.getDonationTab(CREATOR_ID, createMember(VIEWER_ID), 0, 20, NOW) + + assertEquals(emptyList(), rankingPort.requests) + assertEquals(emptyList(), tab.rankings) + assertEquals(2, tab.donationCount) + assertEquals(2, tab.donations.size) + assertEquals(0, tab.page.page) + assertEquals(20, tab.page.size) + assertFalse(tab.hasNext) + } + + private fun createService( + queryPort: FakeDonationQueryPort = FakeDonationQueryPort(), + rankingPort: FakeDonationRankingPort = FakeDonationRankingPort() + ): CreatorChannelDonationQueryService { + val provider = Mockito.mock(ObjectProvider::class.java) as ObjectProvider + Mockito.doReturn(queryPort).`when`(provider).getObject() + return CreatorChannelDonationQueryService( + queryPortProvider = provider, + rankingPort = rankingPort, + queryPolicy = CreatorChannelDonationQueryPolicy(), + messageSource = SodaMessageSource(), + langContext = LangContext(), + cloudFrontHost = "https://cdn.test" + ) + } + + private fun createCreator( + role: MemberRole = MemberRole.CREATOR, + isVisibleDonationRank: Boolean = true, + donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE + ): CreatorChannelDonationCreatorRecord { + return CreatorChannelDonationCreatorRecord( + creatorId = CREATOR_ID, + role = role, + nickname = "creator-nickname", + isVisibleDonationRank = isVisibleDonationRank, + donationRankingPeriod = donationRankingPeriod + ) + } + + private fun createDonationRecord( + nickname: String = "donor", + profileImagePath: String? = "profile/donor.png", + can: Int = 100, + message: String? = "thanks", + createdAt: LocalDateTime = NOW + ): CreatorChannelDonationRecord { + return CreatorChannelDonationRecord( + nickname = nickname, + profileImagePath = profileImagePath, + can = can, + message = message, + createdAt = createdAt + ) + } + + private fun createRankingRecord(): CreatorChannelDonationRankingRecord { + return CreatorChannelDonationRankingRecord( + userId = VIEWER_ID, + nickname = "fan", + profileImage = "https://cdn.test/fan.png", + donationCan = 300 + ) } private fun createMember(id: Long): Member { @@ -36,4 +307,100 @@ class CreatorChannelDonationQueryServiceTest { role = MemberRole.USER ).apply { this.id = id } } + + private class FakeDonationQueryPort( + private val creator: CreatorChannelDonationCreatorRecord? = defaultCreator(), + private val blocked: Boolean = false, + private val donationCount: Int = 0, + private val donations: List = emptyList() + ) : CreatorChannelDonationQueryPort { + var lastCountDonationRequest: CountDonationRequest? = null + private set + var lastFindDonationRequest: FindDonationRequest? = null + private set + + override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? { + return creator + } + + override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean { + return blocked + } + + override fun countChannelDonations(creatorId: Long, viewerId: Long, now: LocalDateTime): Int { + lastCountDonationRequest = CountDonationRequest(creatorId, viewerId, now) + return donationCount + } + + override fun findChannelDonations( + creatorId: Long, + viewerId: Long, + now: LocalDateTime, + offset: Long, + limit: Int + ): List { + lastFindDonationRequest = FindDonationRequest(creatorId, viewerId, now, offset, limit) + return donations + } + } + + private class FakeDonationRankingPort( + val records: List = listOf(defaultRankingRecord()) + ) : CreatorChannelDonationRankingPort { + val requests = mutableListOf() + + override fun findTopRankings( + creatorId: Long, + period: DonationRankingPeriod, + withDonationCan: Boolean + ): List { + requests += RankingRequest(creatorId, period, withDonationCan) + return records + } + } + + private data class CountDonationRequest( + val creatorId: Long, + val viewerId: Long, + val now: LocalDateTime + ) + + private data class FindDonationRequest( + val creatorId: Long, + val viewerId: Long, + val now: LocalDateTime, + val offset: Long, + val limit: Int + ) + + private data class RankingRequest( + val creatorId: Long, + val period: DonationRankingPeriod, + val withDonationCan: Boolean + ) + + companion object { + private const val CREATOR_ID = 1L + private const val VIEWER_ID = 10L + private val NOW: LocalDateTime = LocalDateTime.of(2026, 6, 22, 3, 0) + + private fun defaultCreator(): CreatorChannelDonationCreatorRecord { + return CreatorChannelDonationCreatorRecord( + creatorId = CREATOR_ID, + role = MemberRole.CREATOR, + nickname = "creator-nickname", + isVisibleDonationRank = true, + donationRankingPeriod = DonationRankingPeriod.CUMULATIVE + ) + } + + private fun defaultRankingRecord(): CreatorChannelDonationRankingRecord { + return CreatorChannelDonationRankingRecord( + userId = VIEWER_ID, + nickname = "fan", + profileImage = "https://cdn.test/fan.png", + donationCan = 300 + ) + } + } }