test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
4 changed files with 469 additions and 27 deletions
Showing only changes of commit 046ce700c7 - Show all commits

View File

@@ -4,7 +4,6 @@ import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SodaException import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade 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.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable 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 import org.springframework.web.bind.annotation.RestController
@RestController @RestController
@ConditionalOnProperty(name = ["creator-channel.donation-tab.enabled"], havingValue = "true")
@RequestMapping("/api/v2/creator-channels") @RequestMapping("/api/v2/creator-channels")
class CreatorChannelDonationController( class CreatorChannelDonationController(
private val creatorChannelDonationFacade: CreatorChannelDonationFacade private val creatorChannelDonationFacade: CreatorChannelDonationFacade

View File

@@ -1,13 +1,39 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
import kr.co.vividnext.sodalive.common.SodaException 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.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.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.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
class CreatorChannelDonationQueryService { @Transactional(readOnly = true)
class CreatorChannelDonationQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelDonationQueryPort>,
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( fun getDonationTab(
creatorId: Long, creatorId: Long,
viewer: Member, viewer: Member,
@@ -15,6 +41,73 @@ class CreatorChannelDonationQueryService {
size: Int?, size: Int?,
now: LocalDateTime now: LocalDateTime
): CreatorChannelDonationTab { ): 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<CreatorChannelDonationRanking> {
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"
}

View File

@@ -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.CreatorChannelDonationResponse
import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse 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 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.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired 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.autoconfigure.web.servlet.WebMvcTest
import org.springframework.boot.test.context.TestConfiguration import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.mock.mockito.MockBean 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.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.HttpStatusEntryPoint 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.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get 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.jsonPath
@@ -38,7 +34,6 @@ import javax.servlet.http.HttpServletResponse
@WebMvcTest(CreatorChannelDonationController::class) @WebMvcTest(CreatorChannelDonationController::class)
@Import(CreatorChannelDonationControllerTest.TestSecurityConfig::class) @Import(CreatorChannelDonationControllerTest.TestSecurityConfig::class)
@TestPropertySource(properties = ["creator-channel.donation-tab.enabled=true"])
class CreatorChannelDonationControllerTest @Autowired constructor( class CreatorChannelDonationControllerTest @Autowired constructor(
private val mockMvc: MockMvc 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 @Test
@DisplayName("크리에이터 채널 후원 탭 조회는 비회원 요청을 거부한다") @DisplayName("크리에이터 채널 후원 탭 조회는 비회원 요청을 거부한다")
fun shouldRejectAnonymousCreatorChannelDonationRequest() { fun shouldRejectAnonymousCreatorChannelDonationRequest() {

View File

@@ -1,31 +1,302 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.application package kr.co.vividnext.sodalive.v2.creator.channel.donation.application
import kr.co.vividnext.sodalive.common.SodaException 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.Member
import kr.co.vividnext.sodalive.member.MemberRole 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.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime import java.time.LocalDateTime
class CreatorChannelDonationQueryServiceTest { class CreatorChannelDonationQueryServiceTest {
@Test @Test
@DisplayName("후원 탭 query service placeholder는 내부 예외 대신 명시적인 API 오류를 던진다") @DisplayName("조회 대상 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowSodaExceptionUntilPhase2Implementation() { fun shouldThrowUserNotFoundWhenCreatorMissing() {
val service = CreatorChannelDonationQueryService() val queryPort = FakeDonationQueryPort(creator = null)
val service = createService(queryPort = queryPort)
val exception = assertThrows(SodaException::class.java) { val exception = assertThrows(SodaException::class.java) {
service.getDonationTab( service.getDonationTab(
creatorId = 1L, creatorId = CREATOR_ID,
viewer = createMember(id = 10L), viewer = createMember(VIEWER_ID),
page = 0, page = 0,
size = 20, 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<RankingRequest>(), rankingPort.requests)
assertEquals(emptyList<Any>(), 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<CreatorChannelDonationQueryPort>
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 { private fun createMember(id: Long): Member {
@@ -36,4 +307,100 @@ class CreatorChannelDonationQueryServiceTest {
role = MemberRole.USER role = MemberRole.USER
).apply { this.id = id } ).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<CreatorChannelDonationRecord> = 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<CreatorChannelDonationRecord> {
lastFindDonationRequest = FindDonationRequest(creatorId, viewerId, now, offset, limit)
return donations
}
}
private class FakeDonationRankingPort(
val records: List<CreatorChannelDonationRankingRecord> = listOf(defaultRankingRecord())
) : CreatorChannelDonationRankingPort {
val requests = mutableListOf<RankingRequest>()
override fun findTopRankings(
creatorId: Long,
period: DonationRankingPeriod,
withDonationCan: Boolean
): List<CreatorChannelDonationRankingRecord> {
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
)
}
}
} }