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 bf0a535d..e05aaa18 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 @@ -52,7 +52,9 @@ data class CreatorChannelDonationResponse( val profileImageUrl: String, val can: Int, val message: String, - val createdAtUtc: String + val createdAtUtc: String, + @JsonProperty("isSecret") + val isSecret: Boolean ) { companion object { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { @@ -61,7 +63,8 @@ data class CreatorChannelDonationResponse( profileImageUrl = donation.profileImageUrl, can = donation.can, message = donation.message, - createdAtUtc = donation.createdAt.toUtcIso() + createdAtUtc = donation.createdAt.toUtcIso(), + isSecret = donation.isSecret ) } } 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 289f9633..a447e69f 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 @@ -68,7 +68,7 @@ class DefaultCreatorChannelDonationQueryRepository( return queryFactory .select(channelDonationMessage.id.count()) .from(channelDonationMessage) - .where(channelDonationCondition(creatorId, viewerId, now)) + .where(channelDonationCondition(creatorId, now)) .fetchOne() ?.toInt() ?: 0 @@ -93,13 +93,14 @@ class DefaultCreatorChannelDonationQueryRepository( channelDonationMessage.can, channelDonationMessage.additionalMessage, channelDonationMessage.createdAt, + channelDonationMessage.isSecret, donorName.name.id.isNotNull ) ) .from(channelDonationMessage) .join(channelDonationMessage.member, member) .leftJoin(donorName.name).on(donorName.condition) - .where(channelDonationCondition(creatorId, viewerId, now)) + .where(channelDonationCondition(creatorId, now)) .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) .offset(offset) .limit(limit.toLong()) @@ -108,22 +109,11 @@ class DefaultCreatorChannelDonationQueryRepository( private fun channelDonationCondition( creatorId: Long, - viewerId: Long, now: LocalDateTime ): BooleanExpression { val monthRange = queryPolicy.currentKstMonthRange(now) return channelDonationMessage.creator.id.eq(creatorId) .and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc)) .and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc)) - .and(donationVisibilityCondition(creatorId, viewerId)) - } - - private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression { - return if (creatorId == viewerId) { - channelDonationMessage.id.isNotNull - } else { - channelDonationMessage.isSecret.isFalse - .or(channelDonationMessage.member.id.eq(viewerId)) - } } } 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 0b3fbd20..ed4f3711 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() }, + donations = queryPolicy.limitItems(fetchedDonations, donationPage).map { it.toDomain(creatorId, viewerId, lang) }, page = donationPage, hasNext = queryPolicy.hasNext(fetchedDonations, donationPage) ) @@ -95,14 +95,30 @@ class CreatorChannelDonationQueryService( ).map { it.toDomain() } } - private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( - memberId = memberId, - nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), - profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), - can = can, - message = message.orEmpty(), - createdAt = createdAt - ) + private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation { + val isMasked = isSecret && viewerId != creatorId && viewerId != memberId + return CreatorChannelDonation( + memberId = memberId, + nickname = when { + isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty() + isLocalizedName -> nickname + else -> nickname.removeDeletedNicknamePrefix() + }, + profileImageUrl = if (isMasked) { + "$cloudFrontHost/profile/anonymous-profile.png" + } else { + profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl() + }, + can = can, + message = if (isMasked) { + messageSource.getMessage("explorer.channel_donation.anonymous.message", lang).orEmpty() + } else { + message.orEmpty() + }, + createdAt = createdAt, + isSecret = isSecret + ) + } private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking( userId = userId, 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 3ead9d17..ce0d12f5 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 @@ -24,5 +24,6 @@ data class CreatorChannelDonation( val profileImageUrl: String, val can: Int, val message: String, - val createdAt: LocalDateTime + val createdAt: LocalDateTime, + val isSecret: Boolean ) 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 3f50af07..f1aa92d1 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 @@ -41,5 +41,6 @@ data class CreatorChannelDonationRecord( val can: Int, val message: String?, val createdAt: LocalDateTime, + val isSecret: Boolean, val isLocalizedName: Boolean = false ) 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 540e730a..4259edc3 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 @@ -116,6 +116,9 @@ class CreatorChannelDonationControllerTest @Autowired constructor( .andExpect(jsonPath("$.data.donations[0].can").value(50)) .andExpect(jsonPath("$.data.donations[0].message").value("thanks")) .andExpect(jsonPath("$.data.donations[0].createdAtUtc").value("2026-06-21T03:30:00Z")) + .andExpect(jsonPath("$.data.donations[0].isSecret").value(true)) + .andExpect(jsonPath("$.data.donations[0].memberId").doesNotExist()) + .andExpect(jsonPath("$.data.donations[0].id").doesNotExist()) .andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -167,7 +170,8 @@ class CreatorChannelDonationControllerTest @Autowired constructor( profileImageUrl = "https://cdn.test/donor.png", can = 50, message = "thanks", - createdAtUtc = "2026-06-21T03:30:00Z" + createdAtUtc = "2026-06-21T03:30:00Z", + isSecret = true ) ), page = page, 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 f9084953..914cf009 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,19 +1,27 @@ package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web +import com.fasterxml.jackson.databind.ObjectMapper 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.i18n.Lang 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 kr.co.vividnext.sodalive.v2.creatorname.adapter.out.persistence.CreatorLocalizedNameEntity +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue 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.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -25,6 +33,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.support.TransactionTemplate import java.time.LocalDateTime +import java.time.ZoneOffset import javax.persistence.EntityManager @SpringBootTest( @@ -39,7 +48,8 @@ import javax.persistence.EntityManager class CreatorChannelDonationEndToEndTest @Autowired constructor( private val mockMvc: MockMvc, private val entityManager: EntityManager, - private val transactionTemplate: TransactionTemplate + private val transactionTemplate: TransactionTemplate, + private val objectMapper: ObjectMapper ) { @Test @DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다") @@ -54,22 +64,28 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.donationCount").value(2)) + .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].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.length()").value(3)) + .andExpect(jsonPath("$.data.donations[0].isSecret").value(true)) + .andExpect(jsonPath("$.data.donations[0].nickname").value("익명")) + .andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/profile/anonymous-profile.png")) + .andExpect(jsonPath("$.data.donations[0].can").value(300)) + .andExpect(jsonPath("$.data.donations[0].message").value("크리에이터에게만 보여요")) .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.donations[1].isSecret").value(true)) + .andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-viewer")) + .andExpect(jsonPath("$.data.donations[1].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png")) + .andExpect(jsonPath("$.data.donations[1].can").value(200)) + .andExpect(jsonPath("$.data.donations[1].message").value("own secret")) + .andExpect(jsonPath("$.data.donations[2].isSecret").value(false)) + .andExpect(jsonPath("$.data.donations[2].nickname").value("donation-e2e-success-other")) + .andExpect(jsonPath("$.data.donations[2].can").value(100)) + .andExpect(jsonPath("$.data.donations[2].message").value("public")) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -88,7 +104,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( ) .andExpect(status().isOk) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.donationCount").value(2)) + .andExpect(jsonPath("$.data.donationCount").value(3)) .andExpect(jsonPath("$.data.donations.length()").value(0)) .andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.size").value(20)) @@ -110,7 +126,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(50)) - .andExpect(jsonPath("$.data.donations.length()").value(2)) + .andExpect(jsonPath("$.data.donationCount").value(3)) + .andExpect(jsonPath("$.data.donations.length()").value(3)) } @Test @@ -125,8 +142,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( .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.donationCount").value(3)) + .andExpect(jsonPath("$.data.donations.length()").value(3)) .andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.hasNext").value(false)) @@ -151,6 +168,148 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( .andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty) } + @ParameterizedTest(name = "{0}, {1}") + @CsvSource( + "creator, ko", "donor, ko", "defaultDonor, ko", "outsider, ko", "otherCreator, ko", + "creator, en", "donor, en", "defaultDonor, en", "outsider, en", "otherCreator, en", + "creator, ja", "donor, ja", "defaultDonor, ja", "outsider, ja", "otherCreator, ja" + ) + @DisplayName("후원 탭은 비밀 후원을 ID로 마스킹하며 요청 언어와 원본 표시 및 저장값을 보존한다") + fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) { + val fixture = transactionTemplate.execute { + val creator = saveMember("secret-tab-creator", MemberRole.CREATOR) + val donor = saveMember("deleted_secret-tab-donor", MemberRole.CREATOR) + val defaultDonor = saveMember("secret-tab-default-donor", MemberRole.USER).apply { profileImage = null } + val viewers = mapOf( + "creator" to creator, + "donor" to donor, + "defaultDonor" to defaultDonor, + "outsider" to saveMember("secret-tab-outsider", MemberRole.USER), + "otherCreator" to saveMember("secret-tab-other-creator", MemberRole.CREATOR) + ) + listOf(Lang.EN, Lang.JA).forEach { lang -> + entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}")) + } + val start = CreatorChannelDonationQueryPolicy().currentKstMonthRange(LocalDateTime.now()).startInclusiveUtc + val rows = listOf( + saveDonation(creator, donor, 100, start.plusMinutes(1), additionalMessage = "public thanks"), + saveDonation(creator, defaultDonor, 200, start.plusMinutes(2), additionalMessage = null), + saveDonation(creator, donor, 300, start.plusMinutes(3), true, "private thanks"), + saveDonation(creator, donor, 400, start.plusMinutes(4), true, null), + saveDonation(creator, donor, 500, start.plusMinutes(5), true, ""), + saveDonation(creator, defaultDonor, 600, start.plusMinutes(6), true, "other private thanks") + ) + entityManager.flush() + entityManager.clear() + val persistedRows = rows.map { entityManager.find(ChannelDonationMessage::class.java, it.id!!) } + persistedRows.forEach { row -> assertEquals(start.plusMinutes((row.can / 100).toLong()), row.createdAt) } + DonationFixture(creator.id!!, donor.id!!, viewers.getValue(viewerRole), persistedRows) + }!! + val result = mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/donations") + .header("Accept-Language", language) + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.donationCount").value(6)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andReturn() + val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["donations"] + assertEquals(listOf(600, 500, 400, 300, 200, 100), donations.map { it["can"].asInt() }) + val anonymousNickname = mapOf("ko" to "익명", "en" to "Anonymous", "ja" to "匿名").getValue(language) + val anonymousMessage = mapOf( + "ko" to "크리에이터에게만 보여요", + "en" to "Only visible to the creator", + "ja" to "クリエイターにのみ表示されます" + ).getValue(language) + fixture.donations.sortedByDescending { it.can }.zip(donations).forEach { (stored, item) -> + assertEquals( + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"), + item.fieldNames().asSequence().toSet() + ) + assertTrue(item["isSecret"].isBoolean) + assertEquals(stored.isSecret, item["isSecret"].asBoolean()) + assertEquals(stored.can, item["can"].asInt()) + assertEquals( + requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + val masked = stored.isSecret && fixture.viewer.id != fixture.creatorId && fixture.viewer.id != stored.member?.id + if (masked) { + assertEquals(anonymousNickname, item["nickname"].asText()) + assertEquals(anonymousMessage, item["message"].asText()) + assertEquals("https://cdn.test/profile/anonymous-profile.png", item["profileImageUrl"].asText()) + assertFalse(item.toString().contains("secret-tab-")) + assertFalse(item.toString().contains("localized-")) + assertFalse(item.toString().contains("private thanks")) + } else { + val isDonor = stored.member?.id == fixture.donorId + val nickname = if (isDonor) { + if (language == "ko") "secret-tab-donor" else "deleted_localized-$language" + } else { + "secret-tab-default-donor" + } + val profile = if (isDonor) "deleted_secret-tab-donor.png" else "profile/default-profile.png" + assertEquals(nickname, item["nickname"].asText()) + assertEquals(stored.additionalMessage.orEmpty(), item["message"].asText()) + assertEquals("https://cdn.test/$profile", item["profileImageUrl"].asText()) + } + } + } + + @Test + @DisplayName("후원 탭은 KST 월 공개·비밀 전체를 최신순으로 페이지화하고 범위 밖에서도 count를 유지한다") + fun shouldPaginateAllCurrentMonthDonationsThroughHttp() { + val range = CreatorChannelDonationQueryPolicy().currentKstMonthRange(LocalDateTime.now()) + val start = range.startInclusiveUtc + val end = range.endExclusiveUtc + val fixture = transactionTemplate.execute { + val creator = saveMember("page-tab-creator", MemberRole.CREATOR) + val donor = saveMember("page-tab-donor", MemberRole.USER) + val viewer = saveMember("page-tab-viewer", MemberRole.USER) + saveDonation(creator, donor, 900, start.minusSeconds(1), true, "previous month") + saveDonation(creator, donor, 901, start, true, "start") + saveDonation(creator, donor, 902, end, true, "next month") + saveDonation(creator, donor, 903, end.minusSeconds(1), true, "last second") + (1..23).forEach { index -> + saveDonation(creator, donor, index, start.plusHours((index / 2).toLong()), index % 2 == 1, "row$index") + } + entityManager.flush() + entityManager.clear() + val rows = entityManager.createQuery( + "select d from ChannelDonationMessage d where d.creator.id = :creatorId", + ChannelDonationMessage::class.java + ).setParameter("creatorId", creator.id).resultList + DonationFixture(creator.id!!, donor.id!!, viewer, rows) + }!! + val persisted = fixture.donations.associateBy { it.can } + val expectedPages = listOf(listOf(903) + (23 downTo 5), listOf(4, 3, 2, 1, 901), emptyList()) + expectedPages.forEachIndexed { page, expectedCans -> + val result = mockMvc.perform( + get("/api/v2/creator-channels/${fixture.creatorId}/donations") + .param("page", page.toString()) + .param("size", "20") + .with(user(MemberAdapter(fixture.viewer))) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.data.donationCount").value(25)) + .andExpect(jsonPath("$.data.page").value(page)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.hasNext").value(page == 0)) + .andReturn() + val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["donations"] + assertEquals(expectedCans, items.map { it["can"].asInt() }) + items.forEach { item -> + val stored = persisted.getValue(item["can"].asInt()) + assertEquals(stored.isSecret, item["isSecret"].asBoolean()) + assertEquals( + requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(), + item["createdAtUtc"].asText() + ) + } + } + } + private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture { return transactionTemplate.execute { val monthStart = CreatorChannelDonationQueryPolicy() @@ -204,7 +363,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( can: Int, createdAt: LocalDateTime, isSecret: Boolean = false, - additionalMessage: String + additionalMessage: String? ): ChannelDonationMessage { val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) donation.creator = creator @@ -242,4 +401,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor( val creator: Member, val creatorId: Long ) + + private data class DonationFixture( + val creatorId: Long, + val donorId: Long, + val viewer: Member, + val donations: List + ) } 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 fd352035..4ded3cfa 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 @@ -36,6 +36,7 @@ class CreatorChannelDonationFacadeTest { assertEquals(50, response.donations.first().can) assertEquals("thanks", response.donations.first().message) assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc) + assertTrue(response.donations.first().isSecret) assertEquals(1, response.page) assertEquals(20, response.size) assertTrue(response.hasNext) @@ -51,6 +52,12 @@ class CreatorChannelDonationFacadeTest { assertEquals(50, json["donations"][0]["can"].asInt()) assertEquals("thanks", json["donations"][0]["message"].asText()) assertEquals("2026-06-21T03:30:00Z", json["donations"][0]["createdAtUtc"].asText()) + assertTrue(json["donations"][0]["isSecret"].isBoolean) + assertTrue(json["donations"][0]["isSecret"].asBoolean()) + assertEquals( + setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"), + json["donations"][0].fieldNames().asSequence().toSet() + ) assertTrue(json["hasNext"].asBoolean()) assertFalse(json.has("languageCode")) } @@ -86,6 +93,7 @@ class CreatorChannelDonationFacadeTest { assertEquals(3, response.donationCount) assertEquals(10L, response.rankings.first().userId) assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl) + assertTrue(response.donations.first().isSecret) assertEquals(1, response.page) assertEquals(20, response.size) assertTrue(response.hasNext) @@ -118,7 +126,8 @@ class CreatorChannelDonationFacadeTest { profileImageUrl = "https://cdn.test/donor.png", can = 50, message = "thanks", - createdAt = LocalDateTime.of(2026, 6, 21, 3, 30) + createdAt = LocalDateTime.of(2026, 6, 21, 3, 30), + isSecret = true ) ), page = CreatorChannelPage(page = 1, size = 20), 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 adc88c78..e07f0043 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 @@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.block.BlockMember +import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull @@ -15,6 +16,8 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue 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.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.context.annotation.Import @@ -78,33 +81,59 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!)) } - @Test - @DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다") - fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() { - val now = LocalDateTime.of(2026, 6, 22, 3, 0) + @ParameterizedTest(name = "{0}, month={1}") + @CsvSource( + "creator, 6", + "donor, 6", + "outsider, 6", + "otherCreator, 6", + "creator, 12", + "donor, 12", + "outsider, 12", + "otherCreator, 12" + ) + @DisplayName("모든 조회자는 KST 월 시작 포함·다음 월 제외 조건으로 공개·비밀 count와 목록을 동일하게 조회한다") + fun shouldCountAndFindAllCurrentMonthDonationsForEveryViewer(viewerRole: String, month: Int) { + val now = LocalDateTime.of(2026, month, 22, 3, 0) + val range = CreatorChannelDonationQueryPolicy().currentKstMonthRange(now) + val start = LocalDateTime.of(2026, month - 1, if (month == 6) 31 else 30, 15, 0) + val end = LocalDateTime.of(2026, month, if (month == 6) 30 else 31, 15, 0) + assertEquals(start, range.startInclusiveUtc) + assertEquals(end, range.endExclusiveUtc) val creator = saveMember("donation-self-creator", MemberRole.CREATOR) val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png") - val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0) - val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null) - val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true) - saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59)) - saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0)) + val viewers = mapOf( + "creator" to creator, + "donor" to donor, + "outsider" to saveMember("donation-boundary-outsider", MemberRole.USER), + "otherCreator" to saveMember("donation-boundary-other-creator", MemberRole.CREATOR) + ) + val viewerId = viewers.getValue(viewerRole).id!! + saveDonation(creator, donor, 100, start, isSecret = true, additionalMessage = null) + saveDonation(creator, donor, 200, now.minusHours(1)) + saveDonation(creator, donor, 300, end.minusSeconds(1), isSecret = true) + saveDonation(creator, donor, 400, start.minusSeconds(1), isSecret = true) + saveDonation(creator, donor, 500, end, isSecret = true) + val otherChannel = saveMember("donation-boundary-other-channel", MemberRole.CREATOR) + saveDonation(otherChannel, donor, 600, now, isSecret = true) flushAndClear() - val count = repository.countChannelDonations(creator.id!!, creator.id!!, now) - val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10, lang = Lang.KO) + val count = repository.countChannelDonations(creator.id!!, viewerId, now) + val records = repository.findChannelDonations(creator.id!!, viewerId, now, offset = 0, limit = 10, lang = Lang.KO) - assertEquals(2, count) - assertEquals(listOf(secret.can, monthStart.can), records.map { it.can }) + assertEquals(3, count) + assertEquals(count, records.size) + assertEquals(listOf(300, 200, 100), records.map { it.can }) + assertEquals(listOf(true, false, true), records.map { it.isSecret }) assertEquals(donor.nickname, records.last().nickname) assertEquals(donor.profileImage, records.last().profileImagePath) assertNull(records.last().message) - assertEquals(monthStartCreatedAt, records.last().createdAt) + assertEquals(listOf(end.minusSeconds(1), now.minusHours(1), start), records.map { it.createdAt }) } @Test - @DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다") - fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() { + @DisplayName("일반 조회자도 공개 후원과 본인·타인 비밀 후원을 모두 조회한다") + fun shouldCountAndFindAllCurrentMonthDonationsForViewer() { val now = LocalDateTime.of(2026, 6, 22, 12, 0) val creator = saveMember("donation-visible-creator", MemberRole.CREATOR) val viewer = saveMember("donation-visible-viewer", MemberRole.USER) @@ -118,15 +147,23 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( isSecret = true, additionalMessage = "own secret" ) - saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden") + val otherSecretDonation = saveDonation( + creator, + otherDonor, + 300, + now.minusHours(1), + isSecret = true, + additionalMessage = "hidden" + ) flushAndClear() val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now) val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10, lang = Lang.KO) - assertEquals(2, count) - assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can }) - assertEquals(listOf("own secret", "public"), records.map { it.message }) + assertEquals(3, count) + assertEquals(listOf(otherSecretDonation.can, ownSecretDonation.can, publicDonation.can), records.map { it.can }) + assertEquals(listOf(true, true, false), records.map { it.isSecret }) + assertEquals(listOf("hidden", "own secret", "public"), records.map { it.message }) } @Test @@ -138,7 +175,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( val donor = saveMember("donation-order-donor", MemberRole.USER) val sameCreatedAt = now.minusHours(1) val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first") - val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second") + val second = saveDonation(creator, donor, 200, sameCreatedAt, isSecret = true, additionalMessage = "second") saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest") flushAndClear() @@ -147,6 +184,42 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor( assertEquals(listOf(second.can, first.can), records.map { it.can }) } + @Test + @DisplayName("공개·비밀 23건의 실제 QueryDSL 페이지 합계와 count는 모든 조회자에서 일치한다") + fun shouldKeepCountAndMixedPagesIdenticalForEveryViewer() { + val now = LocalDateTime.of(2026, 12, 22, 12, 0) + val start = CreatorChannelDonationQueryPolicy().currentKstMonthRange(now).startInclusiveUtc + val creator = saveMember("donation-page-creator", MemberRole.CREATOR) + val donor = saveMember("donation-page-donor", MemberRole.USER) + val viewers = listOf( + creator, + donor, + saveMember("donation-page-outsider", MemberRole.USER), + saveMember("donation-page-other-creator", MemberRole.CREATOR) + ) + (1..23).forEach { index -> + saveDonation(creator, donor, index, start.plusHours((index / 2).toLong()), isSecret = index % 2 == 1) + } + flushAndClear() + + viewers.forEach { viewer -> + val count = 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) + val outOfRange = repository.findChannelDonations(creator.id!!, viewer.id!!, now, 40, 20, Lang.KO) + + assertEquals(23, count) + assertEquals((23 downTo 4).toList(), first.map { it.can }) + assertEquals(listOf(3, 2, 1), second.map { it.can }) + assertTrue(outOfRange.isEmpty()) + assertEquals(count, first.size + second.size) + (first + second).forEach { record -> + assertEquals(record.can % 2 == 1, record.isSecret) + assertEquals(start.plusHours((record.can / 2).toLong()), record.createdAt) + } + } + } + @Test @DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다") fun shouldUseProjectionForDonationList() { 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 b7807554..f4117107 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 @@ -17,6 +17,8 @@ import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource import org.mockito.Mockito import org.springframework.beans.factory.ObjectProvider import java.time.LocalDateTime @@ -154,6 +156,54 @@ class CreatorChannelDonationQueryServiceTest { assertEquals("thanks", tab.donations[1].message) } + @ParameterizedTest + @CsvSource( + "1, CREATOR, true", + "20, USER, true", + "10, USER, true", + "30, CREATOR, true", + "1, CREATOR, false", + "20, USER, false", + "10, USER, false", + "30, CREATOR, false" + ) + @DisplayName("후원 mapper는 비밀 여부와 ID로만 마스킹하고 원본 이름·프로필·null 메시지 계약을 유지한다") + fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) { + listOf(false, true).forEach { isLocalized -> + listOf(null, "profile/donor.png").forEach { profilePath -> + val messages = listOf(null, "", "private thanks") + val port = FakeDonationQueryPort( + donations = messages.map { message -> + createDonationRecord( + memberId = 20L, + nickname = "deleted_donor", + profileImagePath = profilePath, + message = message, + isSecret = isSecret, + isLocalizedName = isLocalized + ) + }, + donationCount = messages.size + ) + val tab = createService(port).getDonationTab(CREATOR_ID, createMember(viewerId, role), 0, 20, NOW, Lang.KO) + + assertEquals(3, tab.donationCount) + assertEquals(3, tab.donations.size) + val masked = isSecret && viewerId != CREATOR_ID && viewerId != 20L + tab.donations.zip(messages).forEach { (donation, message) -> + val originalNickname = if (isLocalized) "deleted_donor" else "donor" + assertEquals(if (masked) "익명" else originalNickname, donation.nickname) + assertEquals(if (masked) "크리에이터에게만 보여요" else message.orEmpty(), donation.message) + val profile = if (masked) "profile/anonymous-profile.png" else profilePath ?: "profile/default-profile.png" + assertEquals("https://cdn.test/$profile", donation.profileImageUrl) + assertEquals(isSecret, donation.isSecret) + assertEquals(100, donation.can) + assertEquals(NOW, donation.createdAt) + } + } + } + } + @Test @DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다") fun shouldFetchRankingsWithDonationCanForCreatorViewer() { @@ -280,19 +330,24 @@ class CreatorChannelDonationQueryServiceTest { } private fun createDonationRecord( + memberId: Long = 20L, nickname: String = "donor", profileImagePath: String? = "profile/donor.png", can: Int = 100, message: String? = "thanks", - createdAt: LocalDateTime = NOW + createdAt: LocalDateTime = NOW, + isSecret: Boolean = false, + isLocalizedName: Boolean = false ): CreatorChannelDonationRecord { return CreatorChannelDonationRecord( - memberId = VIEWER_ID, + memberId = memberId, nickname = nickname, profileImagePath = profileImagePath, can = can, message = message, - createdAt = createdAt + createdAt = createdAt, + isSecret = isSecret, + isLocalizedName = isLocalizedName ) } @@ -305,12 +360,12 @@ class CreatorChannelDonationQueryServiceTest { ) } - private fun createMember(id: Long): Member { + private fun createMember(id: Long, role: MemberRole = MemberRole.USER): Member { return Member( email = "viewer$id@test.com", password = "password", nickname = "viewer$id", - role = MemberRole.USER + role = role ).apply { this.id = id } } 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 e990c9c3..95172f2c 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 @@ -83,7 +83,8 @@ class CreatorChannelDonationQueryPolicyTest { profileImageUrl = "https://cdn.test/donor.png", can = 50, message = "thanks", - createdAt = createdAt + createdAt = createdAt, + isSecret = true ) val tab = CreatorChannelDonationTab( donationCount = 1, @@ -105,7 +106,8 @@ class CreatorChannelDonationQueryPolicyTest { profileImagePath = null, can = 50, message = "thanks", - createdAt = createdAt + createdAt = createdAt, + isSecret = true ) val rankingRecord = CreatorChannelDonationRankingRecord( userId = 10L, @@ -122,6 +124,8 @@ class CreatorChannelDonationQueryPolicyTest { assertEquals(MemberRole.CREATOR, creatorRecord.role) assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod) assertNull(donationRecord.profileImagePath) + assertTrue(donationRecord.isSecret) + assertTrue(tab.donations.first().isSecret) assertEquals(100, rankingRecord.donationCan) } }