Test #467

Merged
klaus merged 18 commits from test into main 2026-11-06 12:15:59 +00:00
11 changed files with 393 additions and 71 deletions
Showing only changes of commit e48ea1b505 - Show all commits
@@ -52,7 +52,9 @@ data class CreatorChannelDonationResponse(
val profileImageUrl: String, val profileImageUrl: String,
val can: Int, val can: Int,
val message: String, val message: String,
val createdAtUtc: String val createdAtUtc: String,
@JsonProperty("isSecret")
val isSecret: Boolean
) { ) {
companion object { companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse { fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
@@ -61,7 +63,8 @@ data class CreatorChannelDonationResponse(
profileImageUrl = donation.profileImageUrl, profileImageUrl = donation.profileImageUrl,
can = donation.can, can = donation.can,
message = donation.message, message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso() createdAtUtc = donation.createdAt.toUtcIso(),
isSecret = donation.isSecret
) )
} }
} }
@@ -68,7 +68,7 @@ class DefaultCreatorChannelDonationQueryRepository(
return queryFactory return queryFactory
.select(channelDonationMessage.id.count()) .select(channelDonationMessage.id.count())
.from(channelDonationMessage) .from(channelDonationMessage)
.where(channelDonationCondition(creatorId, viewerId, now)) .where(channelDonationCondition(creatorId, now))
.fetchOne() .fetchOne()
?.toInt() ?.toInt()
?: 0 ?: 0
@@ -93,13 +93,14 @@ class DefaultCreatorChannelDonationQueryRepository(
channelDonationMessage.can, channelDonationMessage.can,
channelDonationMessage.additionalMessage, channelDonationMessage.additionalMessage,
channelDonationMessage.createdAt, channelDonationMessage.createdAt,
channelDonationMessage.isSecret,
donorName.name.id.isNotNull donorName.name.id.isNotNull
) )
) )
.from(channelDonationMessage) .from(channelDonationMessage)
.join(channelDonationMessage.member, member) .join(channelDonationMessage.member, member)
.leftJoin(donorName.name).on(donorName.condition) .leftJoin(donorName.name).on(donorName.condition)
.where(channelDonationCondition(creatorId, viewerId, now)) .where(channelDonationCondition(creatorId, now))
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.offset(offset) .offset(offset)
.limit(limit.toLong()) .limit(limit.toLong())
@@ -108,22 +109,11 @@ class DefaultCreatorChannelDonationQueryRepository(
private fun channelDonationCondition( private fun channelDonationCondition(
creatorId: Long, creatorId: Long,
viewerId: Long,
now: LocalDateTime now: LocalDateTime
): BooleanExpression { ): BooleanExpression {
val monthRange = queryPolicy.currentKstMonthRange(now) val monthRange = queryPolicy.currentKstMonthRange(now)
return channelDonationMessage.creator.id.eq(creatorId) return channelDonationMessage.creator.id.eq(creatorId)
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc)) .and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc)) .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))
}
} }
} }
@@ -68,7 +68,7 @@ class CreatorChannelDonationQueryService(
return CreatorChannelDonationTab( return CreatorChannelDonationTab(
donationCount = queryPort.countChannelDonations(creatorId, viewerId, now), donationCount = queryPort.countChannelDonations(creatorId, viewerId, now),
rankings = findRankings(creator, viewerId), 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, page = donationPage,
hasNext = queryPolicy.hasNext(fetchedDonations, donationPage) hasNext = queryPolicy.hasNext(fetchedDonations, donationPage)
) )
@@ -95,14 +95,30 @@ class CreatorChannelDonationQueryService(
).map { it.toDomain() } ).map { it.toDomain() }
} }
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation {
val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
return CreatorChannelDonation(
memberId = memberId, memberId = memberId,
nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), nickname = when {
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), 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, can = can,
message = message.orEmpty(), message = if (isMasked) {
createdAt = createdAt messageSource.getMessage("explorer.channel_donation.anonymous.message", lang).orEmpty()
} else {
message.orEmpty()
},
createdAt = createdAt,
isSecret = isSecret
) )
}
private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking( private fun CreatorChannelDonationRankingRecord.toDomain() = CreatorChannelDonationRanking(
userId = userId, userId = userId,
@@ -24,5 +24,6 @@ data class CreatorChannelDonation(
val profileImageUrl: String, val profileImageUrl: String,
val can: Int, val can: Int,
val message: String, val message: String,
val createdAt: LocalDateTime val createdAt: LocalDateTime,
val isSecret: Boolean
) )
@@ -41,5 +41,6 @@ data class CreatorChannelDonationRecord(
val can: Int, val can: Int,
val message: String?, val message: String?,
val createdAt: LocalDateTime, val createdAt: LocalDateTime,
val isSecret: Boolean,
val isLocalizedName: Boolean = false val isLocalizedName: Boolean = false
) )
@@ -116,6 +116,9 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.donations[0].can").value(50)) .andExpect(jsonPath("$.data.donations[0].can").value(50))
.andExpect(jsonPath("$.data.donations[0].message").value("thanks")) .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].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.page").value(1))
.andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.hasNext").value(false))
@@ -167,7 +170,8 @@ class CreatorChannelDonationControllerTest @Autowired constructor(
profileImageUrl = "https://cdn.test/donor.png", profileImageUrl = "https://cdn.test/donor.png",
can = 50, can = 50,
message = "thanks", message = "thanks",
createdAtUtc = "2026-06-21T03:30:00Z" createdAtUtc = "2026-06-21T03:30:00Z",
isSecret = true
) )
), ),
page = page, page = page,
@@ -1,19 +1,27 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.donation.adapter.`in`.web 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.payment.PaymentGateway
import kr.co.vividnext.sodalive.can.use.CanUsage import kr.co.vividnext.sodalive.can.use.CanUsage
import kr.co.vividnext.sodalive.can.use.UseCan import kr.co.vividnext.sodalive.can.use.UseCan
import kr.co.vividnext.sodalive.can.use.UseCanCalculate import kr.co.vividnext.sodalive.can.use.UseCanCalculate
import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus import kr.co.vividnext.sodalive.can.use.UseCanCalculateStatus
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage 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.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberAdapter import kr.co.vividnext.sodalive.member.MemberAdapter
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy 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.DisplayName
import org.junit.jupiter.api.Test 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.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest 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.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneOffset
import javax.persistence.EntityManager import javax.persistence.EntityManager
@SpringBootTest( @SpringBootTest(
@@ -39,7 +48,8 @@ import javax.persistence.EntityManager
class CreatorChannelDonationEndToEndTest @Autowired constructor( class CreatorChannelDonationEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc, private val mockMvc: MockMvc,
private val entityManager: EntityManager, private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate private val transactionTemplate: TransactionTemplate,
private val objectMapper: ObjectMapper
) { ) {
@Test @Test
@DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다") @DisplayName("후원 탭 API는 controller-service-repository를 거쳐 후원 목록과 랭킹을 반환한다")
@@ -54,22 +64,28 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .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.length()").value(1))
.andExpect(jsonPath("$.data.rankings[0].userId").value(fixture.viewer.id!!)) .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].nickname").value(fixture.viewer.nickname))
.andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}")) .andExpect(jsonPath("$.data.rankings[0].profileImage").value("https://cdn.test/${fixture.viewer.profileImage}"))
.andExpect(jsonPath("$.data.rankings[0].donationCan").value(0)) .andExpect(jsonPath("$.data.rankings[0].donationCan").value(0))
.andExpect(jsonPath("$.data.donations.length()").value(2)) .andExpect(jsonPath("$.data.donations.length()").value(3))
.andExpect(jsonPath("$.data.donations[0].nickname").value("donation-e2e-success-viewer")) .andExpect(jsonPath("$.data.donations[0].isSecret").value(true))
.andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png")) .andExpect(jsonPath("$.data.donations[0].nickname").value("익명"))
.andExpect(jsonPath("$.data.donations[0].can").value(200)) .andExpect(jsonPath("$.data.donations[0].profileImageUrl").value("https://cdn.test/profile/anonymous-profile.png"))
.andExpect(jsonPath("$.data.donations[0].message").value("own secret")) .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[0].createdAtUtc").exists())
.andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-other")) .andExpect(jsonPath("$.data.donations[1].isSecret").value(true))
.andExpect(jsonPath("$.data.donations[1].can").value(100)) .andExpect(jsonPath("$.data.donations[1].nickname").value("donation-e2e-success-viewer"))
.andExpect(jsonPath("$.data.donations[1].message").value("public")) .andExpect(jsonPath("$.data.donations[1].profileImageUrl").value("https://cdn.test/donation-e2e-success-viewer.png"))
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isEmpty) .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.page").value(0))
.andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.hasNext").value(false))
@@ -88,7 +104,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
) )
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .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.donations.length()").value(0))
.andExpect(jsonPath("$.data.page").value(1)) .andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.size").value(20))
@@ -110,7 +126,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(50)) .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 @Test
@@ -125,8 +142,8 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
.andExpect(status().isOk) .andExpect(status().isOk)
.andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.rankings.length()").value(0)) .andExpect(jsonPath("$.data.rankings.length()").value(0))
.andExpect(jsonPath("$.data.donationCount").value(2)) .andExpect(jsonPath("$.data.donationCount").value(3))
.andExpect(jsonPath("$.data.donations.length()").value(2)) .andExpect(jsonPath("$.data.donations.length()").value(3))
.andExpect(jsonPath("$.data.page").value(0)) .andExpect(jsonPath("$.data.page").value(0))
.andExpect(jsonPath("$.data.size").value(20)) .andExpect(jsonPath("$.data.size").value(20))
.andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.hasNext").value(false))
@@ -151,6 +168,148 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.donations[?(@.message == 'hidden')]").isNotEmpty) .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 { private fun createFixture(prefix: String, isVisibleDonationRank: Boolean): Fixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val monthStart = CreatorChannelDonationQueryPolicy() val monthStart = CreatorChannelDonationQueryPolicy()
@@ -204,7 +363,7 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
can: Int, can: Int,
createdAt: LocalDateTime, createdAt: LocalDateTime,
isSecret: Boolean = false, isSecret: Boolean = false,
additionalMessage: String additionalMessage: String?
): ChannelDonationMessage { ): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage) val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
donation.creator = creator donation.creator = creator
@@ -242,4 +401,11 @@ class CreatorChannelDonationEndToEndTest @Autowired constructor(
val creator: Member, val creator: Member,
val creatorId: Long val creatorId: Long
) )
private data class DonationFixture(
val creatorId: Long,
val donorId: Long,
val viewer: Member,
val donations: List<ChannelDonationMessage>
)
} }
@@ -36,6 +36,7 @@ class CreatorChannelDonationFacadeTest {
assertEquals(50, response.donations.first().can) assertEquals(50, response.donations.first().can)
assertEquals("thanks", response.donations.first().message) assertEquals("thanks", response.donations.first().message)
assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc) assertEquals("2026-06-21T03:30:00Z", response.donations.first().createdAtUtc)
assertTrue(response.donations.first().isSecret)
assertEquals(1, response.page) assertEquals(1, response.page)
assertEquals(20, response.size) assertEquals(20, response.size)
assertTrue(response.hasNext) assertTrue(response.hasNext)
@@ -51,6 +52,12 @@ class CreatorChannelDonationFacadeTest {
assertEquals(50, json["donations"][0]["can"].asInt()) assertEquals(50, json["donations"][0]["can"].asInt())
assertEquals("thanks", json["donations"][0]["message"].asText()) assertEquals("thanks", json["donations"][0]["message"].asText())
assertEquals("2026-06-21T03:30:00Z", json["donations"][0]["createdAtUtc"].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()) assertTrue(json["hasNext"].asBoolean())
assertFalse(json.has("languageCode")) assertFalse(json.has("languageCode"))
} }
@@ -86,6 +93,7 @@ class CreatorChannelDonationFacadeTest {
assertEquals(3, response.donationCount) assertEquals(3, response.donationCount)
assertEquals(10L, response.rankings.first().userId) assertEquals(10L, response.rankings.first().userId)
assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl) assertEquals("https://cdn.test/donor.png", response.donations.first().profileImageUrl)
assertTrue(response.donations.first().isSecret)
assertEquals(1, response.page) assertEquals(1, response.page)
assertEquals(20, response.size) assertEquals(20, response.size)
assertTrue(response.hasNext) assertTrue(response.hasNext)
@@ -118,7 +126,8 @@ class CreatorChannelDonationFacadeTest {
profileImageUrl = "https://cdn.test/donor.png", profileImageUrl = "https://cdn.test/donor.png",
can = 50, can = 50,
message = "thanks", 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), page = CreatorChannelPage(page = 1, size = 20),
@@ -8,6 +8,7 @@ import kr.co.vividnext.sodalive.member.DonationRankingPeriod
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember 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.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNotNull 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.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import import org.springframework.context.annotation.Import
@@ -78,33 +81,59 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!)) assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!))
} }
@Test @ParameterizedTest(name = "{0}, month={1}")
@DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다") @CsvSource(
fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() { "creator, 6",
val now = LocalDateTime.of(2026, 6, 22, 3, 0) "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 creator = saveMember("donation-self-creator", MemberRole.CREATOR)
val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png") val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png")
val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0) val viewers = mapOf(
val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null) "creator" to creator,
val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true) "donor" to donor,
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59)) "outsider" to saveMember("donation-boundary-outsider", MemberRole.USER),
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0)) "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() flushAndClear()
val count = repository.countChannelDonations(creator.id!!, creator.id!!, now) val count = repository.countChannelDonations(creator.id!!, viewerId, now)
val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10, lang = Lang.KO) val records = repository.findChannelDonations(creator.id!!, viewerId, now, offset = 0, limit = 10, lang = Lang.KO)
assertEquals(2, count) assertEquals(3, count)
assertEquals(listOf(secret.can, monthStart.can), records.map { it.can }) 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.nickname, records.last().nickname)
assertEquals(donor.profileImage, records.last().profileImagePath) assertEquals(donor.profileImage, records.last().profileImagePath)
assertNull(records.last().message) assertNull(records.last().message)
assertEquals(monthStartCreatedAt, records.last().createdAt) assertEquals(listOf(end.minusSeconds(1), now.minusHours(1), start), records.map { it.createdAt })
} }
@Test @Test
@DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다") @DisplayName("일반 조회자도 공개 후원과 본인·타인 비밀 후원을 모두 조회한다")
fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() { fun shouldCountAndFindAllCurrentMonthDonationsForViewer() {
val now = LocalDateTime.of(2026, 6, 22, 12, 0) val now = LocalDateTime.of(2026, 6, 22, 12, 0)
val creator = saveMember("donation-visible-creator", MemberRole.CREATOR) val creator = saveMember("donation-visible-creator", MemberRole.CREATOR)
val viewer = saveMember("donation-visible-viewer", MemberRole.USER) val viewer = saveMember("donation-visible-viewer", MemberRole.USER)
@@ -118,15 +147,23 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
isSecret = true, isSecret = true,
additionalMessage = "own secret" 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() flushAndClear()
val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now) 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) val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10, lang = Lang.KO)
assertEquals(2, count) assertEquals(3, count)
assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can }) assertEquals(listOf(otherSecretDonation.can, ownSecretDonation.can, publicDonation.can), records.map { it.can })
assertEquals(listOf("own secret", "public"), records.map { it.message }) assertEquals(listOf(true, true, false), records.map { it.isSecret })
assertEquals(listOf("hidden", "own secret", "public"), records.map { it.message })
} }
@Test @Test
@@ -138,7 +175,7 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
val donor = saveMember("donation-order-donor", MemberRole.USER) val donor = saveMember("donation-order-donor", MemberRole.USER)
val sameCreatedAt = now.minusHours(1) val sameCreatedAt = now.minusHours(1)
val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first") 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") saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest")
flushAndClear() flushAndClear()
@@ -147,6 +184,42 @@ class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(second.can, first.can), records.map { it.can }) 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 @Test
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다") @DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
fun shouldUseProjectionForDonationList() { fun shouldUseProjectionForDonationList() {
@@ -17,6 +17,8 @@ import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test 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.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -154,6 +156,54 @@ class CreatorChannelDonationQueryServiceTest {
assertEquals("thanks", tab.donations[1].message) 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 @Test
@DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다") @DisplayName("조회자가 크리에이터 본인이면 순위 공개 여부와 무관하게 donationCan 포함 랭킹을 조회한다")
fun shouldFetchRankingsWithDonationCanForCreatorViewer() { fun shouldFetchRankingsWithDonationCanForCreatorViewer() {
@@ -280,19 +330,24 @@ class CreatorChannelDonationQueryServiceTest {
} }
private fun createDonationRecord( private fun createDonationRecord(
memberId: Long = 20L,
nickname: String = "donor", nickname: String = "donor",
profileImagePath: String? = "profile/donor.png", profileImagePath: String? = "profile/donor.png",
can: Int = 100, can: Int = 100,
message: String? = "thanks", message: String? = "thanks",
createdAt: LocalDateTime = NOW createdAt: LocalDateTime = NOW,
isSecret: Boolean = false,
isLocalizedName: Boolean = false
): CreatorChannelDonationRecord { ): CreatorChannelDonationRecord {
return CreatorChannelDonationRecord( return CreatorChannelDonationRecord(
memberId = VIEWER_ID, memberId = memberId,
nickname = nickname, nickname = nickname,
profileImagePath = profileImagePath, profileImagePath = profileImagePath,
can = can, can = can,
message = message, 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( return Member(
email = "viewer$id@test.com", email = "viewer$id@test.com",
password = "password", password = "password",
nickname = "viewer$id", nickname = "viewer$id",
role = MemberRole.USER role = role
).apply { this.id = id } ).apply { this.id = id }
} }
@@ -83,7 +83,8 @@ class CreatorChannelDonationQueryPolicyTest {
profileImageUrl = "https://cdn.test/donor.png", profileImageUrl = "https://cdn.test/donor.png",
can = 50, can = 50,
message = "thanks", message = "thanks",
createdAt = createdAt createdAt = createdAt,
isSecret = true
) )
val tab = CreatorChannelDonationTab( val tab = CreatorChannelDonationTab(
donationCount = 1, donationCount = 1,
@@ -105,7 +106,8 @@ class CreatorChannelDonationQueryPolicyTest {
profileImagePath = null, profileImagePath = null,
can = 50, can = 50,
message = "thanks", message = "thanks",
createdAt = createdAt createdAt = createdAt,
isSecret = true
) )
val rankingRecord = CreatorChannelDonationRankingRecord( val rankingRecord = CreatorChannelDonationRankingRecord(
userId = 10L, userId = 10L,
@@ -122,6 +124,8 @@ class CreatorChannelDonationQueryPolicyTest {
assertEquals(MemberRole.CREATOR, creatorRecord.role) assertEquals(MemberRole.CREATOR, creatorRecord.role)
assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod) assertEquals(DonationRankingPeriod.CUMULATIVE, creatorRecord.donationRankingPeriod)
assertNull(donationRecord.profileImagePath) assertNull(donationRecord.profileImagePath)
assertTrue(donationRecord.isSecret)
assertTrue(tab.donations.first().isSecret)
assertEquals(100, rankingRecord.donationCan) assertEquals(100, rankingRecord.donationCan)
} }
} }