test #426
@@ -0,0 +1,5 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationQueryPort
|
||||
|
||||
interface CreatorChannelDonationQueryRepository : CreatorChannelDonationQueryPort
|
||||
@@ -0,0 +1,119 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
|
||||
|
||||
import com.querydsl.core.types.Projections
|
||||
import com.querydsl.core.types.dsl.BooleanExpression
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.QChannelDonationMessage.channelDonationMessage
|
||||
import kr.co.vividnext.sodalive.member.QMember.member
|
||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.domain.CreatorChannelDonationQueryPolicy
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.donation.port.out.CreatorChannelDonationRecord
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Repository
|
||||
class DefaultCreatorChannelDonationQueryRepository(
|
||||
private val queryFactory: JPAQueryFactory
|
||||
) : CreatorChannelDonationQueryRepository {
|
||||
private val queryPolicy = CreatorChannelDonationQueryPolicy()
|
||||
|
||||
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelDonationCreatorRecord? {
|
||||
val creator = queryFactory
|
||||
.select(
|
||||
member.id,
|
||||
member.role,
|
||||
member.nickname,
|
||||
member.isVisibleDonationRank,
|
||||
member.donationRankingPeriod
|
||||
)
|
||||
.from(member)
|
||||
.where(
|
||||
member.id.eq(creatorId),
|
||||
member.isActive.isTrue
|
||||
)
|
||||
.fetchFirst() ?: return null
|
||||
|
||||
return CreatorChannelDonationCreatorRecord(
|
||||
creatorId = creator.get(member.id)!!,
|
||||
role = creator.get(member.role)!!,
|
||||
nickname = creator.get(member.nickname)!!,
|
||||
isVisibleDonationRank = creator.get(member.isVisibleDonationRank)!!,
|
||||
donationRankingPeriod = creator.get(member.donationRankingPeriod)
|
||||
)
|
||||
}
|
||||
|
||||
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean {
|
||||
val blockMember = QBlockMember("creatorChannelDonationBlockMember")
|
||||
return queryFactory
|
||||
.select(blockMember.id)
|
||||
.from(blockMember)
|
||||
.where(
|
||||
blockMember.isActive.isTrue,
|
||||
blockMember.member.id.eq(viewerId).and(blockMember.blockedMember.id.eq(creatorId))
|
||||
.or(blockMember.member.id.eq(creatorId).and(blockMember.blockedMember.id.eq(viewerId)))
|
||||
)
|
||||
.fetchFirst() != null
|
||||
}
|
||||
|
||||
override fun countChannelDonations(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime
|
||||
): Int {
|
||||
return queryFactory
|
||||
.select(channelDonationMessage.id.count())
|
||||
.from(channelDonationMessage)
|
||||
.where(channelDonationCondition(creatorId, viewerId, now))
|
||||
.fetchOne()
|
||||
?.toInt()
|
||||
?: 0
|
||||
}
|
||||
|
||||
override fun findChannelDonations(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<CreatorChannelDonationRecord> {
|
||||
return queryFactory
|
||||
.select(
|
||||
Projections.constructor(
|
||||
CreatorChannelDonationRecord::class.java,
|
||||
channelDonationMessage.member.nickname,
|
||||
channelDonationMessage.member.profileImage,
|
||||
channelDonationMessage.can,
|
||||
channelDonationMessage.additionalMessage,
|
||||
channelDonationMessage.createdAt
|
||||
)
|
||||
)
|
||||
.from(channelDonationMessage)
|
||||
.where(channelDonationCondition(creatorId, viewerId, now))
|
||||
.orderBy(channelDonationMessage.createdAt.desc(), channelDonationMessage.id.desc())
|
||||
.offset(offset)
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
}
|
||||
|
||||
private fun channelDonationCondition(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
now: LocalDateTime
|
||||
): BooleanExpression {
|
||||
val monthRange = queryPolicy.currentKstMonthRange(now)
|
||||
return channelDonationMessage.creator.id.eq(creatorId)
|
||||
.and(channelDonationMessage.createdAt.goe(monthRange.startInclusiveUtc))
|
||||
.and(channelDonationMessage.createdAt.lt(monthRange.endExclusiveUtc))
|
||||
.and(donationVisibilityCondition(creatorId, viewerId))
|
||||
}
|
||||
|
||||
private fun donationVisibilityCondition(creatorId: Long, viewerId: Long): BooleanExpression {
|
||||
return if (creatorId == viewerId) {
|
||||
channelDonationMessage.id.isNotNull
|
||||
} else {
|
||||
channelDonationMessage.isSecret.isFalse
|
||||
.or(channelDonationMessage.member.id.eq(viewerId))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.donation.adapter.out.persistence
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.explorer.profile.channelDonation.ChannelDonationMessage
|
||||
import kr.co.vividnext.sodalive.member.DonationRankingPeriod
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import java.nio.file.Paths
|
||||
import java.time.LocalDateTime
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
properties = [
|
||||
"spring.cache.type=none",
|
||||
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
|
||||
]
|
||||
)
|
||||
@Import(QueryDslConfig::class)
|
||||
class DefaultCreatorChannelDonationQueryRepositoryTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
queryFactory: JPAQueryFactory
|
||||
) {
|
||||
private val repository = DefaultCreatorChannelDonationQueryRepository(queryFactory)
|
||||
|
||||
@Test
|
||||
@DisplayName("활성 회원은 후원 랭킹 설정과 role을 조회하고 비활성 회원은 조회하지 않는다")
|
||||
fun shouldFindOnlyActiveCreatorWithDonationRankingSettings() {
|
||||
val viewer = saveMember("donation-lookup-viewer", MemberRole.USER)
|
||||
val creator = saveMember(
|
||||
"donation-active-creator",
|
||||
MemberRole.CREATOR,
|
||||
isVisibleDonationRank = false,
|
||||
donationRankingPeriod = DonationRankingPeriod.WEEKLY
|
||||
)
|
||||
val inactiveCreator = saveMember("donation-inactive-creator", MemberRole.CREATOR, isActive = false)
|
||||
val nonCreator = saveMember("donation-non-creator", MemberRole.USER)
|
||||
flushAndClear()
|
||||
|
||||
val creatorRecord = repository.findCreator(creator.id!!, viewer.id!!)
|
||||
val inactiveRecord = repository.findCreator(inactiveCreator.id!!, viewer.id!!)
|
||||
val nonCreatorRecord = repository.findCreator(nonCreator.id!!, viewer.id!!)
|
||||
|
||||
assertNotNull(creatorRecord)
|
||||
assertEquals(creator.id, creatorRecord!!.creatorId)
|
||||
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||
assertEquals(creator.nickname, creatorRecord.nickname)
|
||||
assertFalse(creatorRecord.isVisibleDonationRank)
|
||||
assertEquals(DonationRankingPeriod.WEEKLY, creatorRecord.donationRankingPeriod)
|
||||
assertNull(inactiveRecord)
|
||||
assertEquals(MemberRole.USER, nonCreatorRecord!!.role)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("조회자와 크리에이터 사이 양방향 활성 차단만 차단 상태로 조회한다")
|
||||
fun shouldFindActiveBlockInBothDirections() {
|
||||
val viewer = saveMember("donation-block-viewer", MemberRole.USER)
|
||||
val creator = saveMember("donation-block-creator", MemberRole.CREATOR)
|
||||
val otherCreator = saveMember("donation-other-creator", MemberRole.CREATOR)
|
||||
saveBlock(viewer, creator, isActive = true)
|
||||
saveBlock(otherCreator, viewer, isActive = false)
|
||||
flushAndClear()
|
||||
|
||||
assertTrue(repository.existsBlockedBetween(viewer.id!!, creator.id!!))
|
||||
assertTrue(repository.existsBlockedBetween(creator.id!!, viewer.id!!))
|
||||
assertFalse(repository.existsBlockedBetween(viewer.id!!, otherCreator.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("크리에이터 본인은 현재 KST 월 범위의 공개/비공개 채널 후원을 모두 조회한다")
|
||||
fun shouldCountAndFindAllCurrentMonthDonationsForCreatorSelf() {
|
||||
val now = LocalDateTime.of(2026, 6, 22, 3, 0)
|
||||
val creator = saveMember("donation-self-creator", MemberRole.CREATOR)
|
||||
val donor = saveMember("donation-self-donor", MemberRole.USER, profileImage = "self-donor.png")
|
||||
val monthStartCreatedAt = LocalDateTime.of(2026, 5, 31, 15, 0)
|
||||
val monthStart = saveDonation(creator, donor, 100, monthStartCreatedAt, additionalMessage = null)
|
||||
val secret = saveDonation(creator, donor, 200, LocalDateTime.of(2026, 6, 22, 2, 0), isSecret = true)
|
||||
saveDonation(creator, donor, 300, LocalDateTime.of(2026, 5, 31, 14, 59, 59))
|
||||
saveDonation(creator, donor, 400, LocalDateTime.of(2026, 6, 30, 15, 0))
|
||||
flushAndClear()
|
||||
|
||||
val count = repository.countChannelDonations(creator.id!!, creator.id!!, now)
|
||||
val records = repository.findChannelDonations(creator.id!!, creator.id!!, now, offset = 0, limit = 10)
|
||||
|
||||
assertEquals(2, count)
|
||||
assertEquals(listOf(secret.can, monthStart.can), records.map { it.can })
|
||||
assertEquals(donor.nickname, records.last().nickname)
|
||||
assertEquals(donor.profileImage, records.last().profileImagePath)
|
||||
assertNull(records.last().message)
|
||||
assertEquals(monthStartCreatedAt, records.last().createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일반 조회자는 현재 KST 월 범위의 공개 후원과 본인 비공개 후원만 조회한다")
|
||||
fun shouldCountAndFindOnlyVisibleCurrentMonthDonationsForViewer() {
|
||||
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||
val creator = saveMember("donation-visible-creator", MemberRole.CREATOR)
|
||||
val viewer = saveMember("donation-visible-viewer", MemberRole.USER)
|
||||
val otherDonor = saveMember("donation-visible-other", MemberRole.USER)
|
||||
val publicDonation = saveDonation(creator, otherDonor, 100, now.minusHours(3), additionalMessage = "public")
|
||||
val ownSecretDonation = saveDonation(
|
||||
creator,
|
||||
viewer,
|
||||
200,
|
||||
now.minusHours(2),
|
||||
isSecret = true,
|
||||
additionalMessage = "own secret"
|
||||
)
|
||||
saveDonation(creator, otherDonor, 300, now.minusHours(1), isSecret = true, additionalMessage = "hidden")
|
||||
flushAndClear()
|
||||
|
||||
val count = repository.countChannelDonations(creator.id!!, viewer.id!!, now)
|
||||
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 0, limit = 10)
|
||||
|
||||
assertEquals(2, count)
|
||||
assertEquals(listOf(ownSecretDonation.can, publicDonation.can), records.map { it.can })
|
||||
assertEquals(listOf("own secret", "public"), records.map { it.message })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("채널 후원 목록은 createdAt desc, id desc로 정렬하고 offset/limit을 적용한다")
|
||||
fun shouldOrderByCreatedAtAndIdDescWithOffsetAndLimit() {
|
||||
val now = LocalDateTime.of(2026, 6, 22, 12, 0)
|
||||
val creator = saveMember("donation-order-creator", MemberRole.CREATOR)
|
||||
val viewer = saveMember("donation-order-viewer", MemberRole.USER)
|
||||
val donor = saveMember("donation-order-donor", MemberRole.USER)
|
||||
val sameCreatedAt = now.minusHours(1)
|
||||
val first = saveDonation(creator, donor, 100, sameCreatedAt, additionalMessage = "first")
|
||||
val second = saveDonation(creator, donor, 200, sameCreatedAt, additionalMessage = "second")
|
||||
saveDonation(creator, donor, 300, sameCreatedAt.plusMinutes(1), additionalMessage = "newest")
|
||||
flushAndClear()
|
||||
|
||||
val records = repository.findChannelDonations(creator.id!!, viewer.id!!, now, offset = 1, limit = 2)
|
||||
|
||||
assertEquals(listOf(second.can, first.can), records.map { it.can })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("후원 탭 repository 목록 조회는 entity 전체 fetch 없이 필요한 컬럼 projection만 사용한다")
|
||||
fun shouldUseProjectionForDonationList() {
|
||||
val source = Paths.get(
|
||||
"src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/donation/adapter/out/persistence/" +
|
||||
"DefaultCreatorChannelDonationQueryRepository.kt"
|
||||
)
|
||||
.toFile()
|
||||
.readText()
|
||||
|
||||
assertFalse(source.contains(".selectFrom(channelDonationMessage)"), "donation list must not fetch entity rows")
|
||||
assertTrue(
|
||||
source.contains(
|
||||
"""Projections.constructor(
|
||||
CreatorChannelDonationRecord::class.java"""
|
||||
),
|
||||
"donation list must use constructor projection for direct record mapping"
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveMember(
|
||||
nickname: String,
|
||||
role: MemberRole,
|
||||
isActive: Boolean = true,
|
||||
profileImage: String? = "$nickname.png",
|
||||
isVisibleDonationRank: Boolean = true,
|
||||
donationRankingPeriod: DonationRankingPeriod? = DonationRankingPeriod.CUMULATIVE
|
||||
): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
profileImage = profileImage,
|
||||
role = role,
|
||||
isVisibleDonationRank = isVisibleDonationRank,
|
||||
donationRankingPeriod = donationRankingPeriod,
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveBlock(member: Member, blockedMember: Member, isActive: Boolean): BlockMember {
|
||||
val block = BlockMember(isActive = isActive)
|
||||
block.member = member
|
||||
block.blockedMember = blockedMember
|
||||
entityManager.persist(block)
|
||||
return block
|
||||
}
|
||||
|
||||
private fun saveDonation(
|
||||
creator: Member,
|
||||
donor: Member,
|
||||
can: Int,
|
||||
createdAt: LocalDateTime,
|
||||
isSecret: Boolean = false,
|
||||
additionalMessage: String? = "thanks"
|
||||
): ChannelDonationMessage {
|
||||
val donation = ChannelDonationMessage(can = can, isSecret = isSecret, additionalMessage = additionalMessage)
|
||||
donation.creator = creator
|
||||
donation.member = donor
|
||||
entityManager.persist(donation)
|
||||
entityManager.flush()
|
||||
updateCreatedAt("ChannelDonationMessage", donation.id!!, createdAt)
|
||||
return donation
|
||||
}
|
||||
|
||||
private fun updateCreatedAt(entityName: String, id: Long, createdAt: LocalDateTime) {
|
||||
entityManager.createQuery("update $entityName e set e.createdAt = :createdAt where e.id = :id")
|
||||
.setParameter("createdAt", createdAt)
|
||||
.setParameter("id", id)
|
||||
.executeUpdate()
|
||||
}
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user