From 2c44cb90ee50c288de109a85494beab953de73a7 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 22 Jun 2026 21:12:22 +0900 Subject: [PATCH] =?UTF-8?q?test(creator-channel):=20=ED=9B=84=EC=9B=90=20?= =?UTF-8?q?=ED=83=AD=20E2E=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plan-task.md | 15 +- .../web/CreatorChannelDonationEndToEndTest.kt | 245 ++++++++++++++++++ 2 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt diff --git a/docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md b/docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md index f51afb02..e7efe60e 100644 --- a/docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md +++ b/docs/20260622_크리에이터_채널_후원_탭_API/plan-task.md @@ -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` 모두 성공했다. diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt new file mode 100644 index 00000000..f9084953 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationEndToEndTest.kt @@ -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 + ) +}