Test #467
+5
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-13
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+25
-9
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
)
|
||||
|
||||
+1
@@ -41,5 +41,6 @@ data class CreatorChannelDonationRecord(
|
||||
val can: Int,
|
||||
val message: String?,
|
||||
val createdAt: LocalDateTime,
|
||||
val isSecret: Boolean,
|
||||
val isLocalizedName: Boolean = false
|
||||
)
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+182
-16
@@ -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<ChannelDonationMessage>
|
||||
)
|
||||
}
|
||||
|
||||
+10
-1
@@ -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),
|
||||
|
||||
+94
-21
@@ -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() {
|
||||
|
||||
+60
-5
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user