feat(channel-home): 비밀 후원을 권한에 따라 익명 표시한다
This commit is contained in:
@@ -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 "クリエイターにのみ表示されます"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
+5
-2
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-12
@@ -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,
|
||||
|
||||
+26
-9
@@ -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,
|
||||
|
||||
+2
-1
@@ -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(
|
||||
|
||||
+1
@@ -120,6 +120,7 @@ data class CreatorChannelDonationRecord(
|
||||
val can: Int,
|
||||
val message: String,
|
||||
val createdAt: LocalDateTime,
|
||||
val isSecret: Boolean,
|
||||
val isLocalizedName: Boolean = false
|
||||
)
|
||||
|
||||
|
||||
+185
-3
@@ -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,
|
||||
|
||||
+3
-2
@@ -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),
|
||||
|
||||
+3
-1
@@ -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),
|
||||
|
||||
+28
-5
@@ -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
|
||||
|
||||
+59
-6
@@ -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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user