test(creator-channel): 후원 탭 E2E 검증을 추가한다
This commit is contained in:
@@ -469,7 +469,7 @@ data class CreatorChannelDonationRankingRecord(
|
|||||||
|
|
||||||
### Phase 3: 통합 검증과 회귀 확인
|
### 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`
|
- 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`
|
- 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 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
|
- REFACTOR: End-to-End 테스트 fixture helper 중복을 줄이되 테스트 의도를 흐리지 않는 범위에서만 정리한다.
|
||||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.in.web.CreatorChannelDonationEndToEndTest`
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
- 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/api/creator/channel/donation`
|
||||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/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건
|
- Expected: 별도 feature flag rollout 정책을 유지하기로 문서화한 경우가 아니라면 검색 결과 0건
|
||||||
- Run: `./gradlew ktlintCheck`
|
- Run: `./gradlew ktlintCheck`
|
||||||
- Expected: `BUILD SUCCESSFUL`
|
- 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. 전체 검증 기록
|
## 6. 전체 검증 기록
|
||||||
|
|
||||||
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
|
- Phase 1 검증은 각 Task 실행 기록에 누적했다.
|
||||||
|
- Phase 2 검증은 각 Task 실행 기록에 누적했다.
|
||||||
|
- Phase 3 검증은 Task 3.1, Task 3.2 실행 기록에 누적했다. 단일 E2E, 관련 테스트 묶음, 의존 방향 검색, endpoint mapping 검색, feature flag 검색, `ktlintCheck` 모두 성공했다.
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user