feat(channel-donation): 원본을 보존하는 후원 숨김 저장 모델을 추가한다

This commit is contained in:
2026-11-05 02:19:30 +09:00
parent e1e6554da5
commit d0b5fff40d
5 changed files with 240 additions and 0 deletions
@@ -15,6 +15,9 @@ class ChannelDonationMessage(
@Column(columnDefinition = "TEXT")
var additionalMessage: String? = null
) : BaseEntity() {
@Column(nullable = false)
var isHidden: Boolean = false
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
var member: Member? = null
@@ -0,0 +1,30 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
@Repository
class DefaultHideCreatorChannelDonationRepository(
private val queryFactory: JPAQueryFactory
) : HideCreatorChannelDonationPort {
@Transactional
override fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean {
val ownedDonation = channelDonationMessage.id.eq(donationId)
.and(channelDonationMessage.creator.id.eq(creatorId))
val updatedCount = queryFactory
.update(channelDonationMessage)
.set(channelDonationMessage.isHidden, true)
.set(channelDonationMessage.updatedAt, channelDonationMessage.updatedAt)
.where(ownedDonation, channelDonationMessage.isHidden.isFalse)
.execute()
return updatedCount > 0 || queryFactory
.selectOne()
.from(channelDonationMessage)
.where(ownedDonation)
.fetchFirst() != null
}
}
@@ -0,0 +1,5 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out
interface HideCreatorChannelDonationPort {
fun hideOwnedDonation(creatorId: Long, donationId: Long): Boolean
}
@@ -0,0 +1,200 @@
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.HideCreatorChannelDonationPort
import org.hibernate.resource.jdbc.spi.StatementInspector
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.ValueSource
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(properties = ["spring.cache.type=none"])
@Import(
QueryDslConfig::class,
DefaultHideCreatorChannelDonationRepository::class,
DefaultHideCreatorChannelDonationRepositoryTest.SqlCaptureConfiguration::class
)
class DefaultHideCreatorChannelDonationRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
private val repository: HideCreatorChannelDonationPort,
private val sqlCapture: SqlCaptureConfiguration
) {
@Test
@DisplayName("새 후원은 기본 비숨김 상태로 저장된다")
fun shouldPersistDonationAsVisibleByDefault() {
val creator = saveMember("hide-default-creator", MemberRole.CREATOR)
val donor = saveMember("hide-default-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor)
val donation = reloadDonation(donationId)
assertFalse(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false)
}
@Test
@DisplayName("숨김 UPDATE는 MySQL 자동 갱신을 막도록 수정 시각을 자기 대입한다")
fun shouldPreserveUpdatedAtWithSelfAssignmentInGeneratedHideSql() {
val creator = saveMember("hide-sql-creator", MemberRole.CREATOR)
val donor = saveMember("hide-sql-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor)
val updatedAt = requireNotNull(reloadDonation(donationId).updatedAt)
sqlCapture.statements.clear()
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
val updateSql = sqlCapture.statements.single { it.startsWith("update channel_donation_message ", ignoreCase = true) }
assertTrue(Regex("""(?i)\bupdated_at\s*=\s*(?:\w+\.)?updated_at\b""").containsMatchIn(updateSql), updateSql)
val donation = reloadDonation(donationId)
assertTrue(donation.isHidden)
assertEquals(updatedAt, donation.updatedAt)
}
@ParameterizedTest
@ValueSource(booleans = [false, true])
@DisplayName("소유 채널의 선택한 공개·비밀 후원 한 건만 숨기고 원본 값을 보존한다")
fun shouldHideOnlyDonationOwnedByCreatorAndRetainOriginalValues(isSecret: Boolean) {
val creator = saveMember("hide-owned-creator", MemberRole.CREATOR)
val otherCreator = saveMember("hide-other-creator", MemberRole.CREATOR)
val donor = saveMember("hide-owned-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor, isSecret)
val sameChannelDonationId = saveDonation(creator, donor)
val otherChannelDonationId = saveDonation(otherCreator, donor)
assertFalse(reloadDonation(donationId).isHidden)
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
val donation = reloadDonation(donationId)
assertTrue(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, isSecret)
val sameChannelDonation = reloadDonation(sameChannelDonationId)
assertFalse(sameChannelDonation.isHidden)
assertOriginalValues(sameChannelDonation, sameChannelDonationId, creator.id!!, donor.id!!, false)
val otherChannelDonation = reloadDonation(otherChannelDonationId)
assertFalse(otherChannelDonation.isHidden)
assertOriginalValues(otherChannelDonation, otherChannelDonationId, otherCreator.id!!, donor.id!!, false)
assertEquals(3L, donationCount())
}
@Test
@DisplayName("다른 채널의 후원은 숨기지 않고 실패를 반환한다")
fun shouldRejectDonationFromAnotherCreatorWithoutChangingIt() {
val creator = saveMember("hide-reject-creator", MemberRole.CREATOR)
val otherCreator = saveMember("hide-reject-other", MemberRole.CREATOR)
val donor = saveMember("hide-reject-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor, isSecret = true)
reloadDonation(donationId)
assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId))
val donation = reloadDonation(donationId)
assertFalse(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true)
assertEquals(1L, donationCount())
}
@Test
@DisplayName("존재하지 않는 후원 ID는 기존 후원을 바꾸지 않고 실패를 반환한다")
fun shouldRejectMissingDonationWithoutChangingExistingDonation() {
val creator = saveMember("hide-missing-creator", MemberRole.CREATOR)
val donor = saveMember("hide-missing-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor)
reloadDonation(donationId)
assertFalse(repository.hideOwnedDonation(creator.id!!, Long.MAX_VALUE))
val donation = reloadDonation(donationId)
assertFalse(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, false)
assertEquals(1L, donationCount())
}
@Test
@DisplayName("같은 후원의 반복 숨김은 성공하며 다른 채널 요청은 거부하고 원본 값을 보존한다")
fun shouldSucceedWhenHidingAgainWithoutChangingOriginalValues() {
val creator = saveMember("hide-repeat-creator", MemberRole.CREATOR)
val otherCreator = saveMember("hide-repeat-other", MemberRole.CREATOR)
val donor = saveMember("hide-repeat-donor", MemberRole.USER)
val donationId = saveDonation(creator, donor, isSecret = true)
reloadDonation(donationId)
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
assertTrue(reloadDonation(donationId).isHidden)
assertTrue(repository.hideOwnedDonation(creator.id!!, donationId))
assertFalse(repository.hideOwnedDonation(otherCreator.id!!, donationId))
val donation = reloadDonation(donationId)
assertTrue(donation.isHidden)
assertOriginalValues(donation, donationId, creator.id!!, donor.id!!, true)
assertEquals(1L, donationCount())
}
private fun saveMember(nickname: String, role: MemberRole): Member {
val member = Member(email = "$nickname@test.com", password = "password", nickname = nickname, role = role)
entityManager.persist(member)
return member
}
private fun saveDonation(creator: Member, donor: Member, isSecret: Boolean = false): Long {
val donation = ChannelDonationMessage(can = 120, isSecret = isSecret, additionalMessage = "original message")
donation.creator = creator
donation.member = donor
entityManager.persist(donation)
donation.createdAt = LocalDateTime.of(2026, 11, 5, 0, 0)
return requireNotNull(donation.id)
}
private fun reloadDonation(donationId: Long): ChannelDonationMessage {
entityManager.flush()
entityManager.clear()
return requireNotNull(entityManager.find(ChannelDonationMessage::class.java, donationId))
}
private fun assertOriginalValues(
donation: ChannelDonationMessage,
donationId: Long,
creatorId: Long,
donorId: Long,
isSecret: Boolean
) {
assertEquals(donationId, donation.id)
assertEquals(120, donation.can)
assertEquals("original message", donation.additionalMessage)
assertEquals(isSecret, donation.isSecret)
assertEquals(creatorId, donation.creator?.id)
assertEquals(donorId, donation.member?.id)
assertEquals(LocalDateTime.of(2026, 11, 5, 0, 0), donation.createdAt)
}
private fun donationCount(): Long {
return entityManager.createQuery("select count(d) from ChannelDonationMessage d", java.lang.Long::class.java)
.singleResult.toLong()
}
@TestConfiguration
class SqlCaptureConfiguration : HibernatePropertiesCustomizer, StatementInspector {
val statements = mutableListOf<String>()
override fun customize(hibernateProperties: MutableMap<String, Any>) {
hibernateProperties["hibernate.session_factory.statement_inspector"] = this
}
override fun inspect(sql: String): String {
statements.add(sql)
return sql
}
}
}