feat(channel-home): 비밀 후원을 권한에 따라 익명 표시한다
This commit is contained in:
@@ -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 "クリエイターにのみ表示されます"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-12
@@ -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,
|
||||||
|
|||||||
+23
-6
@@ -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 {
|
||||||
|
val isMasked = isSecret && viewerId != creatorId && viewerId != memberId
|
||||||
|
return CreatorChannelDonation(
|
||||||
memberId = memberId,
|
memberId = memberId,
|
||||||
nickname = if (isLocalizedName) nickname else nickname.removeDeletedNicknamePrefix(),
|
nickname = when {
|
||||||
profileImageUrl = profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
|
isMasked -> messageSource.getMessage("explorer.channel_donation.anonymous.nickname", lang).orEmpty()
|
||||||
|
isLocalizedName -> nickname
|
||||||
|
else -> nickname.removeDeletedNicknamePrefix()
|
||||||
|
},
|
||||||
|
profileImageUrl = if (isMasked) {
|
||||||
|
"$cloudFrontHost/profile/anonymous-profile.png"
|
||||||
|
} else {
|
||||||
|
profileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl()
|
||||||
|
},
|
||||||
can = can,
|
can = can,
|
||||||
message = message,
|
message = if (isMasked) {
|
||||||
createdAt = createdAt
|
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,
|
||||||
|
|||||||
+2
-1
@@ -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(
|
||||||
|
|||||||
+1
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+185
-3
@@ -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,
|
||||||
|
|||||||
+3
-2
@@ -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),
|
||||||
|
|||||||
+3
-1
@@ -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),
|
||||||
|
|||||||
+28
-5
@@ -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
|
||||||
|
|||||||
+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.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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user