Test #467

Merged
klaus merged 18 commits from test into main 2026-11-06 12:15:59 +00:00
11 changed files with 324 additions and 41 deletions
Showing only changes of commit 6947675322 - Show all commits
@@ -1921,6 +1921,16 @@ class SodaMessageSource {
Lang.KO to "%s캔을 비밀후원하셨습니다.",
Lang.EN to "You secretly sponsored %s cans.",
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 can: Int,
val message: String,
val createdAtUtc: String
val createdAtUtc: String,
@JsonProperty("isSecret")
val isSecret: Boolean
) {
companion object {
fun from(donation: CreatorChannelDonation): CreatorChannelDonationResponse {
@@ -122,7 +124,8 @@ data class CreatorChannelDonationResponse(
profileImageUrl = donation.profileImageUrl,
can = donation.can,
message = donation.message,
createdAtUtc = donation.createdAt.toUtcIso()
createdAtUtc = donation.createdAt.toUtcIso(),
isSecret = donation.isSecret
)
}
}
@@ -201,6 +201,7 @@ class DefaultCreatorChannelHomeQueryRepository(
channelDonationMessage.can,
channelDonationMessage.additionalMessage.coalesce(""),
channelDonationMessage.createdAt,
channelDonationMessage.isSecret,
donorName.name.id.isNotNull
)
)
@@ -210,8 +211,7 @@ class DefaultCreatorChannelHomeQueryRepository(
.where(
channelDonationMessage.creator.id.eq(creatorId),
channelDonationMessage.createdAt.goe(start),
channelDonationMessage.createdAt.lt(end),
donationVisibilityCondition(creatorId, viewerId)
channelDonationMessage.createdAt.lt(end)
)
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
.limit(limit.toLong())
@@ -737,16 +737,6 @@ class DefaultCreatorChannelHomeQueryRepository(
.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(
seriesIds: List<Long>,
now: LocalDateTime,
@@ -102,7 +102,8 @@ class CreatorChannelHomeQueryService(
effectiveViewerGender = effectiveViewerGender
)?.toDomain(),
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(
creatorId = creatorId,
viewerId = viewerId,
@@ -196,14 +197,30 @@ class CreatorChannelHomeQueryService(
isRented = isRented
)
private fun CreatorChannelDonationRecord.toDomain() = CreatorChannelDonation(
memberId = memberId,
nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(),
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
can = can,
message = message,
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
},
createdAt = createdAt,
isSecret = isSecret
)
}
private fun CreatorChannelScheduleRecord.toDomain() = CreatorChannelSchedule(
scheduledAt = scheduledAt,
@@ -48,7 +48,8 @@ data class CreatorChannelDonation(
val profileImageUrl: String,
val can: Int,
val message: String,
val createdAt: LocalDateTime
val createdAt: LocalDateTime,
val isSecret: Boolean
)
data class CreatorChannelSchedule(
@@ -120,6 +120,7 @@ data class CreatorChannelDonationRecord(
val can: Int,
val message: String,
val createdAt: LocalDateTime,
val isSecret: Boolean,
val isLocalizedName: Boolean = false
)
@@ -1,5 +1,6 @@
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.common.CountryContext
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.channelDonation.ChannelDonationMessage
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.live.room.LiveRoom
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.support.EmbeddedRedisInitializer
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.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.mockito.Mockito
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
@@ -49,7 +57,8 @@ import javax.persistence.EntityManager
class CreatorChannelHomeEndToEndTest @Autowired constructor(
private val mockMvc: MockMvc,
private val entityManager: EntityManager,
private val transactionTemplate: TransactionTemplate
private val transactionTemplate: TransactionTemplate,
private val objectMapper: ObjectMapper
) {
@MockBean
private lateinit var countryContext: CountryContext
@@ -67,6 +76,7 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.andExpect(jsonPath("$.data.currentLive.liveId").value(fixture.currentLiveId))
.andExpect(jsonPath("$.data.latestAudioContent.audioContentId").value(fixture.latestAudioId))
.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].isPinned").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"))
}
@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 {
return transactionTemplate.execute {
val now = LocalDateTime.now(ZoneOffset.UTC)
@@ -231,8 +399,15 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
return order
}
private fun saveDonation(creator: Member, donor: Member, createdAt: LocalDateTime): ChannelDonationMessage {
val donation = ChannelDonationMessage(can = 500, isSecret = false, additionalMessage = "e2e thanks")
private fun saveDonation(
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.member = donor
entityManager.persist(donation)
@@ -301,6 +476,13 @@ class CreatorChannelHomeEndToEndTest @Autowired constructor(
.executeUpdate()
}
private data class DonationFixture(
val creatorId: Long,
val donorId: Long,
val viewer: Member,
val donations: List<ChannelDonationMessage>
)
private data class Fixture(
val viewer: Member,
val creatorId: Long,
@@ -139,7 +139,7 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
.andExpect(jsonPath("$.data.schedules[0].isAdult").doesNotExist())
.andExpect(jsonPath("$.data.channelDonations[0].donationId").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].imageUrl").doesNotExist())
.andExpect(jsonPath("$.data.notices[0].audioUrl").doesNotExist())
@@ -259,7 +259,8 @@ class CreatorChannelHomeControllerTest @Autowired constructor(
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
)
),
notices = listOf(post),
@@ -64,6 +64,7 @@ class CreatorChannelHomeFacadeTest {
assertTrue(response.latestAudioContent?.isOwned == true)
assertFalse(response.latestAudioContent?.isRented == true)
assertEquals("thanks", response.channelDonations.first().message)
assertTrue(response.channelDonations.first().isSecret)
assertEquals(301L, response.notices.first().postId)
assertEquals("2026-06-12T04:00:00Z", response.notices.first().dateUtc)
assertNull(response.notices.first().imageUrl)
@@ -190,7 +191,8 @@ class CreatorChannelHomeFacadeTest {
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = true
)
),
notices = listOf(post),
@@ -779,13 +779,16 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
}
@Test
@DisplayName("채널 후원은 KST 기준 이번 달과 크리에이터의 비밀 후원 열람을 반영한다")
fun shouldFindKstMonthDonationsAndExposeSecretDonationToCreator() {
@DisplayName("채널 후원은 KST 기준 이번 달 공개·비밀 후원을 조회자와 무관하게 포함한다")
fun shouldFindAllKstMonthDonationsForEveryViewer() {
val now = LocalDateTime.of(2026, 5, 31, 15, 30)
val creator = saveMember("kst-donation-creator", MemberRole.CREATOR)
val donor = saveMember("kst-donation-donor", MemberRole.USER)
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, 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)
flushAndClear()
@@ -793,9 +796,29 @@ class DefaultCreatorChannelHomeQueryRepositoryTest @Autowired constructor(
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)
assertEquals(listOf(300), creatorView.map { it.can })
assertEquals(listOf(300), donorView.map { it.can })
assertEquals(emptyList<Int>(), thirdPartyView.map { it.can })
assertEquals(listOf(400, 300, 200), creatorView.map { it.can })
assertEquals(listOf(400, 300, 200), donorView.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
@@ -54,6 +54,8 @@ import org.junit.jupiter.api.Assertions.assertThrows
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.mockito.Mockito
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
@@ -196,6 +198,49 @@ class CreatorChannelHomeQueryServiceTest {
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
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
@@ -350,7 +395,9 @@ class CreatorChannelHomeQueryServiceTest {
assertEquals("thanks", json["channelDonations"][0]["message"].asText())
assertFalse(json["channelDonations"][0].has("donation" + "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 {
@@ -413,7 +460,8 @@ class CreatorChannelHomeQueryServiceTest {
profileImageUrl = "fan.png",
can = 50,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0)
createdAt = LocalDateTime.of(2026, 6, 12, 2, 0),
isSecret = false
)
),
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(
creatorId = 1L,
role = MemberRole.CREATOR,
@@ -593,6 +641,9 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
var audioContentsLocale: String? = null
var seriesLocale: String? = null
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
@@ -639,12 +690,14 @@ private class FakeCreatorChannelHomeQueryPort : CreatorChannelHomeQueryPort {
lang: Lang
): List<CreatorChannelDonationRecord> = listOf(
CreatorChannelDonationRecord(
memberId = 20L,
memberId = donorId,
nickname = channelDonationNickname,
profileImagePath = "profile/fan.png",
profileImagePath = channelDonationProfilePath,
can = 30,
message = "thanks",
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0)
createdAt = LocalDateTime.of(2026, 6, 13, 8, 0),
isSecret = channelDonationIsSecret,
isLocalizedName = channelDonationIsLocalizedName
)
)