test(creator-channel): 후원 탭 E2E 검증을 추가한다

This commit is contained in:
2026-06-22 21:12:22 +09:00
parent 02d5446888
commit 2c44cb90ee
2 changed files with 258 additions and 2 deletions

View File

@@ -469,7 +469,7 @@ data class CreatorChannelDonationRankingRecord(
### Phase 3: 통합 검증과 회귀 확인
- [ ] **Task 3.1: 후원 탭 End-to-End 테스트 추가**
- [x] **Task 3.1: 후원 탭 End-to-End 테스트 추가**
- 파일:
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt`
@@ -494,8 +494,11 @@ data class CreatorChannelDonationRankingRecord(
- REFACTOR: End-to-End 테스트 fixture helper 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- E2E: `CreatorChannelDonationEndToEndTest`를 추가한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, 기존 Phase 2 wiring으로 `BUILD SUCCESSFUL` 확인.
- 검증 범위: 기본 Spring context endpoint 등록, controller-service-repository-legacy ranking 통합, page 범위 밖 응답, page/size 보정, 일반 조회자 비공개 후원/랭킹 숨김, 크리에이터 본인 비공개 후원 및 `donationCan` 노출을 확인.
- [ ] **Task 3.2: 관련 테스트와 아키텍처 의존 방향 검증**
- [x] **Task 3.2: 관련 테스트와 아키텍처 의존 방향 검증**
- 파일:
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation`
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation`
@@ -516,6 +519,12 @@ data class CreatorChannelDonationRankingRecord(
- Expected: 별도 feature flag rollout 정책을 유지하기로 문서화한 경우가 아니라면 검색 결과 0건
- Run: `./gradlew ktlintCheck`
- Expected: `BUILD SUCCESSFUL`
- 실행 기록:
- 관련 테스트 묶음: `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacadeTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationControllerTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence.DefaultCreatorChannelDonationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.legacy.LegacyCreatorChannelDonationRankingAdapterTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest` 실행, `BUILD SUCCESSFUL` 확인.
- 의존 방향: `rg -n "v2\.api" src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation` 실행, 검색 결과 0건 확인.
- endpoint mapping: `rg -n "class CreatorChannelDonationController|/\{creatorId\}/donations" src/main/kotlin/kr/co/vividnext/sodalive/v2` 실행, controller class와 endpoint mapping 각 1건 확인.
- feature flag: `rg -n "ConditionalOnProperty|creator-channel\.donation-tab\.enabled" src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation` 실행, 검색 결과 0건 확인.
- format: `./gradlew ktlintCheck` 실행, `BUILD SUCCESSFUL` 확인.
---
@@ -531,3 +540,5 @@ data class CreatorChannelDonationRankingRecord(
## 6. 전체 검증 기록
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색, `ktlintCheck` 모두 성공했다.

View File

@@ -0,0 +1,245 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web
import kr.co.vividnext.sodalive.can.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
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 kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
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.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 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-donation-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE"
]
)
@AutoConfigureMockMvc
@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class])
class CreatorChannelDonationEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
) {
@Test
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() {
val fixture = createFixture("donation-e2e-success", isVisibleDonationRank = true)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", "0")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.rankings.length()").value(1))
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
.andExpect(jsonPath("$.data.rankings[0].nickname").value(fixture.viewer.nickname))
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}"))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(0))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.donations[0].nickname").value("donation-e2e-success-viewer"))
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png"))
.andExpect(jsonPath("$.data.donations[0].can").value(200))
.andExpect(jsonPath("$.data.donations[0].message").value("own secret"))
.andExpect(jsonPath("$.data.donations[0].createdAtUtc").exists())
.andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-other"))
.andExpect(jsonPath("$.data.donations[1].can").value(100))
.andExpect(jsonPath("$.data.donations[1].message").value("public"))
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isEmpty)
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("후원 탭 API는 page 범위 밖 요청에 빈 목록과 유지된 count를 반환한다")
fun shouldReturnEmptyDonationsAndKeepCountForOutOfRangePage() {
val fixture = createFixture("donation-e2e-out-of-range", isVisibleDonationRank = true)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", "1")
.param("size", "20")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donations.length()").value(0))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("후원 탭 API는 page와 size를 정책 범위로 보정한다")
fun shouldClampPageAndSize() {
val fixture = createFixture("donation-e2e-clamp", isVisibleDonationRank = true)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.param("page", "-1")
.param("size", "100")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50))
.andExpect(jsonPath("$.data.donations.length()").value(2))
}
@Test
@DisplayName("후원 랭킹 비공개 크리에이터는 일반 조회자에게 빈 랭킹과 정상 후원 목록을 반환한다")
fun shouldReturnEmptyRankingsAndDonationTabForHiddenRankingCreator() {
val fixture = createFixture("donation-e2e-hidden-ranking", isVisibleDonationRank = false)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.rankings.length()").value(0))
.andExpect(jsonPath("$.data.donationCount").value(2))
.andExpect(jsonPath("$.data.donations.length()").value(2))
.andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false))
}
@Test
@DisplayName("크리에이터 본인 조회는 비공개 후원과 실제 donationCan 랭킹을 포함한다")
fun shouldReturnPrivateDonationsAndDonationCanForCreatorSelf() {
val fixture = createFixture("donation-e2e-self", isVisibleDonationRank = false)
mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/donations")
.with(user(MemberAdapter(fixture.creator)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.rankings.length()").value(1))
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(500))
.andExpect(jsonPath("$.data.donations.length()").value(3))
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty)
}
private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture {
return transactionTemplate.execute {
val monthStart = CreatorChannelDonationQueryPolicy()
.currentKstMonthRange(LocalDateTime.now())
.startInclusiveUtc
val now = monthStart.plusDays(10)
val viewer = saveMember("$prefix-viewer", MemberRole.USER)
val creator = saveMember(
"$prefix-creator",
MemberRole.CREATOR,
isVisibleDonationRank = isVisibleDonationRank
)
val otherDonor = saveMember("$prefix-other", MemberRole.USER)
saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public")
saveDonation(creator, viewer, 200, now.minusHours(2), isSecret = true, additionalMessage = "own secret")
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
saveRankingDonation(viewer, creator, can = 450, rewardCan = 50)
entityManager.flush()
entityManager.clear()
Fixture(
viewer = viewer,
creator = creator,
creatorId = creator.id!!
)
}!!
}
private fun saveMember(
nickname: String,
role: MemberRole,
isVisibleDonationRank: Boolean = true
): Member {
val member = Member(
email = "$nickname@test.com",
password = "password",
nickname = nickname,
profileImage = "$nickname.png",
role = role,
isVisibleDonationRank = isVisibleDonationRank,
donationRankingPeriod = DonationRankingPeriod.CUMULATIVE
)
entityManager.persist(member)
return member
}
private fun saveDonation(
creator: Member,
donor: Member,
can: Int,
createdAt: LocalDateTime,
isSecret: Boolean = false,
additionalMessage: String
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
entityManager.flush()
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
return donation
}
private fun saveRankingDonation(donor: Member, creator: Member, can: Int, rewardCan: Int) {
val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false)
useCan.member = donor
entityManager.persist(useCan)
val calculate = UseCanCalculate(
can = can + rewardCan,
paymentGateway = PaymentGateway.PG,
status = UseCanCalculateStatus.RECEIVED
)
calculate.useCan = useCan
calculate.recipientCreatorId = creator.id
entityManager.persist(calculate)
}
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 creator: Member,
val creatorId: Long
)
}