feat(channel-home): 비밀 후원을 권한에 따라 익명 표시한다

This commit is contained in:
2026-11-04 22:02:00 +09:00
parent 8b3aab232f
commit 6947675322
11 changed files with 324 additions and 41 deletions
@@ -1921,6 +1921,16 @@ class SodaMessageSource {
Lang.KO to "%s캔을 비밀후원하셨습니다.", Lang.KO to "%s캔을 비밀후원하셨습니다.",
Lang.EN to "You secretly sponsored %s cans.", Lang.EN to "You secretly sponsored %s cans.",
Lang.JA to "%sCANをシークレット支援しました。" Lang.JA to "%sCANをシークレット支援しました。"
),
"explorer.channel_donation.anonymous.nickname" to mapOf(
Lang.KO to "익명",
Lang.EN to "Anonymous",
Lang.JA to "匿名"
),
"explorer.channel_donation.anonymous.message" to mapOf(
Lang.KO to "크리에이터에게만 보여요",
Lang.EN to "Only visible to the creator",
Lang.JA to "クリエイターにのみ表示されます"
) )
) )
@@ -113,7 +113,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 {
@@ -122,7 +124,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
) )
} }
} }
@@ -201,6 +201,7 @@ class DefaultCreatorChannelHomeQueryRepository(
channelDonationMessage.can, channelDonationMessage.can,
channelDonationMessage.additionalMessage.coalesce(""), channelDonationMessage.additionalMessage.coalesce(""),
channelDonationMessage.createdAt, channelDonationMessage.createdAt,
channelDonationMessage.isSecret,
donorName.name.id.isNotNull donorName.name.id.isNotNull
) )
) )
@@ -210,8 +211,7 @@ class DefaultCreatorChannelHomeQueryRepository(
.where( .where(
channelDonationMessage.creator.id.eq(creatorId), channelDonationMessage.creator.id.eq(creatorId),
channelDonationMessage.createdAt.goe(start), channelDonationMessage.createdAt.goe(start),
channelDonationMessage.createdAt.lt(end), channelDonationMessage.createdAt.lt(end)
donationVisibilityCondition(creatorId, viewerId)
) )
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc()) .orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.limit(limit.toLong()) .limit(limit.toLong())
@@ -737,16 +737,6 @@ class DefaultCreatorChannelHomeQueryRepository(
.not() .not()
} }
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long?): BooleanExpression? {
return if (viewerId == null) {
channelDonationMessage.isSecret.isFalse
} else if (viewerId == creatorId) {
null
} else {
channelDonationMessage.isSecret.isFalse.or(channelDonationMessage.member.id.eq(viewerId))
}
}
private fun seriesContentStats( private fun seriesContentStats(
seriesIds: List<Long>, seriesIds: List<Long>,
now: LocalDateTime, now: LocalDateTime,
@@ -102,7 +102,8 @@ class CreatorChannelHomeQueryService(
effectiveViewerGender = effectiveViewerGender effectiveViewerGender = effectiveViewerGender
)?.toDomain(), )?.toDomain(),
latestAudioContent = latestAudioContent, latestAudioContent = latestAudioContent,
channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang).map { it.toDomain() }, channelDonations = queryPort.findChannelDonations(creatorId, viewerId, now, lang = lang)
.map { it.toDomain(creatorId, viewerId, lang) },
notices = communityQueryService.findHomeCommunityPosts( notices = communityQueryService.findHomeCommunityPosts(
creatorId = creatorId, creatorId = creatorId,
viewerId = viewerId, viewerId = viewerId,
@@ -196,14 +197,30 @@ class CreatorChannelHomeQueryService(
isRented = isRented isRented = isRented
) )
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation( private fun CreatorChannelDonationRecord.toDomain(creatorId: Long, viewerId: Long, lang: Lang): CreatorChannelDonation {
memberId = memberId, val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(), return CreatorChannelDonation(
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(), memberId = memberId,
can = can, nickname = when {
message = message, isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
createdAt = createdAt 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
},
createdAt = createdAt,
isSecret = isSecret
)
}
private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule( private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule(
scheduledAt = scheduledAt, scheduledAt = scheduledAt,
@@ -48,7 +48,8 @@ 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
) )
data class CreatorChannelSchedule( data class CreatorChannelSchedule(
@@ -120,6 +120,7 @@ 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
) )
@@ -1,5 +1,6 @@
package kr.co.vividnext.sodalive.v2.api.creator.channel.home package kr.co.vividnext.sodalive.v2.api.creator.channel.home
import com.fasterxml.jackson.databind.ObjectMapper
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
import kr.co.vividnext.sodalive.common.CountryContext import kr.co.vividnext.sodalive.common.CountryContext
import kr.co.vividnext.sodalive.content.AudioContent import kr.co.vividnext.sodalive.content.AudioContent
@@ -11,6 +12,7 @@ import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent
import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers import kr.co.vividnext.sodalive.explorer.profile.CreatorCheers
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer import kr.co.vividnext.sodalive.i18n.translation.SourceTextNormalizer
import kr.co.vividnext.sodalive.live.room.LiveRoom import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit import kr.co.vividnext.sodalive.live.room.visit.LiveRoomVisit
@@ -19,8 +21,14 @@ 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.community.translation.adapter.out.persistence.CreatorCommunityTranslation import kr.co.vividnext.sodalive.v2.creator.channel.community.translation.adapter.out.persistence.CreatorCommunityTranslation
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.mockito.Mockito import org.mockito.Mockito
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
@@ -49,7 +57,8 @@ import javax.persistence.EntityManager
class CreatorChannelHomeEndToEndTest @Autowired constructor( class CreatorChannelHomeEndToEndTest @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
) { ) {
@MockBean @MockBean
private lateinit var countryContext: CountryContext private lateinit var countryContext: CountryContext
@@ -67,6 +76,7 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId)) .andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId)) .andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.andExpect(jsonPath("$.data.channelDonations[0].can").value(500)) .andExpect(jsonPath("$.data.channelDonations[0].can").value(500))
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(false))
.andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId)) .andExpect(jsonPath("$.data.notices[0].postId").value(fixture.noticeId))
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true)) .andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true)) .andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
@@ -106,6 +116,164 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.communities[0].content").value("translated community")) .andExpect(jsonPath("$.data.communities[0].content").value("translated community"))
} }
@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("홈 비밀 후원은 본인과 채널 주인만 원본을 보고 타인은 요청 언어로 익명 표시한다")
fun shouldMaskOnlyOtherMembersSecretDonationsThroughHttp(viewerRole: String, language: String) {
val fixture = transactionTemplate.execute {
val creator = saveMember("secret-home-creator", MemberRole.CREATOR)
val donor = saveMember("deleted_secret-home-donor", MemberRole.CREATOR)
val defaultDonor = saveMember("secret-home-default-donor", MemberRole.USER).apply { profileImage = null }
val viewers = mapOf(
"creator" to creator,
"donor" to donor,
"defaultDonor" to defaultDonor,
"outsider" to saveMember("secret-home-outsider", MemberRole.USER),
"otherCreator" to saveMember("secret-home-other-creator", MemberRole.CREATOR)
)
listOf(Lang.EN, Lang.JA).forEach { lang ->
entityManager.persist(CreatorLocalizedNameEntity(donor.id!!, lang, "deleted_localized-${lang.code}"))
}
val start = currentKstMonthStartUtc()
val rows = listOf(
saveDonation(creator, donor, start.plusMinutes(1), 100, false, "public thanks"),
saveDonation(creator, defaultDonor, start.plusMinutes(2), 200, false, null),
saveDonation(creator, donor, start.plusMinutes(3), 300, true, "private thanks"),
saveDonation(creator, donor, start.plusMinutes(4), 400, true, null),
saveDonation(creator, donor, start.plusMinutes(5), 500, true, ""),
saveDonation(creator, defaultDonor, start.plusMinutes(6), 600, 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)
} ?: throw IllegalStateException("donation fixture creation failed")
val result = mockMvc.perform(
get("/api/v2/creator-channels/${fixture.creatorId}/home")
.header("Accept-Language", language)
.with(user(MemberAdapter(fixture.viewer)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.donationCount").doesNotExist())
.andReturn()
val donations = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"]
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) ->
assertTrue(item.path("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()
)
assertEquals(
setOf("nickname", "profileImageUrl", "can", "message", "createdAtUtc", "isSecret"),
item.fieldNames().asSequence().toSet()
)
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-home-donor"))
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-home-donor" else "deleted_localized-$language"
} else {
"secret-home-default-donor"
}
val profile = if (isDonor) "deleted_secret-home-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 월 시작을 포함하고 다음 월을 제외하며 공개·비밀 전체에서 최신 8건을 반환한다")
fun shouldKeepKstMonthBoundsAndLatestEightMixedDonationsThroughHttp() {
fun nextMonthStartUtc(monthStartUtc: LocalDateTime): LocalDateTime =
monthStartUtc.plusHours(9).plusMonths(1).minusHours(9)
assertEquals(
LocalDateTime.of(2026, 12, 31, 15, 0),
nextMonthStartUtc(LocalDateTime.of(2026, 11, 30, 15, 0))
)
val start = currentKstMonthStartUtc()
val end = nextMonthStartUtc(start)
val members = transactionTemplate.execute {
val creator = saveMember("month-home-creator", MemberRole.CREATOR)
val donor = saveMember("month-home-donor", MemberRole.USER)
val viewer = saveMember("month-home-viewer", MemberRole.USER)
saveDonation(creator, donor, start.minusSeconds(1), 900, true)
saveDonation(creator, donor, start, 901, true)
saveDonation(creator, donor, end, 902, true)
saveDonation(creator, donor, end.minusSeconds(1), 903, false)
Triple(creator, donor, viewer)
} ?: throw IllegalStateException("month fixture creation failed")
val boundaryResult = mockMvc.perform(
get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third)))
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.data.channelDonations[0].createdAtUtc").value("${end.minusSeconds(1)}Z"))
.andExpect(jsonPath("$.data.channelDonations[1].createdAtUtc").value("$start:00Z"))
.andExpect(jsonPath("$.data.channelDonations[1].isSecret").value(true))
.andReturn()
val boundaryItems = objectMapper.readTree(boundaryResult.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(903, 901), boundaryItems.map { it["can"].asInt() })
transactionTemplate.execute {
val creator = entityManager.find(Member::class.java, members.first.id)
val donor = entityManager.find(Member::class.java, members.second.id)
(1..10).forEach { index ->
saveDonation(creator, donor, start.plusHours((index / 2).toLong()), index, index % 2 == 1)
}
entityManager.flush()
entityManager.clear()
}
val result = mockMvc.perform(
get("/api/v2/creator-channels/${members.first.id}/home").with(user(MemberAdapter(members.third)))
)
.andExpect(status().isOk)
.andReturn()
val items = objectMapper.readTree(result.response.contentAsByteArray)["data"]["channelDonations"]
assertEquals(listOf(903, 10, 9, 8, 7, 6, 5, 4), items.map { it["can"].asInt() })
assertEquals(listOf(false, false, true, false, true, false, true, false), items.map { it["isSecret"].asBoolean() })
val persisted = transactionTemplate.execute {
entityManager.createQuery(
"select d from ChannelDonationMessage d where d.creator.id = :creatorId order by d.createdAt desc, d.id desc",
ChannelDonationMessage::class.java
).setParameter("creatorId", members.first.id).resultList.associateBy { it.can }
}!!
items.forEach { item ->
val stored = persisted.getValue(item["can"].asInt())
assertEquals(
requireNotNull(stored.createdAt).atOffset(ZoneOffset.UTC).toInstant().toString(),
item["createdAtUtc"].asText()
)
}
}
private fun currentKstMonthStartUtc(): LocalDateTime =
LocalDateTime.now(ZoneOffset.UTC).plusHours(9).toLocalDate().withDayOfMonth(1).atStartOfDay().minusHours(9)
private fun createFixture(): Fixture { private fun createFixture(): Fixture {
return transactionTemplate.execute { return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC) val now = LocalDateTime.now(ZoneOffset.UTC)
@@ -231,8 +399,15 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
return order return order
} }
private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage { private fun saveDonation(
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks") creator: Member,
donor: Member,
createdAt: LocalDateTime,
can: Int = 500,
isSecret: Boolean = false,
message: String? = "e2e thanks"
): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = message)
donation.creator = creator donation.creator = creator
donation.member = donor donation.member = donor
entityManager.persist(donation) entityManager.persist(donation)
@@ -301,6 +476,13 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.executeUpdate() .executeUpdate()
} }
private data class DonationFixture(
val creatorId: Long,
val donorId: Long,
val viewer: Member,
val donations: List<ChannelDonationMessage>
)
private data class Fixture( private data class Fixture(
val viewer: Member, val viewer: Member,
val creatorId: Long, val creatorId: Long,
@@ -139,7 +139,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist()) .andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist()) .andExpect(jsonPath("$.data.channelDonations[0].donationId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist()) .andExpect(jsonPath("$.data.channelDonations[0].memberId").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].isSecret").doesNotExist()) .andExpect(jsonPath("$.data.channelDonations[0].isSecret").value(true))
.andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z")) .andExpect(jsonPath("$.data.notices[0].dateUtc").value("2026-06-12T04:00:00Z"))
.andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist()) .andExpect(jsonPath("$.data.notices[0].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist()) .andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
@@ -259,7 +259,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
profileImageUrl = "fan.png", profileImageUrl = "fan.png",
can = 50, can = 50,
message = "thanks", message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
) )
), ),
notices = listOf(post), notices = listOf(post),
@@ -64,6 +64,7 @@ class CreatorChannelHomeFacadeTest {
assertTrue(response.latestAudioContent?.isOwned == true) assertTrue(response.latestAudioContent?.isOwned == true)
assertFalse(response.latestAudioContent?.isRented == true) assertFalse(response.latestAudioContent?.isRented == true)
assertEquals("thanks", response.channelDonations.first().message) assertEquals("thanks", response.channelDonations.first().message)
assertTrue(response.channelDonations.first().isSecret)
assertEquals(301L, response.notices.first().postId) assertEquals(301L, response.notices.first().postId)
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc) assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
assertNull(response.notices.first().imageUrl) assertNull(response.notices.first().imageUrl)
@@ -190,7 +191,8 @@ class CreatorChannelHomeFacadeTest {
profileImageUrl = "fan.png", profileImageUrl = "fan.png",
can = 50, can = 50,
message = "thanks", message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
) )
), ),
notices = listOf(post), notices = listOf(post),
@@ -779,13 +779,16 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
} }
@Test @Test
@DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다") @DisplayName("채널 후원은 KST 기준 이번 달 공개·비밀 후원을 조회자와 무관하게 포함한다")
fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() { fun shouldFindAllKstMonthDonationsForEveryViewer() {
val now = LocalDateTime.of(2026, 5, 31, 15, 30) val now = LocalDateTime.of(2026, 5, 31, 15, 30)
val creator = saveMember("kst-donation-creator", MemberRole.CREATOR) val creator = saveMember("kst-donation-creator", MemberRole.CREATOR)
val donor = saveMember("kst-donation-donor", MemberRole.USER) val donor = saveMember("kst-donation-donor", MemberRole.USER)
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 15, 10), isSecret = true) saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 15, 10), isSecret = true)
saveDonation(creator, donor, 100, LocalDateTime.of(2026, 5, 31, 14, 50), isSecret = false) saveDonation(creator, donor, 100, LocalDateTime.of(2026, 5, 31, 14, 50), isSecret = false)
saveDonation(creator, donor, 200, LocalDateTime.of(2026, 5, 31, 15, 0), isSecret = true)
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 14, 59, 59), isSecret = true)
saveDonation(creator, donor, 500, LocalDateTime.of(2026, 6, 30, 15, 0), isSecret = true)
val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER) val thirdParty = saveMember("kst-donation-third-party", MemberRole.USER)
flushAndClear() flushAndClear()
@@ -793,9 +796,29 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO) val donorView = repository.findChannelDonations(creator.id!!, donor.id!!, now, limit = 8, lang = Lang.KO)
val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8, lang = Lang.KO) val thirdPartyView = repository.findChannelDonations(creator.id!!, thirdParty.id!!, now, limit = 8, lang = Lang.KO)
assertEquals(listOf(300), creatorView.map { it.can }) assertEquals(listOf(400, 300, 200), creatorView.map { it.can })
assertEquals(listOf(300), donorView.map { it.can }) assertEquals(listOf(400, 300, 200), donorView.map { it.can })
assertEquals(emptyList<Int>(), thirdPartyView.map { it.can }) assertEquals(listOf(400, 300, 200), thirdPartyView.map { it.can })
assertEquals(listOf(true, true, true), thirdPartyView.map { it.isSecret })
assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), thirdPartyView.last().createdAt)
}
@Test
@DisplayName("홈 후원은 공개·비밀 후보 전체 중 생성 시각과 id 내림차순으로 최신 8건을 조회한다")
fun shouldFindLatestEightAcrossPublicAndSecretDonations() {
val now = LocalDateTime.of(2026, 6, 12, 12, 0)
val creator = saveMember("mixed-home-creator", MemberRole.CREATOR)
val donor = saveMember("mixed-home-donor", MemberRole.USER)
val viewer = saveMember("mixed-home-viewer", MemberRole.USER)
(1..10).forEach { index ->
saveDonation(creator, donor, index, now.minusMinutes((index / 2).toLong()), isSecret = index % 2 == 1)
}
flushAndClear()
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, limit = 8, lang = Lang.KO)
assertEquals(listOf(1, 3, 2, 5, 4, 7, 6, 9), records.map { it.can })
assertEquals(listOf(true, true, false, true, false, true, false, true), records.map { it.isSecret })
} }
@Test @Test
@@ -54,6 +54,8 @@ import org.junit.jupiter.api.Assertions.assertThrows
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.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
@@ -196,6 +198,49 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("donor", home.channelDonations.first().nickname) assertEquals("donor", home.channelDonations.first().nickname)
} }
@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로만 익명화하며 원본 이름과 프로필 기본값을 보존한다")
fun shouldMaskSecretDonationOnlyForOtherViewers(viewerId: Long, role: MemberRole, isSecret: Boolean) {
listOf(false, true).forEach { isLocalized ->
listOf(null, "profile/fan.png").forEach { profilePath ->
val port = FakeCreatorChannelHomeQueryPort(donorId = 20L).apply {
channelDonationNickname = "deleted_donor"
channelDonationIsSecret = isSecret
channelDonationIsLocalizedName = isLocalized
channelDonationProfilePath = profilePath
}
val service = createService(port)
val home = service.getHome(
1L,
createMember(viewerId, role = role),
LocalDateTime.of(2026, 6, 13, 10, 0),
Lang.KO
)
val donation = home.channelDonations.single()
val masked = isSecret && viewerId != 1L && viewerId != 20L
val expectedNickname = if (isLocalized) "deleted_donor" else "donor"
assertEquals(if (masked) "익명" else expectedNickname, donation.nickname)
assertEquals(if (masked) "크리에이터에게만 보여요" else "thanks", donation.message)
val originalProfile = profilePath ?: "profile/default-profile.png"
val expectedProfile = if (masked) "profile/anonymous-profile.png" else originalProfile
assertEquals("https://cdn.test/$expectedProfile", donation.profileImageUrl)
assertEquals(isSecret, donation.isSecret)
assertEquals(30, donation.can)
assertEquals(LocalDateTime.of(2026, 6, 13, 8, 0), donation.createdAt)
}
}
}
@Test @Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다") @DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() { fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
@@ -350,7 +395,9 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("thanks", json["channelDonations"][0]["message"].asText()) assertEquals("thanks", json["channelDonations"][0]["message"].asText())
assertFalse(json["channelDonations"][0].has("donation" + "Id")) assertFalse(json["channelDonations"][0].has("donation" + "Id"))
assertFalse(json["channelDonations"][0].has("member" + "Id")) assertFalse(json["channelDonations"][0].has("member" + "Id"))
assertFalse(json["channelDonations"][0].has("is" + "Secret")) assertTrue(json["channelDonations"][0]["isSecret"].isBoolean)
assertFalse(json["channelDonations"][0]["isSecret"].asBoolean())
assertFalse(json["channelDonations"][0].has("secret"))
} }
private fun createHome(): CreatorChannelHome { private fun createHome(): CreatorChannelHome {
@@ -413,7 +460,8 @@ class CreatorChannelHomeQueryServiceTest {
profileImageUrl = "fan.png", profileImageUrl = "fan.png",
can = 50, can = 50,
message = "thanks", message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0) createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = false
) )
), ),
notices = listOf(post), notices = listOf(post),
@@ -564,7 +612,7 @@ class CreatorChannelHomeQueryServiceTest {
} }
} }
private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort { private class FakeCreatorChannelHomeQueryPort(private val donorId: Long = 20L) : CreatorChannelHomeQueryPort {
var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord( var creator: CreatorChannelCreatorRecord? = CreatorChannelCreatorRecord(
creatorId = 1L, creatorId = 1L,
role = MemberRole.CREATOR, role = MemberRole.CREATOR,
@@ -593,6 +641,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var audioContentsLocale: String? = null var audioContentsLocale: String? = null
var seriesLocale: String? = null var seriesLocale: String? = null
var channelDonationNickname: String = "fan" var channelDonationNickname: String = "fan"
var channelDonationIsSecret: Boolean = false
var channelDonationIsLocalizedName: Boolean = false
var channelDonationProfilePath: String? = "profile/fan.png"
override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? = creator override fun findCreator(creatorId: Long, viewerId: Long?, lang: Lang): CreatorChannelCreatorRecord? = creator
@@ -639,12 +690,14 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
lang: Lang lang: Lang
): List<CreatorChannelDonationRecord> = listOf( ): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord( CreatorChannelDonationRecord(
memberId = 20L, memberId = donorId,
nickname = channelDonationNickname, nickname = channelDonationNickname,
profileImagePath = "profile/fan.png", profileImagePath = channelDonationProfilePath,
can = 30, can = 30,
message = "thanks", message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0) createdAt = LocalDateTime.of(2026, 6, 13, 8, 0),
isSecret = channelDonationIsSecret,
isLocalizedName = channelDonationIsLocalizedName
) )
) )