test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
3 changed files with 351 additions and 0 deletions
Showing only changes of commit 951f6789f0 - Show all commits

View File

@@ -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

View File

@@ -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))
}
}
}

View File

@@ -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()
}
}