feat(creator-channel): 후원 탭 조회 서비스를 구현한다
This commit is contained in:
-16
@@ -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() {
|
||||
|
||||
+374
-7
@@ -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<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 {
|
||||
@@ -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<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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user