diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt index 7907600b..9e71a620 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/i18n/SodaMessageSource.kt @@ -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 "クリエイターにのみ表示されます" ) ) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt index 6e14c92a..2bd07c61 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt @@ -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 ) } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt index 36ab9621..6890fdd9 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt @@ -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, now: LocalDateTime, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt index 1476ed04..c4f16bc0 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt @@ -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, diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt index e36d61da..48732928 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/domain/CreatorChannelHome.kt @@ -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( diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt index 5325e485..49c584d4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt @@ -120,6 +120,7 @@ data class CreatorChannelDonationRecord( val can: Int, val message: String, val createdAt: LocalDateTime, + val isSecret: Boolean, val isLocalizedName: Boolean = false ) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt index 6f62841b..92c96d22 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt @@ -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 + ) + private data class Fixture( val viewer: Member, val creatorId: Long, diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt index 8b305a21..b5f062e5 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt @@ -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), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt index 9dfb070f..6583349a 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacadeTest.kt @@ -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), diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt index a49b84c4..297a64e4 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt @@ -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(), 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 diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt index 94ccaf13..9f3c4365 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt @@ -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 = 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 ) )