From 4fbe4ced28da9c223cd4d1361f2214e24dde1889 Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 5 Nov 2026 02:20:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(channel-donation):=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=EC=9A=A9=20=ED=9B=84=EC=9B=90=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20API=EC=99=80=20=EC=A1=B0=ED=9A=8C=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/CreatorChannelDonationController.kt | 11 + .../CreatorChannelDonationFacade.kt | 9 +- .../dto/CreatorChannelDonationTabResponse.kt | 4 + ...ltCreatorChannelDonationQueryRepository.kt | 2 + .../CreatorChannelDonationQueryService.kt | 6 +- .../HideCreatorChannelDonationService.kt | 23 + .../domain/CreatorChannelDonationTab.kt | 1 + .../out/CreatorChannelDonationQueryPort.kt | 1 + .../CreatorChannelDonationControllerTest.kt | 27 + .../web/CreatorChannelDonationEndToEndTest.kt | 511 +++++++++++++++++- .../CreatorChannelDonationFacadeTest.kt | 5 +- ...eatorChannelDonationQueryRepositoryTest.kt | 56 +- .../CreatorChannelDonationQueryServiceTest.kt | 5 + .../HideCreatorChannelDonationServiceTest.kt | 159 ++++++ .../CreatorChannelDonationQueryPolicyTest.kt | 2 + 15 files changed, 812 insertions(+), 10 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt index c618884f..73590e7f 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationController.kt @@ -6,6 +6,7 @@ import kr.co.vividnext.sodalive.i18n.LangContext import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.application.CreatorChannelDonationFacade import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RequestMapping @@ -18,6 +19,16 @@ class CreatorChannelDonationController( private val creatorChannelDonationFacade: CreatorChannelDonationFacade, private val langContext: LangContext ) { + @DeleteMapping("/{creatorId}/donations/{donationId}") + fun hideDonation( + @PathVariable creatorId: Long, + @PathVariable donationId: Long, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ): ApiResponse { + creatorChannelDonationFacade.hide(creatorId, donationId, requireMember(member)) + return ApiResponse.ok() + } + @GetMapping("/{creatorId}/donations") fun getDonationTab( @PathVariable creatorId: Long, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt index 14d409f9..32d1d018 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacade.kt @@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.i18n.Lang import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService import kr.co.vividnext.sodalive.v2.creatorname.application.CreatorLocalizedNameService import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -13,8 +14,14 @@ import java.time.LocalDateTime @Transactional(readOnly = true) class CreatorChannelDonationFacade( private val creatorChannelDonationQueryService: CreatorChannelDonationQueryService, - private val creatorLocalizedNameService: CreatorLocalizedNameService + private val creatorLocalizedNameService: CreatorLocalizedNameService, + private val hideCreatorChannelDonationService: HideCreatorChannelDonationService ) { + @Transactional + fun hide(creatorId: Long, donationId: Long, actor: Member) { + hideCreatorChannelDonationService.hide(creatorId, donationId, actor) + } + fun getDonationTab( creatorId: Long, viewer: Member, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt index e05aaa18..483a02c7 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/dto/CreatorChannelDonationTabResponse.kt @@ -1,5 +1,6 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto +import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import kr.co.vividnext.sodalive.extensions.toUtcIso import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation @@ -48,6 +49,8 @@ data class MemberDonationRankingResponse( } data class CreatorChannelDonationResponse( + @JsonInclude(JsonInclude.Include.NON_NULL) + val donationId: Long?, val nickname: String, val profileImageUrl: String, val can: Int, @@ -59,6 +62,7 @@ data class CreatorChannelDonationResponse( companion object { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { return CreatorChannelDonationResponse( + donationId = donation.donationId, nickname = donation.nickname, profileImageUrl = donation.profileImageUrl, can = donation.can, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt index a447e69f..936b5e75 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepository.kt @@ -87,6 +87,7 @@ class DefaultCreatorChannelDonationQueryRepository( .select( Projections.constructor( CreatorChannelDonationRecord::class.java, + channelDonationMessage.id, channelDonationMessage.member.id, donorName.displayName, channelDonationMessage.member.profileImage, @@ -113,6 +114,7 @@ class DefaultCreatorChannelDonationQueryRepository( ): BooleanExpression { val monthRange = queryPolicy.currentKstMonthRange(now) return channelDonationMessage.creator.id.eq(creatorId) + .and(channelDonationMessage.isHidden.isFalse) .and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc)) .and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc)) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt index ed4f3711..682fb61e 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryService.kt @@ -68,7 +68,7 @@ class CreatorChannelDonationQueryService( return CreatorChannelDonationTab( donationCount = queryPort.countChannelDonations(creatorId, viewerId, now), rankings = findRankings(creator, viewerId), - donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewerId, lang) }, + donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewer, lang) }, page = donationPage, hasNext = queryPolicy.hasNext(fetchedDonations, donationPage) ) @@ -95,9 +95,11 @@ class CreatorChannelDonationQueryService( ).map { it.toDomain() } } - private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation { + private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewer: Member, lang: Lang): CreatorChannelDonation { + val viewerId = viewer.id!! val isMasked = isSecret && viewerId != creatorId && viewerId != memberId return CreatorChannelDonation( + donationId = donationId.takeIf { viewer.role == MemberRole.CREATOR && viewerId == creatorId }, memberId = memberId, nickname = when { isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt new file mode 100644 index 00000000..65e9684b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationService.kt @@ -0,0 +1,23 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class HideCreatorChannelDonationService( + private val hideCreatorChannelDonationPort: HideCreatorChannelDonationPort +) { + @Transactional + fun hide(creatorId: Long, donationId: Long, actor: Member) { + if (actor.role != MemberRole.CREATOR || actor.id != creatorId) { + throw SodaException(messageKey = "common.error.access_denied") + } + if (!hideCreatorChannelDonationPort.hideOwnedDonation(creatorId, donationId)) { + throw SodaException(messageKey = "common.error.invalid_request") + } + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt index ce0d12f5..d987f69c 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationTab.kt @@ -19,6 +19,7 @@ data class CreatorChannelDonationRanking( ) data class CreatorChannelDonation( + val donationId: Long?, val memberId: Long, val nickname: String, val profileImageUrl: String, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt index f1aa92d1..736b999d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/port/out/CreatorChannelDonationQueryPort.kt @@ -35,6 +35,7 @@ data class CreatorChannelDonationCreatorRecord( ) data class CreatorChannelDonationRecord( + val donationId: Long, val memberId: Long, val nickname: String, val profileImagePath: String?, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt index 4259edc3..25c8c495 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/adapter/in/web/CreatorChannelDonationControllerTest.kt @@ -28,6 +28,7 @@ import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequ import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.authentication.HttpStatusEntryPoint import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete 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 @@ -83,6 +84,31 @@ class CreatorChannelDonationControllerTest @Autowired constructor( .andExpect(status().isUnauthorized) } + @Test + @DisplayName("후원 숨김 DELETE는 body 없이 경로 ID와 인증 회원을 전달하고 data 없는 성공을 반환한다") + fun shouldHideDonationWithoutRequestBodyOrResponseData() { + val creator = createMember(id = 1L).apply { role = MemberRole.CREATOR } + + mockMvc.perform( + delete("/api/v2/creator-channels/1/donations/7") + .with(user(MemberAdapter(creator))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").doesNotExist()) + + Mockito.verify(facade).hide(1L, 7L, creator) + } + + @Test + @DisplayName("후원 숨김 DELETE는 비회원 요청을 거부한다") + fun shouldRejectAnonymousHideDonationRequest() { + mockMvc.perform(delete("/api/v2/creator-channels/1/donations/7").with(anonymous())) + .andExpect(status().isUnauthorized) + + Mockito.verifyNoInteractions(facade) + } + @Test @DisplayName("크리에이터 채널 후원 탭 조회는 query parameter를 facade에 전달하고 성공 응답을 반환한다") fun shouldReturnCreatorChannelDonationTabForAuthenticatedMember() { @@ -166,6 +192,7 @@ class CreatorChannelDonationControllerTest @Autowired constructor( ), donations = listOf( CreatorChannelDonationResponse( + donationId = null, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", can = 50, 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 index 914cf009..6c172abc 100644 --- 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 @@ -1,6 +1,8 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper +import kr.co.vividnext.sodalive.admin.calculate.channelDonation.AdminChannelDonationCalculateQueryRepository import kr.co.vividnext.sodalive.can.payment.PaymentGateway import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.UseCan @@ -8,13 +10,16 @@ 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.i18n.Lang +import kr.co.vividnext.sodalive.jwt.TokenProvider 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.member.token.MemberTokenRepository import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy import kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -22,10 +27,20 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource 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.boot.test.web.client.TestRestTemplate +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.annotation.DirtiesContext import org.springframework.test.context.ContextConfiguration import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get @@ -37,6 +52,7 @@ import java.time.ZoneOffset import javax.persistence.EntityManager @SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = [ "cloud.aws.cloud-front.host=https://cdn.test", "spring.cache.type=none", @@ -45,12 +61,279 @@ import javax.persistence.EntityManager ) @AutoConfigureMockMvc @ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class CreatorChannelDonationEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, private val transactionTemplate: TransactionTemplate, - private val objectMapper: ObjectMapper + private val objectMapper: ObjectMapper, + private val restTemplate: TestRestTemplate, + private val tokenProvider: TokenProvider, + private val tokenRepository: MemberTokenRepository, + private val jdbcTemplate: JdbcTemplate, + private val settlementRepository: AdminChannelDonationCalculateQueryRepository, + private val redisTemplate: RedisTemplate ) { + private val tokenMemberIds = mutableSetOf() + + @AfterEach + fun removeIssuedTokens() { + tokenMemberIds.forEach { tokenRepository.deleteById(it) } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("소유 CREATOR의 실제 HTTP DELETE는 공개·비밀 한 건만 숨기고 원본과 재무 기록을 보존한다") + fun shouldAllowOnlyOwningCreatorToHideDonationThroughHttp(isSecret: Boolean) { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val donationId = fixture.donationIds[if (isSecret) 1 else 0] + + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("실제 HTTP 숨김은 단일 공개·비밀 후원의 legacy 응답과 양수 랭킹·정산을 그대로 보존한다") + fun shouldPreserveLegacyRankingAndSettlementAfterHidingDonationThroughHttp(isSecret: Boolean) { + val createdAt = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + .plusHours(2).plusSeconds(if (isSecret) 1 else 0) + val fixture = transactionTemplate.execute { + val prefix = "preservation-$isSecret" + val creator = saveMember("$prefix-creator", MemberRole.CREATOR) + val donor = saveMember("$prefix-donor", MemberRole.USER) + val outsider = saveMember("$prefix-outsider", MemberRole.USER) + val otherCreator = saveMember("$prefix-other-creator", MemberRole.CREATOR) + val admin = saveMember("$prefix-admin", MemberRole.ADMIN) + val donation = saveDonation(creator, donor, 500, createdAt, isSecret, "preserved contribution") + val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, false, "other channel original") + val usage = saveRankingDonation(donor, creator, can = 450, rewardCan = 50, isSecret = isSecret) + entityManager.flush() + updateCreatedAt("UseCan", usage.id!!, createdAt) + updateCreatedAt("UseCanCalculate", usage.useCanCalculates.single().id!!, createdAt) + entityManager.clear() + val stored = entityManager.find(ChannelDonationMessage::class.java, donation.id!!) + val storedUsage = entityManager.find(UseCan::class.java, usage.id!!) + val calculation = storedUsage.useCanCalculates.single() + assertEquals(stored.can, storedUsage.can + storedUsage.rewardCan) + assertEquals(stored.member!!.id, storedUsage.member!!.id) + assertEquals(stored.isSecret, storedUsage.isSecret) + assertEquals(createdAt, stored.createdAt) + assertEquals(stored.createdAt, storedUsage.createdAt) + assertEquals(stored.can, calculation.can) + assertEquals(stored.creator!!.id, calculation.recipientCreatorId) + assertEquals(UseCanCalculateStatus.RECEIVED, calculation.status) + assertEquals(CanUsage.CHANNEL_DONATION, storedUsage.canUsage) + assertFalse(storedUsage.isRefund) + HideFixture(creator, donor, outsider, otherCreator, admin, listOf(donation.id!!), otherDonation.id!!) + }!! + val before = readHideRecords(fixture) + val path = "/api/v2/creator-channels/${fixture.creator.id}" + val legacyPath = "/explorer/profile/channel-donation?creatorId=${fixture.creator.id}&page=0&size=20" + val tabBefore = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"] + val selected = tabBefore["donations"].single() + val donationId = selected["donationId"].asLong() + assertEquals(fixture.donationIds.single(), donationId) + assertEquals(500, selected["can"].asInt()) + assertEquals(isSecret, selected["isSecret"].asBoolean()) + assertEquals(1, tabBefore["donationCount"].asInt()) + assertEquals( + tabBefore["donations"], + requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"] + ) + val legacyBefore = requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator) + assertTrue(legacyBefore["success"].asBoolean()) + assertEquals(1, legacyBefore["data"]["totalCount"].asInt()) + assertEquals(donationId, legacyBefore["data"]["items"].single()["id"].asLong()) + val rankingsBefore = tabBefore["rankings"] + assertEquals(fixture.donor.id, rankingsBefore.single()["userId"].asLong()) + assertEquals(500, rankingsBefore.single()["donationCan"].asInt()) + val totalBefore = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt) + val settlementBefore = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20) + val settlement = settlementBefore.single().toResponseItem() + assertEquals(fixture.creator.nickname, settlement.creator) + assertEquals(1L, totalBefore.count) + assertEquals(500, totalBefore.totalCan) + assertEquals(1, settlement.count) + assertEquals(500, settlement.totalCan) + assertEquals(39695, settlement.settlementAmount) + assertEquals(38385, settlement.depositAmount) + println("SETTLEMENT EVIDENCE before creatorId=${fixture.creator.id} at=$createdAt total=$totalBefore item=$settlement") + + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + + assertTrue(redisTemplate.delete("creator_donation_ranking_v2:${fixture.creator.id}:CUMULATIVE:0:8:true")) + val tabAfter = requestThroughHttp("$path/donations?page=0&size=20", HttpMethod.GET, fixture.creator)["data"] + assertEquals(0, tabAfter["donationCount"].asInt()) + assertTrue(tabAfter["donations"].isEmpty) + assertFalse(tabAfter["hasNext"].asBoolean()) + assertTrue(requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"].isEmpty) + assertEquals(rankingsBefore, tabAfter["rankings"]) + assertEquals(legacyBefore, requestThroughHttp(legacyPath, HttpMethod.GET, fixture.creator)) + val totalAfter = settlementRepository.getChannelDonationByCreatorTotal(createdAt, createdAt) + val settlementAfter = settlementRepository.getChannelDonationByCreator(createdAt, createdAt, 0, 20) + assertEquals(totalBefore, totalAfter) + assertEquals(settlementBefore, settlementAfter) + assertEquals(settlement, settlementAfter.single().toResponseItem()) + println( + "SETTLEMENT EVIDENCE after creatorId=${fixture.creator.id} at=$createdAt " + + "total=$totalAfter item=${settlementAfter.single().toResponseItem()}" + ) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + + @ParameterizedTest + @ValueSource(strings = ["donor", "outsider", "otherCreator", "admin"]) + @DisplayName("후원자·일반 회원·다른 CREATOR·ADMIN의 실제 HTTP DELETE는 기록 변경 없이 거부된다") + fun shouldRejectNonOwnersThroughHttpWithoutChangingRecords(actorType: String) { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val actor = when (actorType) { + "donor" -> fixture.donor + "outsider" -> fixture.outsider + "otherCreator" -> fixture.otherCreator + else -> fixture.admin + } + + deleteThroughHttp(fixture.creator.id!!, fixture.donationIds.first(), actor, false, "권한이 없습니다.") + + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, emptySet()) + } + + @Test + @DisplayName("실제 HTTP DELETE는 경로 소유권 위조·다른 채널 후원·미존재 ID를 동일한 원본을 유지하며 거부한다") + fun shouldRejectMismatchedOrMissingIdsThroughHttp() { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val creatorId = fixture.creator.id!! + val requests = listOf( + Triple(fixture.otherCreator.id!!, fixture.donationIds.first(), "권한이 없습니다."), + Triple(creatorId, fixture.otherDonationId, "잘못된 요청입니다."), + Triple(creatorId, Long.MAX_VALUE, "잘못된 요청입니다.") + ) + + requests.forEach { (pathCreatorId, donationId, message) -> + deleteThroughHttp(pathCreatorId, donationId, fixture.creator, false, message) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, emptySet()) + } + } + + @Test + @DisplayName("실제 HTTP 중복 숨김은 성공하고 이미 숨긴 항목의 비소유자 요청은 거부하며 재무를 보존한다") + fun shouldSucceedWhenHidingAgainThroughHttpWithoutChangingRecords() { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val donationId = fixture.donationIds[1] + + repeat(2) { + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.donor, false, "권한이 없습니다.") + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, setOf(donationId)) + } + + @Test + @DisplayName("인증 없는 실제 HTTP DELETE는 운영 인증 체인에서 401로 거부되고 기록을 보존한다") + fun shouldRejectUnauthenticatedDeleteThroughRealSecurityChain() { + val fixture = createHideFixture() + val before = readHideRecords(fixture) + val path = "/api/v2/creator-channels/${fixture.creator.id}/donations/${fixture.donationIds.first()}" + + val response = restTemplate.exchange(path, HttpMethod.DELETE, HttpEntity(HttpHeaders()), String::class.java) + + println("HTTP EVIDENCE DELETE $path actor=anonymous ${response.statusCodeValue}: ${response.body}") + assertEquals(HttpStatus.UNAUTHORIZED, response.statusCode) + assertEquals(before, readHideRecords(fixture)) + assertHiddenDonations(fixture, emptySet()) + } + + private fun deleteThroughHttp( + creatorId: Long, + donationId: Long, + actor: Member, + success: Boolean, + message: String? = null + ) { + val path = "/api/v2/creator-channels/$creatorId/donations/$donationId" + val body = requestThroughHttp(path, HttpMethod.DELETE, actor) + assertEquals(success, body["success"].asBoolean(), body.toString()) + assertTrue(body["data"].isNull, body.toString()) + if (message != null) assertEquals(message, body["message"].asText()) + } + + private fun requestThroughHttp(path: String, method: HttpMethod, actor: Member): JsonNode { + val principal = MemberAdapter(actor) + val token = tokenProvider.createToken( + UsernamePasswordAuthenticationToken(principal, "", principal.authorities), + actor.id!! + ) + tokenMemberIds.add(actor.id!!) + val headers = HttpHeaders().apply { + setBearerAuth(token) + set(HttpHeaders.ACCEPT_LANGUAGE, "ko") + } + val response = restTemplate.exchange(path, method, HttpEntity(headers), String::class.java) + println("HTTP EVIDENCE $method $path actor=${actor.role} ${response.statusCodeValue}: ${response.body}") + assertEquals(HttpStatus.OK, response.statusCode, response.body) + return objectMapper.readTree(response.body) + } + + private fun createHideFixture(): HideFixture = transactionTemplate.execute { + val creator = saveMember("hide-http-creator", MemberRole.CREATOR) + val donor = saveMember("hide-http-donor", MemberRole.USER) + val outsider = saveMember("hide-http-outsider", MemberRole.USER) + val otherCreator = saveMember("hide-http-other-creator", MemberRole.CREATOR) + val admin = saveMember("hide-http-admin", MemberRole.ADMIN) + val createdAt = LocalDateTime.of(2026, 11, 5, 0, 0) + val donations = listOf( + saveDonation(creator, donor, 100, createdAt, false, "public original"), + saveDonation(creator, donor, 200, createdAt, true, "secret original"), + saveDonation(creator, donor, 300, createdAt, false, "untouched original") + ) + val otherDonation = saveDonation(otherCreator, donor, 400, createdAt, true, "other channel original") + saveRankingDonation(donor, creator, can = 450, rewardCan = 50) + entityManager.flush() + entityManager.clear() + HideFixture(creator, donor, outsider, otherCreator, admin, donations.map { it.id!! }, otherDonation.id!!) + }!! + + private fun readHideRecords(fixture: HideFixture): List>> { + val donations = jdbcTemplate.queryForList( + "select * from channel_donation_message where creator_id in (?, ?) order by id", + fixture.creator.id, + fixture.otherCreator.id + ).map { row -> row.filterKeys { !it.equals("is_hidden", ignoreCase = true) } } + val usages = jdbcTemplate.queryForList("select * from use_can where member_id = ? order by id", fixture.donor.id) + val calculations = jdbcTemplate.queryForList( + "select c.* from use_can_calculate c join use_can u on u.id = c.use_can_id where u.member_id = ? order by c.id", + fixture.donor.id + ) + assertEquals(fixture.donationIds.size + 1, donations.size) + assertEquals(1, usages.size) + assertEquals(1, calculations.size) + return listOf(donations, usages, calculations) + } + + private fun assertHiddenDonations(fixture: HideFixture, hiddenIds: Set) { + transactionTemplate.executeWithoutResult { + entityManager.clear() + (fixture.donationIds + fixture.otherDonationId).forEach { donationId -> + val stored = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + assertEquals(donationId in hiddenIds, stored.isHidden) + } + } + } + @Test @DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다") fun shouldReturnDonationTabThroughControllerServiceRepositoryAndLegacyRanking() { @@ -223,10 +506,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( "ja" to "クリエイターにのみ表示されます" ).getValue(language) fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) -> + val isOwner = fixture.viewer.role == MemberRole.CREATOR && fixture.viewer.id == fixture.creatorId assertEquals( - setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"), + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret") + + if (isOwner) setOf("donationId") else emptySet(), item.fieldNames().asSequence().toSet() ) + if (isOwner) assertEquals(stored.id, item["donationId"]?.asLong()) + assertFalse(item.has("memberId")) assertTrue(item["isSecret"].isBoolean) assertEquals(stored.isSecret, item["isSecret"].asBoolean()) assertEquals(stored.can, item["can"].asInt()) @@ -257,6 +544,201 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( } } + @Test + @DisplayName("실제 HTTP 후원 탭은 숨김 제외 후 20건과 hasNext를 채우며 전부 숨기면 0건을 반환한다") + fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewerThroughHttp() { + val fixture = createHideFixture() + val rows = transactionTemplate.execute { + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + fixture.donationIds.forEach { id -> + entityManager.find(ChannelDonationMessage::class.java, id).isHidden = true + } + val donations = (1..25).map { index -> + saveDonation( + fixture.creator, + fixture.donor, + index, + start.plusHours(1), + index % 2 == 1, + "row$index", + isHidden = index >= 23 + ) + } + entityManager.flush() + entityManager.clear() + donations + }!! + val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin) + val path = "/api/v2/creator-channels/${fixture.creator.id}/donations" + viewers.forEach { viewer -> + listOf((22 downTo 3).toList(), listOf(2, 1), emptyList()).forEachIndexed { page, expected -> + val body = requestThroughHttp("$path?page=$page&size=20", HttpMethod.GET, viewer) + assertTrue(body["success"].asBoolean()) + val data = body["data"] + assertEquals(22, data["donationCount"].asInt()) + assertEquals(page, data["page"].asInt()) + assertEquals(20, data["size"].asInt()) + assertEquals(page == 0, data["hasNext"].asBoolean()) + assertEquals(expected, data["donations"].map { it["can"].asInt() }) + data["donations"].forEach { item -> + assertFalse(item.has("memberId")) + if (viewer.id == fixture.creator.id) { + assertEquals(rows[item["can"].asInt() - 1].id, item["donationId"]?.asLong()) + } else { + assertFalse(item.has("donationId")) + } + } + } + } + rows.take(2).forEach { deleteThroughHttp(fixture.creator.id!!, it.id!!, fixture.creator, true) } + viewers.forEach { viewer -> + val data = requestThroughHttp("$path?size=20", HttpMethod.GET, viewer)["data"] + assertEquals(20, data["donationCount"].asInt()) + assertEquals((22 downTo 3).toList(), data["donations"].map { it["can"].asInt() }) + assertFalse(data["hasNext"].asBoolean()) + } + transactionTemplate.executeWithoutResult { + rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true } + } + viewers.forEach { viewer -> + val data = requestThroughHttp(path, HttpMethod.GET, viewer)["data"] + assertEquals(0, data["donationCount"].asInt()) + assertTrue(data["donations"].isEmpty) + assertFalse(data["hasNext"].asBoolean()) + } + } + + @Test + @DisplayName("실제 HTTP에서 소유 CREATOR만 영속 후원 ID를 얻어 숨길 수 있다") + fun shouldExposeDonationIdOnlyToOwningCreator() { + val fixture = createHideFixture() + transactionTemplate.executeWithoutResult { + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + fixture.donationIds.forEachIndexed { index, id -> + updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong())) + } + } + val path = "/api/v2/creator-channels/${fixture.creator.id}/donations" + val data = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"] + assertEquals(fixture.donationIds.reversed(), data["donations"].map { it["donationId"]?.asLong() }) + val donationId = data["donations"][0]["donationId"].asLong() + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + val after = requestThroughHttp(path, HttpMethod.GET, fixture.creator)["data"] + assertEquals(2, after["donationCount"].asInt()) + assertEquals(fixture.donationIds.dropLast(1).reversed(), after["donations"].map { it["donationId"].asLong() }) + } + + @Test + @DisplayName("실제 홈 HTTP 응답도 소유 CREATOR에만 탭과 동일한 영속 후원 ID를 제공한다") + fun shouldExposeHomeDonationIdOnlyToOwningCreator() { + val fixture = createHideFixture() + transactionTemplate.executeWithoutResult { + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + fixture.donationIds.forEachIndexed { index, id -> + updateCreatedAt("ChannelDonationMessage", id, start.plusMinutes(index.toLong())) + } + } + val path = "/api/v2/creator-channels/${fixture.creator.id}" + val home = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"] + val tab = requestThroughHttp("$path/donations", HttpMethod.GET, fixture.creator)["data"]["donations"] + assertEquals(fixture.donationIds.reversed(), home.map { it["donationId"]?.asLong() }) + assertEquals(tab, home) + val donationId = home[1]["donationId"].asLong() + deleteThroughHttp(fixture.creator.id!!, donationId, fixture.creator, true) + listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin).forEach { viewer -> + val homeAfter = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"] + val tabAfter = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"] + assertEquals(listOf(300, 100), homeAfter.map { it["can"].asInt() }) + assertEquals(tabAfter["donations"], homeAfter) + assertEquals(2, tabAfter["donationCount"].asInt()) + homeAfter.forEach { item -> + assertFalse(item.has("memberId")) + if (viewer.id == fixture.creator.id) { + assertTrue(item.has("donationId")) + } else { + assertFalse(item.has("donationId")) + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("실제 HTTP 홈은 숨김 제외 후 8건을 보충하고 공개·비밀 DELETE를 모든 역할에 반영한다") + fun shouldRefillHomeWithLatestEightVisibleDonationsAfterHiddenRowsAreExcludedThroughHttp(isSecret: Boolean) { + val fixture = createHideFixture() + val start = CreatorChannelDonationQueryPolicy() + .currentKstMonthRange(LocalDateTime.now(ZoneOffset.UTC)).startInclusiveUtc + val rows = transactionTemplate.execute { + fixture.donationIds.forEach { entityManager.find(ChannelDonationMessage::class.java, it).isHidden = true } + val donations = (1..12).map { index -> + saveDonation( + fixture.creator, + fixture.donor, + index, + start.plusHours((index / 2).toLong()), + index % 2 == 1, + "visible$index", + isHidden = index >= 10 + ) + } + entityManager.flush() + entityManager.clear() + donations + }!! + val path = "/api/v2/creator-channels/${fixture.creator.id}" + val viewers = listOf(fixture.creator, fixture.donor, fixture.outsider, fixture.otherCreator, fixture.admin) + viewers.forEach { viewer -> + val data = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"] + assertFalse(data.has("donationCount")) + val items = data["channelDonations"] + assertEquals((9 downTo 2).toList(), items.map { it["can"].asInt() }) + items.forEach { item -> + val can = item["can"].asInt() + val secret = can % 2 == 1 + assertEquals(secret, item["isSecret"].asBoolean()) + assertEquals( + start.plusHours((can / 2).toLong()).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + assertFalse(item.has("memberId")) + if (viewer.id == fixture.creator.id) { + assertEquals(rows[can - 1].id, item["donationId"]?.asLong()) + } else { + assertFalse(item.has("donationId")) + } + val masked = secret && viewer.id != fixture.creator.id && viewer.id != fixture.donor.id + assertEquals(if (masked) "익명" else fixture.donor.nickname, item["nickname"].asText()) + assertEquals(if (masked) "크리에이터에게만 보여요" else "visible$can", item["message"].asText()) + } + } + val ownerHome = requestThroughHttp("$path/home", HttpMethod.GET, fixture.creator)["data"]["channelDonations"] + val selected = ownerHome.first { it["isSecret"].asBoolean() == isSecret } + val selectedCan = if (isSecret) 9 else 8 + assertEquals(selectedCan, selected["can"].asInt()) + assertEquals(rows[selectedCan - 1].id, selected["donationId"].asLong()) + deleteThroughHttp(fixture.creator.id!!, selected["donationId"].asLong(), fixture.creator, true) + val expected = if (isSecret) (8 downTo 1).toList() else listOf(9, 7, 6, 5, 4, 3, 2, 1) + viewers.forEach { viewer -> + val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"] + val tab = requestThroughHttp("$path/donations", HttpMethod.GET, viewer)["data"] + assertEquals(expected, home.map { it["can"].asInt() }) + assertEquals(8, tab["donationCount"].asInt()) + assertFalse(tab["hasNext"].asBoolean()) + assertEquals(tab["donations"], home) + } + transactionTemplate.executeWithoutResult { + rows.forEach { entityManager.find(ChannelDonationMessage::class.java, it.id!!).isHidden = true } + } + viewers.forEach { viewer -> + val home = requestThroughHttp("$path/home", HttpMethod.GET, viewer)["data"]["channelDonations"] + assertTrue(home.isEmpty) + } + } + @Test @DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다") fun shouldPaginateAllCurrentMonthDonationsThroughHttp() { @@ -363,9 +845,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String? + additionalMessage: String?, + isHidden: Boolean = false ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.isHidden = isHidden donation.creator = creator donation.member = donor entityManager.persist(donation) @@ -374,8 +858,14 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( 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) + private fun saveRankingDonation( + donor: Member, + creator: Member, + can: Int, + rewardCan: Int, + isSecret: Boolean = false + ): UseCan { + val useCan = UseCan(CanUsage.CHANNEL_DONATION, can = can, rewardCan = rewardCan, isRefund = false, isSecret = isSecret) useCan.member = donor entityManager.persist(useCan) @@ -387,6 +877,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( calculate.useCan = useCan calculate.recipientCreatorId = creator.id entityManager.persist(calculate) + return useCan } private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) { @@ -402,6 +893,16 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( val creatorId: Long ) + private data class HideFixture( + val creator: Member, + val donor: Member, + val outsider: Member, + val otherCreator: Member, + val admin: Member, + val donationIds: List, + val otherDonationId: Long + ) + private data class DonationFixture( val creatorId: Long, val donorId: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt index 4ded3cfa..69266ec5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/donation/application/CreatorChannelDonationFacadeTest.kt @@ -7,6 +7,7 @@ import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.v2.api.creator.channel.donation.dto.CreatorChannelDonationTabResponse import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.CreatorChannelDonationQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.donation.application.HideCreatorChannelDonationService import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonation import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationRanking import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationTab @@ -68,7 +69,8 @@ class CreatorChannelDonationFacadeTest { val service = Mockito.mock(CreatorChannelDonationQueryService::class.java) val facade = CreatorChannelDonationFacade( service, - Mockito.mock(CreatorLocalizedNameService::class.java) + Mockito.mock(CreatorLocalizedNameService::class.java), + Mockito.mock(HideCreatorChannelDonationService::class.java) ) val viewer = createMember(id = 10L) val now = LocalDateTime.of(2026, 6, 21, 12, 0) @@ -121,6 +123,7 @@ class CreatorChannelDonationFacadeTest { ), donations = listOf( CreatorChannelDonation( + donationId = null, memberId = 10L, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt index e07f0043..8fe08311 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/DefaultCreatorChannelDonationQueryRepositoryTest.kt @@ -220,6 +220,58 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( } } + @Test + @DisplayName("숨긴 공개·비밀 후원은 모든 역할의 count와 페이지 경계에서 제외된다") + fun shouldExcludeHiddenRowsFromDonationTabCountAndPagesForEveryViewer() { + val now = LocalDateTime.of(2026, 12, 22, 12, 0) + val creator = saveMember("hidden-page-creator", MemberRole.CREATOR) + val donor = saveMember("hidden-page-donor", MemberRole.USER) + val viewers = listOf( + creator, + donor, + saveMember("hidden-page-outsider", MemberRole.USER), + saveMember("hidden-page-other-creator", MemberRole.CREATOR), + saveMember("hidden-page-admin", MemberRole.ADMIN) + ) + val rows = (1..25).map { index -> + saveDonation(creator, donor, index, now, isSecret = index % 2 == 1, isHidden = index >= 23) + } + flushAndClear() + + viewers.forEach { viewer -> + assertEquals(22, repository.countChannelDonations(creator.id!!, viewer.id!!, now)) + val first = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 20, Lang.KO) + val second = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 20, 20, Lang.KO) + assertEquals((22 downTo 3).toList(), first.map { it.can }) + assertEquals(listOf(2, 1), second.map { it.can }) + assertEquals(rows.take(22).reversed().map { it.id }, (first + second).map { it.donationId }) + } + rows.take(2).forEach { row -> + entityManager.find(ChannelDonationMessage::class.java, row.id!!).isHidden = true + } + flushAndClear() + assertEquals(20, repository.countChannelDonations(creator.id!!, donor.id!!, now)) + assertEquals(20, repository.findChannelDonations(creator.id!!, donor.id!!, now, 0, 21, Lang.KO).size) + assertTrue(repository.findChannelDonations(creator.id!!, donor.id!!, now, 20, 21, Lang.KO).isEmpty()) + } + + @Test + @DisplayName("모든 후원이 숨김이면 모든 역할의 count는 0이고 목록은 비어 있다") + fun shouldReturnZeroCountAndEmptyPagesWhenAllDonationsAreHidden() { + val now = LocalDateTime.of(2026, 12, 22, 12, 0) + val creator = saveMember("all-hidden-creator", MemberRole.CREATOR) + val donor = saveMember("all-hidden-donor", MemberRole.USER) + listOf(false, true).forEach { secret -> + saveDonation(creator, donor, 100, now, isSecret = secret, isHidden = true) + } + flushAndClear() + + listOf(creator, donor).forEach { viewer -> + assertEquals(0, repository.countChannelDonations(creator.id!!, viewer.id!!, now)) + assertTrue(repository.findChannelDonations(creator.id!!, viewer.id!!, now, 0, 21, Lang.KO).isEmpty()) + } + } + @Test @DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다") fun shouldUseProjectionForDonationList() { @@ -276,9 +328,11 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String? = "thanks" + additionalMessage: String? = "thanks", + isHidden: Boolean = false ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) + donation.isHidden = isHidden donation.creator = creator donation.member = donor entityManager.persist(donation) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt index f4117107..3adbf35a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/CreatorChannelDonationQueryServiceTest.kt @@ -162,6 +162,9 @@ class CreatorChannelDonationQueryServiceTest { "20, USER, true", "10, USER, true", "30, CREATOR, true", + "1, USER, true", + "1, ADMIN, true", + "40, ADMIN, true", "1, CREATOR, false", "20, USER, false", "10, USER, false", @@ -199,6 +202,7 @@ class CreatorChannelDonationQueryServiceTest { assertEquals(isSecret, donation.isSecret) assertEquals(100, donation.can) assertEquals(NOW, donation.createdAt) + assertEquals(if (role == MemberRole.CREATOR && viewerId == CREATOR_ID) 101L else null, donation.donationId) } } } @@ -340,6 +344,7 @@ class CreatorChannelDonationQueryServiceTest { isLocalizedName: Boolean = false ): CreatorChannelDonationRecord { return CreatorChannelDonationRecord( + donationId = 101L, memberId = memberId, nickname = nickname, profileImagePath = profileImagePath, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt new file mode 100644 index 00000000..69c97ed4 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/application/HideCreatorChannelDonationServiceTest.kt @@ -0,0 +1,159 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.donation.application + +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource +import org.junit.jupiter.params.provider.ValueSource +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.annotation.DirtiesContext +import org.springframework.test.context.ContextConfiguration +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:hide-donation-service;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class HideCreatorChannelDonationServiceTest @Autowired constructor( + private val service: HideCreatorChannelDonationService, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @ParameterizedTest + @ValueSource(booleans = [false, true]) + @DisplayName("서비스는 소유 CREATOR의 공개·비밀 후원을 숨기고 DB 원본을 보존한다") + fun shouldHideOwnedPublicAndSecretDonationAndPreserveOriginalValues(isSecret: Boolean) { + val fixture = createFixture(isSecret = isSecret) + val before = originalValues(fixture.donationId) + + service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator) + + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, true) + } + + @ParameterizedTest + @EnumSource(value = MemberRole::class, names = ["USER", "ADMIN"]) + @DisplayName("서비스는 채널 ID가 같아도 CREATOR가 아닌 actor를 거부한다") + fun shouldRejectNonCreatorEvenWhenActorIdMatchesChannel(role: MemberRole) { + val fixture = createFixture(role = role) + val before = originalValues(fixture.donationId) + + val exception = assertThrows(SodaException::class.java) { + service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator) + } + + assertEquals("common.error.access_denied", exception.messageKey) + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, false) + } + + @ParameterizedTest + @EnumSource(value = MemberRole::class, names = ["USER", "ADMIN", "CREATOR"]) + @DisplayName("서비스는 비소유 actor를 대상 존재 여부 확인보다 먼저 거부한다") + fun shouldRejectNonOwnerBeforeCheckingDonationExistence(role: MemberRole) { + val fixture = createFixture() + val actor = transactionTemplate.execute { saveMember("hide-service-outsider", role) }!! + val before = originalValues(fixture.donationId) + + listOf(fixture.donationId, Long.MAX_VALUE).forEach { donationId -> + val exception = assertThrows(SodaException::class.java) { + service.hide(fixture.creator.id!!, donationId, actor) + } + assertEquals("common.error.access_denied", exception.messageKey) + } + + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, false) + } + + @Test + @DisplayName("서비스는 소속이 다른 후원과 미존재 ID를 같은 오류로 거부한다") + fun shouldRejectOtherChannelAndMissingDonationWithSameError() { + val fixture = createFixture() + val otherCreator = transactionTemplate.execute { saveMember("hide-service-other", MemberRole.CREATOR) }!! + val before = originalValues(fixture.donationId) + + listOf(otherCreator to fixture.donationId, fixture.creator to Long.MAX_VALUE).forEach { (actor, donationId) -> + val exception = assertThrows(SodaException::class.java) { + service.hide(actor.id!!, donationId, actor) + } + assertEquals("common.error.invalid_request", exception.messageKey) + } + + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, false) + } + + @Test + @DisplayName("서비스의 중복 숨김은 성공하고 원본을 보존한다") + fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() { + val fixture = createFixture(isSecret = true) + val before = originalValues(fixture.donationId) + + repeat(2) { + service.hide(fixture.creator.id!!, fixture.donationId, fixture.creator) + assertEquals(before, originalValues(fixture.donationId)) + assertHidden(fixture.donationId, true) + } + } + + private fun createFixture(role: MemberRole = MemberRole.CREATOR, isSecret: Boolean = false): Fixture { + return transactionTemplate.execute { + val creator = saveMember("hide-service-creator", role) + val donor = saveMember("hide-service-donor", MemberRole.USER) + val donation = ChannelDonationMessage(120, isSecret, "service original").apply { + this.creator = creator + member = donor + } + entityManager.persist(donation) + donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0) + entityManager.flush() + entityManager.clear() + Fixture(creator, donation.id!!) + }!! + } + + private fun saveMember(nickname: String, role: MemberRole): Member { + return Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role) + .also { entityManager.persist(it) } + } + + private fun originalValues(donationId: Long): List = transactionTemplate.execute { + entityManager.clear() + val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + listOf( + donation.id, + donation.can, + donation.additionalMessage, + donation.isSecret, + donation.creator?.id, + donation.member?.id, + donation.createdAt + ) + }!! + + private fun assertHidden(donationId: Long, hidden: Boolean) { + transactionTemplate.executeWithoutResult { + entityManager.clear() + val donation = requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId)) + assertEquals(hidden, donation.isHidden) + } + } + + private data class Fixture(val creator: Member, val donationId: Long) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt index 95172f2c..0517bd60 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/domain/CreatorChannelDonationQueryPolicyTest.kt @@ -78,6 +78,7 @@ class CreatorChannelDonationQueryPolicyTest { donationCan = 100 ) val donation = CreatorChannelDonation( + donationId = 101L, memberId = 10L, nickname = "donor", profileImageUrl = "https://cdn.test/donor.png", @@ -101,6 +102,7 @@ class CreatorChannelDonationQueryPolicyTest { donationRankingPeriod = DonationRankingPeriod.CUMULATIVE ) val donationRecord = CreatorChannelDonationRecord( + donationId = 101L, memberId = 10L, nickname = "donor", profileImagePath = null,