feat(home): 팔로잉 탭 최근 소식 정책을 보강한다
This commit is contained in:
@@ -497,12 +497,14 @@ class MemberService(
|
||||
fun creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long) {
|
||||
if (creatorId == memberId) return
|
||||
|
||||
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate(
|
||||
creatorId = creatorId,
|
||||
memberId = memberId
|
||||
)
|
||||
|
||||
if (creatorFollowing == null) {
|
||||
if (!isActive) return
|
||||
|
||||
val creator = repository.findByIdOrNull(creatorId)
|
||||
?: throw SodaException(messageKey = "member.validation.creator_not_found")
|
||||
val member = repository.findByIdOrNull(memberId)
|
||||
@@ -515,12 +517,15 @@ class MemberService(
|
||||
} else {
|
||||
creatorFollowing.isNotify = isNotify && isActive
|
||||
creatorFollowing.isActive = isActive
|
||||
if (!isActive) {
|
||||
homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun creatorUnFollow(creatorId: Long, memberId: Long) {
|
||||
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
val creatorFollowing = creatorFollowingRepository.findByCreatorIdAndMemberIdForUpdate(
|
||||
creatorId = creatorId,
|
||||
memberId = memberId
|
||||
)
|
||||
|
||||
@@ -4,12 +4,14 @@ import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.stereotype.Repository
|
||||
import javax.persistence.LockModeType
|
||||
|
||||
@Repository
|
||||
interface CreatorFollowingRepository : JpaRepository<CreatorFollowing, Long>, CreatorFollowingQueryRepository
|
||||
|
||||
interface CreatorFollowingQueryRepository {
|
||||
fun findByCreatorIdAndMemberId(creatorId: Long, memberId: Long): CreatorFollowing?
|
||||
fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing?
|
||||
}
|
||||
|
||||
@Repository
|
||||
@@ -25,4 +27,15 @@ class CreatorFollowingQueryRepositoryImpl(
|
||||
)
|
||||
.fetchFirst()
|
||||
}
|
||||
|
||||
override fun findByCreatorIdAndMemberIdForUpdate(creatorId: Long, memberId: Long): CreatorFollowing? {
|
||||
return queryFactory
|
||||
.selectFrom(creatorFollowing)
|
||||
.where(
|
||||
creatorFollowing.creator.id.eq(creatorId)
|
||||
.and(creatorFollowing.member.id.eq(memberId))
|
||||
)
|
||||
.setLockMode(LockModeType.PESSIMISTIC_WRITE)
|
||||
.fetchFirst()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommun
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.QCreatorCommunityComment.creatorCommunityComment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.QCreatorCommunityLike.creatorCommunityLike
|
||||
import kr.co.vividnext.sodalive.extensions.toUtcIso
|
||||
import kr.co.vividnext.sodalive.live.room.GenderRestriction
|
||||
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.QMember
|
||||
import kr.co.vividnext.sodalive.member.block.QBlockMember
|
||||
@@ -22,11 +24,16 @@ import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeF
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCommunityPostNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingContentRankingNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreatorRankingNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.QCreatorRankingSnapshotJob
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.LocalDateTime
|
||||
@@ -64,7 +71,13 @@ class DefaultHomeFollowingQueryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
|
||||
override fun findOnAirLives(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
limit: Int
|
||||
): List<HomeFollowingLive> {
|
||||
val creator = QMember("onAirCreator")
|
||||
return queryFactory
|
||||
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
|
||||
@@ -80,6 +93,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
creator.isActive.isTrue,
|
||||
creator.role.eq(MemberRole.CREATOR),
|
||||
adultLiveCondition(canViewAdultContent),
|
||||
genderLiveCondition(memberId, effectiveViewerGender),
|
||||
creatorJoinLiveCondition(memberId, isViewerCreator),
|
||||
notBlockedCreatorCondition(memberId, creator.id)
|
||||
)
|
||||
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
|
||||
@@ -99,11 +114,19 @@ class DefaultHomeFollowingQueryRepository(
|
||||
override fun findMonthlySchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
): List<HomeFollowingSchedule> {
|
||||
val window = monthlyScheduleWindow(now)
|
||||
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
|
||||
val liveSchedules = findLiveSchedules(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
effectiveViewerGender,
|
||||
isViewerCreator,
|
||||
window
|
||||
)
|
||||
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
|
||||
return (liveSchedules + audioSchedules)
|
||||
.sortedWith(
|
||||
@@ -141,7 +164,7 @@ class DefaultHomeFollowingQueryRepository(
|
||||
.from(homeFollowingNewsInbox)
|
||||
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
|
||||
.leftJoin(newsAudioContent).on(
|
||||
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT),
|
||||
homeFollowingNewsInbox.newsType.`in`(FollowingNewsType.AUDIO_CONTENT, FollowingNewsType.CONTENT_RANKING),
|
||||
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId)
|
||||
)
|
||||
.leftJoin(newsCommunity).on(
|
||||
@@ -157,7 +180,7 @@ class DefaultHomeFollowingQueryRepository(
|
||||
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||
adultNewsCondition(canViewAdultContent),
|
||||
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||
activeNewsTargetCondition(canViewAdultContent)
|
||||
activeNewsTargetCondition(memberId, canViewAdultContent, nowUtc)
|
||||
)
|
||||
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
|
||||
.limit(limit.toLong())
|
||||
@@ -209,6 +232,16 @@ class DefaultHomeFollowingQueryRepository(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
contentRanking = if (type == FollowingNewsType.CONTENT_RANKING && rank != null) {
|
||||
HomeFollowingContentRankingNews(
|
||||
rank = rank,
|
||||
contentId = targetId,
|
||||
contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||
title = get(newsAudioContent.title)!!
|
||||
)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
communityPost = if (type == FollowingNewsType.COMMUNITY_POST) {
|
||||
HomeFollowingCommunityPostNews(
|
||||
postId = targetId,
|
||||
@@ -229,6 +262,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
private fun findLiveSchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
window: ScheduleWindow
|
||||
): List<HomeFollowingSchedule> {
|
||||
val creator = QMember("scheduleLiveCreator")
|
||||
@@ -254,6 +289,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
creator.isActive.isTrue,
|
||||
creator.role.eq(MemberRole.CREATOR),
|
||||
adultLiveCondition(canViewAdultContent),
|
||||
genderLiveCondition(memberId, effectiveViewerGender),
|
||||
creatorJoinLiveCondition(memberId, isViewerCreator),
|
||||
notBlockedCreatorCondition(memberId, creator.id)
|
||||
)
|
||||
.fetch()
|
||||
@@ -340,6 +377,20 @@ class DefaultHomeFollowingQueryRepository(
|
||||
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun genderLiveCondition(viewerId: Long, effectiveViewerGender: Gender): BooleanExpression? {
|
||||
val genderCondition = when (effectiveViewerGender) {
|
||||
Gender.MALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.MALE_ONLY)
|
||||
Gender.FEMALE -> liveRoom.genderRestriction.`in`(GenderRestriction.ALL, GenderRestriction.FEMALE_ONLY)
|
||||
Gender.NONE -> return null
|
||||
}
|
||||
return genderCondition.or(liveRoom.member.id.eq(viewerId))
|
||||
}
|
||||
|
||||
private fun creatorJoinLiveCondition(viewerId: Long, isViewerCreator: Boolean): BooleanExpression? {
|
||||
if (!isViewerCreator) return null
|
||||
return liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(viewerId))
|
||||
}
|
||||
|
||||
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
|
||||
}
|
||||
@@ -385,7 +436,11 @@ class DefaultHomeFollowingQueryRepository(
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression {
|
||||
private fun activeNewsTargetCondition(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
nowUtc: LocalDateTime
|
||||
): BooleanExpression {
|
||||
val newsAudioContent = QAudioContent("newsAudioContent")
|
||||
val newsCommunity = QCreatorCommunity("newsCommunity")
|
||||
val activeAudioExists = JPAExpressions
|
||||
@@ -410,10 +465,84 @@ class DefaultHomeFollowingQueryRepository(
|
||||
|
||||
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
|
||||
.and(homeFollowingNewsInbox.rank.isNotNull)
|
||||
.and(latestVisibleCreatorRankingBatchCondition(nowUtc))
|
||||
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
|
||||
.or(
|
||||
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CONTENT_RANKING)
|
||||
.and(homeFollowingNewsInbox.rank.isNotNull)
|
||||
.and(activeAudioExists)
|
||||
.and(latestContentRankingNewsCondition(memberId, canViewAdultContent, nowUtc))
|
||||
)
|
||||
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
|
||||
}
|
||||
|
||||
private fun latestVisibleCreatorRankingBatchCondition(nowUtc: LocalDateTime): BooleanExpression {
|
||||
val latestCreatorRankingSnapshot = QCreatorRankingSnapshot("latestCreatorRankingSnapshot")
|
||||
val latestCreatorRankingJob = QCreatorRankingSnapshotJob("latestCreatorRankingJob")
|
||||
val anyCreatorRankingJob = QCreatorRankingSnapshotJob("anyCreatorRankingJob")
|
||||
val latestJobVisibleFromAt = JPAExpressions
|
||||
.select(latestCreatorRankingJob.visibleFromAtUtc.max())
|
||||
.from(latestCreatorRankingJob)
|
||||
.where(
|
||||
latestCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
|
||||
latestCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
|
||||
latestCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
|
||||
)
|
||||
val hasRankingJob = JPAExpressions
|
||||
.selectOne()
|
||||
.from(anyCreatorRankingJob)
|
||||
.where(
|
||||
anyCreatorRankingJob.rankingType.eq(CreatorRankingType.WEEKLY),
|
||||
anyCreatorRankingJob.status.eq(CreatorRankingSnapshotJobStatus.DONE),
|
||||
anyCreatorRankingJob.visibleFromAtUtc.loe(nowUtc)
|
||||
)
|
||||
.exists()
|
||||
val latestSnapshotVisibleFromAt = JPAExpressions
|
||||
.select(latestCreatorRankingSnapshot.visibleFromAtUtc.max())
|
||||
.from(latestCreatorRankingSnapshot)
|
||||
.where(
|
||||
latestCreatorRankingSnapshot.rankingType.eq(CreatorRankingType.WEEKLY),
|
||||
latestCreatorRankingSnapshot.visibleFromAtUtc.loe(nowUtc)
|
||||
)
|
||||
|
||||
return homeFollowingNewsInbox.visibleFromAtUtc.eq(latestJobVisibleFromAt).or(
|
||||
hasRankingJob.not().and(homeFollowingNewsInbox.visibleFromAtUtc.eq(latestSnapshotVisibleFromAt))
|
||||
)
|
||||
}
|
||||
|
||||
private fun latestContentRankingNewsCondition(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
nowUtc: LocalDateTime
|
||||
): BooleanExpression {
|
||||
val newerContentRanking = QHomeFollowingNewsInbox("newerContentRanking")
|
||||
return JPAExpressions
|
||||
.selectOne()
|
||||
.from(newerContentRanking)
|
||||
.where(
|
||||
newerContentRanking.memberId.eq(memberId),
|
||||
newerContentRanking.newsType.eq(FollowingNewsType.CONTENT_RANKING),
|
||||
newerContentRanking.targetId.eq(homeFollowingNewsInbox.targetId),
|
||||
newerContentRanking.isActive.isTrue,
|
||||
newerContentRanking.rank.isNotNull,
|
||||
adultNewerContentRankingCondition(canViewAdultContent, newerContentRanking),
|
||||
newerContentRanking.visibleFromAtUtc.loe(nowUtc),
|
||||
newerContentRanking.visibleFromAtUtc.gt(homeFollowingNewsInbox.visibleFromAtUtc)
|
||||
.or(
|
||||
newerContentRanking.visibleFromAtUtc.eq(homeFollowingNewsInbox.visibleFromAtUtc)
|
||||
.and(newerContentRanking.id.gt(homeFollowingNewsInbox.id))
|
||||
)
|
||||
)
|
||||
.notExists()
|
||||
}
|
||||
|
||||
private fun adultNewerContentRankingCondition(
|
||||
canViewAdultContent: Boolean,
|
||||
newerContentRanking: QHomeFollowingNewsInbox
|
||||
): BooleanExpression? {
|
||||
return if (canViewAdultContent) null else newerContentRanking.isAdult.isFalse
|
||||
}
|
||||
|
||||
private fun adultAudioNewsTargetCondition(
|
||||
canViewAdultContent: Boolean,
|
||||
newsAudioContent: QAudioContent
|
||||
|
||||
@@ -52,6 +52,7 @@ interface HomeFollowingNewsInboxJpaRepository : JpaRepository<HomeFollowingNewsI
|
||||
where cf.creator_id = :creatorId
|
||||
and cf.is_active = true
|
||||
order by cf.member_id asc
|
||||
for update
|
||||
""",
|
||||
nativeQuery = true
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing
|
||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort
|
||||
@@ -19,17 +21,36 @@ class HomeFollowingQueryService(
|
||||
fun findHomeFollowing(member: Member): HomeFollowing {
|
||||
val memberId = requireNotNull(member.id)
|
||||
val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member)
|
||||
val effectiveViewerGender = member.effectiveGender()
|
||||
val isViewerCreator = member.role == MemberRole.CREATOR
|
||||
val now = nowProvider()
|
||||
|
||||
return HomeFollowing(
|
||||
followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT),
|
||||
onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT),
|
||||
onAirLives = queryPort.findOnAirLives(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
effectiveViewerGender,
|
||||
isViewerCreator,
|
||||
ON_AIR_LIVES_LIMIT
|
||||
),
|
||||
recentChats = emptyList(),
|
||||
monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT),
|
||||
monthlySchedules = queryPort.findMonthlySchedules(
|
||||
memberId,
|
||||
canViewAdultContent,
|
||||
effectiveViewerGender,
|
||||
isViewerCreator,
|
||||
now,
|
||||
MONTHLY_SCHEDULES_LIMIT
|
||||
),
|
||||
recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Member.effectiveGender(): Gender {
|
||||
return auth?.let { if (it.gender == 1) Gender.MALE else Gender.FEMALE } ?: gender
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FOLLOWING_CREATORS_LIMIT = 20
|
||||
private const val ON_AIR_LIVES_LIMIT = 10
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
||||
@@ -9,11 +10,19 @@ import java.time.LocalDateTime
|
||||
interface HomeFollowingQueryPort {
|
||||
fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator>
|
||||
|
||||
fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive>
|
||||
fun findOnAirLives(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
limit: Int
|
||||
): List<HomeFollowingLive>
|
||||
|
||||
fun findMonthlySchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
): List<HomeFollowingSchedule>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package kr.co.vividnext.sodalive.member
|
||||
|
||||
import kr.co.vividnext.sodalive.common.SodaException
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.member.login.LoginRequest
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
|
||||
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertThrows
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -23,6 +25,7 @@ import javax.persistence.EntityManager
|
||||
class MemberServiceTest @Autowired constructor(
|
||||
private val service: MemberService,
|
||||
private val memberRepository: MemberRepository,
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
|
||||
private val entityManager: EntityManager
|
||||
) {
|
||||
@@ -94,4 +97,88 @@ class MemberServiceTest @Autowired constructor(
|
||||
|
||||
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("통합 팔로우 API에서 isActive=false이고 기존 관계가 없으면 active 팔로우를 생성하지 않는다")
|
||||
fun shouldNotCreateActiveFollowingWhenCreatorFollowIsInactiveWithoutExistingRelation() {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
email = "inactive-follower@test.com",
|
||||
password = "password",
|
||||
nickname = "inactive-follower"
|
||||
)
|
||||
)
|
||||
val creator = memberRepository.save(
|
||||
Member(
|
||||
email = "inactive-creator@test.com",
|
||||
password = "password",
|
||||
nickname = "inactive-creator",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
|
||||
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
assertNull(creatorFollowingRepository.findByCreatorIdAndMemberId(creatorId = creator.id!!, memberId = member.id!!))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("통합 팔로우 API에서 isActive=false이면 기존 inbox를 비활성화하고 재팔로우해도 복구하지 않는다")
|
||||
fun shouldDeactivateFollowingNewsInboxWhenCreatorFollowIsInactiveAndKeepInactiveAfterRefollow() {
|
||||
val member = memberRepository.save(
|
||||
Member(
|
||||
email = "integrated-follower@test.com",
|
||||
password = "password",
|
||||
nickname = "integrated-follower"
|
||||
)
|
||||
)
|
||||
val creator = memberRepository.save(
|
||||
Member(
|
||||
email = "integrated-creator@test.com",
|
||||
password = "password",
|
||||
nickname = "integrated-creator",
|
||||
role = MemberRole.CREATOR
|
||||
)
|
||||
)
|
||||
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
|
||||
val inbox = homeFollowingNewsInboxJpaRepository.save(
|
||||
HomeFollowingNewsInbox(
|
||||
memberId = member.id!!,
|
||||
creatorId = creator.id!!,
|
||||
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||
sourceKey = "COMMUNITY_POST:2",
|
||||
targetId = 2L,
|
||||
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
|
||||
creatorNickname = "integrated-creator",
|
||||
creatorProfileImagePath = null,
|
||||
title = "title",
|
||||
body = "body",
|
||||
thumbnailImagePath = null,
|
||||
rank = null,
|
||||
isAdult = false
|
||||
)
|
||||
)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
val inactiveFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creator.id!!,
|
||||
memberId = member.id!!
|
||||
)
|
||||
assertEquals(false, inactiveFollowing?.isActive)
|
||||
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
|
||||
|
||||
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
|
||||
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
|
||||
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
|
||||
@@ -167,7 +169,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
||||
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||
targetId = paidPost.id!!
|
||||
)
|
||||
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
|
||||
val rankedVisibleFromAtUtc = now.minusHours(1)
|
||||
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
|
||||
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
|
||||
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
@@ -275,6 +279,28 @@ class HomeFollowingEndToEndTest @Autowired constructor(
|
||||
return news
|
||||
}
|
||||
|
||||
private fun saveCreatorRankingSnapshot(
|
||||
creator: Member,
|
||||
rankNo: Int,
|
||||
visibleFromAtUtc: LocalDateTime
|
||||
): CreatorRankingSnapshot {
|
||||
val snapshot = CreatorRankingSnapshot(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
|
||||
aggregationEndAtUtc = visibleFromAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
creatorId = creator.id!!,
|
||||
nickname = creator.nickname,
|
||||
profileImageUrl = creator.profileImage,
|
||||
rankNo = rankNo,
|
||||
finalScore = 100.0 - rankNo,
|
||||
scorePolicyVersion = "v1",
|
||||
scoreDetailJson = "{}"
|
||||
)
|
||||
entityManager.persist(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity {
|
||||
val post = CreatorCommunity(
|
||||
content = content,
|
||||
|
||||
@@ -7,13 +7,20 @@ import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunity
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.comment.CreatorCommunityComment
|
||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.like.CreatorCommunityLike
|
||||
import kr.co.vividnext.sodalive.live.room.GenderRestriction
|
||||
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.block.BlockMember
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
|
||||
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshotJob
|
||||
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
|
||||
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -98,12 +105,50 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
|
||||
flushAndClear()
|
||||
|
||||
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10)
|
||||
val lives = repository.findOnAirLives(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = false,
|
||||
effectiveViewerGender = Gender.NONE,
|
||||
isViewerCreator = false,
|
||||
limit = 10
|
||||
)
|
||||
|
||||
assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId })
|
||||
assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("On Air는 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
|
||||
fun shouldFindFollowingOnAirLivesWithViewerGenderAndCreatorJoinPolicy() {
|
||||
val viewer = saveMember("live-policy-viewer", MemberRole.CREATOR)
|
||||
val creator = saveMember("live-policy-creator", MemberRole.CREATOR)
|
||||
saveFollowing(viewer, creator)
|
||||
saveLiveRoom(
|
||||
creator,
|
||||
LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||
channelName = "male-only",
|
||||
genderRestriction = GenderRestriction.MALE_ONLY
|
||||
)
|
||||
saveLiveRoom(
|
||||
creator,
|
||||
LocalDateTime.of(2026, 6, 25, 11, 0),
|
||||
channelName = "creator-hidden",
|
||||
isAvailableJoinCreator = false
|
||||
)
|
||||
val visible = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "visible")
|
||||
flushAndClear()
|
||||
|
||||
val lives = repository.findOnAirLives(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
effectiveViewerGender = Gender.FEMALE,
|
||||
isViewerCreator = true,
|
||||
limit = 10
|
||||
)
|
||||
|
||||
assertEquals(listOf(visible.id!!), lives.map { it.liveId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
|
||||
fun shouldFindMonthlySchedulesInKstWindow() {
|
||||
@@ -127,6 +172,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
val schedules = repository.findMonthlySchedules(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = false,
|
||||
effectiveViewerGender = Gender.NONE,
|
||||
isViewerCreator = false,
|
||||
now = LocalDateTime.of(2026, 6, 25, 12, 0),
|
||||
limit = 3
|
||||
)
|
||||
@@ -160,6 +207,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
val schedules = repository.findMonthlySchedules(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = false,
|
||||
effectiveViewerGender = Gender.NONE,
|
||||
isViewerCreator = false,
|
||||
now = LocalDateTime.of(2026, 6, 25, 0, 0),
|
||||
limit = 3
|
||||
)
|
||||
@@ -181,6 +230,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
val schedules = repository.findMonthlySchedules(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = false,
|
||||
effectiveViewerGender = Gender.NONE,
|
||||
isViewerCreator = false,
|
||||
now = LocalDateTime.of(2026, 6, 25, 14, 30),
|
||||
limit = 3
|
||||
)
|
||||
@@ -205,6 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
val schedules = repository.findMonthlySchedules(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = false,
|
||||
effectiveViewerGender = Gender.NONE,
|
||||
isViewerCreator = false,
|
||||
now = LocalDateTime.of(2026, 6, 25, 0, 0),
|
||||
limit = 10
|
||||
)
|
||||
@@ -220,6 +273,41 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("라이브 스케줄은 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
|
||||
fun shouldFindLiveMonthlySchedulesWithViewerGenderAndCreatorJoinPolicy() {
|
||||
val viewer = saveMember("schedule-policy-viewer", MemberRole.CREATOR)
|
||||
val creator = saveMember("schedule-policy-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("schedule-policy-theme")
|
||||
saveFollowing(viewer, creator)
|
||||
saveLiveRoom(
|
||||
creator,
|
||||
LocalDateTime.of(2026, 6, 25, 1, 0),
|
||||
channelName = null,
|
||||
genderRestriction = GenderRestriction.MALE_ONLY
|
||||
)
|
||||
saveLiveRoom(
|
||||
creator,
|
||||
LocalDateTime.of(2026, 6, 25, 2, 0),
|
||||
channelName = null,
|
||||
isAvailableJoinCreator = false
|
||||
)
|
||||
val visibleLive = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 3, 0), channelName = null)
|
||||
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 4, 0))
|
||||
flushAndClear()
|
||||
|
||||
val schedules = repository.findMonthlySchedules(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
effectiveViewerGender = Gender.FEMALE,
|
||||
isViewerCreator = true,
|
||||
now = LocalDateTime.of(2026, 6, 25, 0, 0),
|
||||
limit = 10
|
||||
)
|
||||
|
||||
assertEquals(listOf("LIVE:${visibleLive.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
|
||||
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
|
||||
@@ -230,6 +318,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
saveFollowing(viewer, creator)
|
||||
saveFollowing(viewer, blockedCreator)
|
||||
saveFollowing(viewer, nonCreator)
|
||||
saveCreatorRankingSnapshot(creator, rankNo = 3, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0))
|
||||
saveNews(viewer.id!!, creator.id!!, "old-without-rank", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
|
||||
val latestVisible = saveNews(viewer.id!!, creator.id!!, "latest", LocalDateTime.of(2026, 6, 25, 9, 0), rank = 3)
|
||||
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
|
||||
@@ -250,12 +339,236 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 공개 배치만 조회한다")
|
||||
fun shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews() {
|
||||
val viewer = saveMember("news-ranking-viewer", MemberRole.USER)
|
||||
val firstCreator = saveMember("news-ranking-first", MemberRole.CREATOR)
|
||||
val secondCreator = saveMember("news-ranking-second", MemberRole.CREATOR)
|
||||
saveFollowing(viewer, firstCreator)
|
||||
saveFollowing(viewer, secondCreator)
|
||||
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
|
||||
saveCreatorRankingSnapshot(secondCreator, rankNo = 2, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
|
||||
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 15, 0, 0))
|
||||
val oldFirstNews = saveNews(
|
||||
viewer.id!!,
|
||||
firstCreator.id!!,
|
||||
"old-ranking-first",
|
||||
LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
rank = 1
|
||||
)
|
||||
val oldSecondNews = saveNews(
|
||||
viewer.id!!,
|
||||
secondCreator.id!!,
|
||||
"old-ranking-second",
|
||||
LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
rank = 2
|
||||
)
|
||||
val latestFirstNews = saveNews(
|
||||
viewer.id!!,
|
||||
firstCreator.id!!,
|
||||
"latest-ranking-first",
|
||||
LocalDateTime.of(2026, 6, 15, 0, 0),
|
||||
rank = 1
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val beforeLatestVisible = repository.findRecentNews(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
nowUtc = LocalDateTime.of(2026, 6, 14, 23, 59),
|
||||
limit = 30
|
||||
)
|
||||
val afterLatestVisible = repository.findRecentNews(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
|
||||
limit = 30
|
||||
)
|
||||
|
||||
assertEquals(listOf(oldSecondNews.id!!.toString(), oldFirstNews.id!!.toString()), beforeLatestVisible.map { it.newsId })
|
||||
assertEquals(listOf(latestFirstNews.id!!.toString()), afterLatestVisible.map { it.newsId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 완료 배치가 비어 있어도 과거 배치로 보충하지 않는다")
|
||||
fun shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty() {
|
||||
val viewer = saveMember("news-empty-ranking-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-empty-ranking-creator", MemberRole.CREATOR)
|
||||
saveFollowing(viewer, creator)
|
||||
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
|
||||
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 8, 0, 0))
|
||||
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 15, 0, 0))
|
||||
saveNews(
|
||||
viewer.id!!,
|
||||
creator.id!!,
|
||||
"old-ranking-empty-latest",
|
||||
LocalDateTime.of(2026, 6, 8, 0, 0),
|
||||
rank = 1
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val news = repository.findRecentNews(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
|
||||
limit = 30
|
||||
)
|
||||
|
||||
assertTrue(news.none { it.type == FollowingNewsType.CREATOR_RANKING })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식의 콘텐츠 랭킹은 콘텐츠별 최신 row만 limit 전에 남긴다")
|
||||
fun shouldFindLatestContentRankingNewsPerContentBeforeLimit() {
|
||||
val viewer = saveMember("news-content-ranking-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-content-ranking-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("news-content-ranking-theme")
|
||||
saveFollowing(viewer, creator)
|
||||
val firstContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0)).apply {
|
||||
title = "first ranked content"
|
||||
coverImage = "content-ranking/first.png"
|
||||
}
|
||||
val secondContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10)).apply {
|
||||
title = "second ranked content"
|
||||
coverImage = "content-ranking/second.png"
|
||||
}
|
||||
saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-first-older",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||
rank = 3,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = firstContent.id!!
|
||||
)
|
||||
saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-first-same-time-older-id",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
|
||||
rank = 2,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = firstContent.id!!
|
||||
)
|
||||
val latestFirst = saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-first-same-time-latest-id",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
|
||||
rank = 1,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = firstContent.id!!
|
||||
)
|
||||
val latestSecond = saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-second",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 8, 59),
|
||||
rank = 4,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = secondContent.id!!
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val news = repository.findRecentNews(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||
limit = 2
|
||||
)
|
||||
|
||||
assertEquals(listOf(latestFirst.id!!.toString(), latestSecond.id!!.toString()), news.map { it.newsId })
|
||||
assertEquals(listOf(firstContent.id!!, secondContent.id!!), news.map { it.contentRanking?.contentId })
|
||||
assertEquals(listOf(1, 4), news.map { it.contentRanking?.rank })
|
||||
assertTrue(news.all { it.creatorRanking == null && it.audioContent == null && it.communityPost == null })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식의 콘텐츠 랭킹은 rank가 없는 최신 row가 이전 노출 가능 row를 가리지 않는다")
|
||||
fun shouldKeepOlderContentRankingNewsWhenNewerRowHasNoRank() {
|
||||
val viewer = saveMember("news-content-null-rank-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-content-null-rank-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("news-content-null-rank-theme")
|
||||
saveFollowing(viewer, creator)
|
||||
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0))
|
||||
val visible = saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-visible-before-null-rank",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||
rank = 2,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = content.id!!
|
||||
)
|
||||
saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-null-rank-newer",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||
rank = null,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = content.id!!
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val news = repository.findRecentNews(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = true,
|
||||
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
|
||||
limit = 30
|
||||
)
|
||||
|
||||
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식의 콘텐츠 랭킹은 성인 최신 row가 비성인 회원의 이전 노출 가능 row를 가리지 않는다")
|
||||
fun shouldKeepOlderContentRankingNewsWhenNewerRowIsAdultForNonAdultViewer() {
|
||||
val viewer = saveMember("news-content-adult-row-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-content-adult-row-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme("news-content-adult-row-theme")
|
||||
saveFollowing(viewer, creator)
|
||||
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isAdult = false)
|
||||
val visible = saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-visible-before-adult-row",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||
rank = 2,
|
||||
isAdult = false,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = content.id!!
|
||||
)
|
||||
saveNews(
|
||||
memberId = viewer.id!!,
|
||||
creatorId = creator.id!!,
|
||||
sourceKey = "content-ranking-adult-newer",
|
||||
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||
rank = 1,
|
||||
isAdult = true,
|
||||
newsType = FollowingNewsType.CONTENT_RANKING,
|
||||
targetId = content.id!!
|
||||
)
|
||||
flushAndClear()
|
||||
|
||||
val news = repository.findRecentNews(
|
||||
memberId = viewer.id!!,
|
||||
canViewAdultContent = false,
|
||||
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
|
||||
limit = 30
|
||||
)
|
||||
|
||||
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다")
|
||||
fun shouldNotExposeNewsVisibleAfterUtcNow() {
|
||||
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
|
||||
saveFollowing(viewer, creator)
|
||||
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 14, 30))
|
||||
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30), rank = 1)
|
||||
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31), rank = 2)
|
||||
flushAndClear()
|
||||
@@ -555,14 +868,18 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
creator: Member,
|
||||
beginDateTime: LocalDateTime,
|
||||
channelName: String?,
|
||||
isAdult: Boolean = false
|
||||
isAdult: Boolean = false,
|
||||
genderRestriction: GenderRestriction = GenderRestriction.ALL,
|
||||
isAvailableJoinCreator: Boolean = true
|
||||
): LiveRoom {
|
||||
val liveRoom = LiveRoom(
|
||||
title = "live-${creator.nickname}-$beginDateTime",
|
||||
notice = "notice",
|
||||
beginDateTime = beginDateTime,
|
||||
numberOfPeople = 0,
|
||||
isAdult = isAdult
|
||||
isAdult = isAdult,
|
||||
isAvailableJoinCreator = isAvailableJoinCreator,
|
||||
genderRestriction = genderRestriction
|
||||
).apply {
|
||||
member = creator
|
||||
this.channelName = channelName
|
||||
@@ -667,6 +984,43 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
return news
|
||||
}
|
||||
|
||||
private fun saveCreatorRankingSnapshot(
|
||||
creator: Member,
|
||||
rankNo: Int,
|
||||
visibleFromAtUtc: LocalDateTime,
|
||||
rankingType: CreatorRankingType = CreatorRankingType.WEEKLY
|
||||
): CreatorRankingSnapshot {
|
||||
val snapshot = CreatorRankingSnapshot(
|
||||
rankingType = rankingType,
|
||||
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
|
||||
aggregationEndAtUtc = visibleFromAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
creatorId = creator.id!!,
|
||||
nickname = creator.nickname,
|
||||
profileImageUrl = creator.profileImage,
|
||||
rankNo = rankNo,
|
||||
finalScore = 100.0 - rankNo,
|
||||
scorePolicyVersion = "v1",
|
||||
scoreDetailJson = "{}"
|
||||
)
|
||||
entityManager.persist(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun saveCreatorRankingSnapshotJob(visibleFromAtUtc: LocalDateTime): CreatorRankingSnapshotJob {
|
||||
val job = CreatorRankingSnapshotJob(
|
||||
rankingType = CreatorRankingType.WEEKLY,
|
||||
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
|
||||
aggregationEndAtUtc = visibleFromAtUtc,
|
||||
visibleFromAtUtc = visibleFromAtUtc,
|
||||
trigger = CreatorRankingSnapshotJobTrigger.SCHEDULED,
|
||||
status = CreatorRankingSnapshotJobStatus.DONE,
|
||||
processedAt = visibleFromAtUtc.minusHours(1)
|
||||
)
|
||||
entityManager.persist(job)
|
||||
return job
|
||||
}
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
|
||||
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
|
||||
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
||||
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
|
||||
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
|
||||
@@ -17,7 +18,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
|
||||
import org.springframework.context.annotation.Import
|
||||
import org.springframework.test.annotation.DirtiesContext
|
||||
import org.springframework.test.context.transaction.TestTransaction
|
||||
import org.springframework.transaction.PlatformTransactionManager
|
||||
import org.springframework.transaction.annotation.Propagation
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.time.LocalDateTime
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
import javax.persistence.EntityManager
|
||||
|
||||
@DataJpaTest(
|
||||
@@ -29,9 +38,13 @@ import javax.persistence.EntityManager
|
||||
@Import(QueryDslConfig::class)
|
||||
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
|
||||
private val repository: HomeFollowingNewsInboxJpaRepository,
|
||||
private val entityManager: EntityManager
|
||||
private val creatorFollowingRepository: CreatorFollowingRepository,
|
||||
private val entityManager: EntityManager,
|
||||
transactionManager: PlatformTransactionManager
|
||||
) {
|
||||
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
|
||||
private val transactionalAdapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager, transactionManager)
|
||||
private val transactionTemplate = TransactionTemplate(transactionManager)
|
||||
|
||||
@Test
|
||||
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
|
||||
@@ -115,6 +128,75 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
|
||||
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
@Transactional(propagation = Propagation.NOT_SUPPORTED)
|
||||
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
|
||||
@DisplayName("publish가 follower 조회 후 insert 전 멈춘 사이 언팔로우가 완료돼도 재팔로우 후 과거 소식이 활성화되지 않는다")
|
||||
fun shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed() {
|
||||
val (memberId, creatorId) = transactionTemplate.execute {
|
||||
val creator = saveMember("concurrent-creator", MemberRole.CREATOR)
|
||||
val follower = saveMember("concurrent-follower", MemberRole.USER)
|
||||
saveFollowing(follower, creator, isActive = true)
|
||||
entityManager.flush()
|
||||
follower.id!! to creator.id!!
|
||||
}!!
|
||||
val followerRead = CountDownLatch(1)
|
||||
val allowPublishInsert = CountDownLatch(1)
|
||||
val executor = Executors.newFixedThreadPool(2)
|
||||
|
||||
try {
|
||||
val publish = executor.submit<Int> {
|
||||
transactionTemplate.execute {
|
||||
val followerIds = transactionalAdapter.findActiveFollowerIds(creatorId)
|
||||
followerRead.countDown()
|
||||
assertTrue(allowPublishInsert.await(5, TimeUnit.SECONDS))
|
||||
transactionalAdapter.insertIgnoreAll(
|
||||
followerIds.map { followerId ->
|
||||
record(memberId = followerId, creatorId = creatorId, sourceKey = "COMMUNITY_POST:delayed")
|
||||
}
|
||||
)
|
||||
}!!
|
||||
}
|
||||
assertTrue(followerRead.await(5, TimeUnit.SECONDS))
|
||||
val unfollow = executor.submit {
|
||||
transactionTemplate.executeWithoutResult {
|
||||
val following = creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creatorId,
|
||||
memberId = memberId
|
||||
)!!
|
||||
following.isActive = false
|
||||
transactionalAdapter.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
|
||||
}
|
||||
}
|
||||
|
||||
Thread.sleep(200)
|
||||
val unfollowCompletedBeforeInsert = try {
|
||||
unfollow.get(1, TimeUnit.SECONDS)
|
||||
true
|
||||
} catch (_: TimeoutException) {
|
||||
false
|
||||
}
|
||||
allowPublishInsert.countDown()
|
||||
assertEquals(1, publish.get(5, TimeUnit.SECONDS))
|
||||
if (!unfollowCompletedBeforeInsert) {
|
||||
unfollow.get(5, TimeUnit.SECONDS)
|
||||
}
|
||||
transactionTemplate.executeWithoutResult {
|
||||
creatorFollowingRepository.findByCreatorIdAndMemberId(
|
||||
creatorId = creatorId,
|
||||
memberId = memberId
|
||||
)!!.isActive = true
|
||||
}
|
||||
|
||||
val activeRows = transactionTemplate.execute {
|
||||
repository.findAll().filter { it.memberId == memberId && it.creatorId == creatorId && it.isActive }
|
||||
}!!
|
||||
assertEquals(emptyList<HomeFollowingNewsInbox>(), activeRows)
|
||||
} finally {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveMember(seed: String, role: MemberRole): Member {
|
||||
val member = Member(
|
||||
email = "$seed@test.com",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package kr.co.vividnext.sodalive.v2.home.following.application
|
||||
|
||||
import kr.co.vividnext.sodalive.member.Gender
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.member.auth.Auth
|
||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
|
||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
|
||||
@@ -58,6 +61,24 @@ class HomeFollowingQueryServiceTest {
|
||||
assertEquals(11L, queryPort.memberId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("본인인증 성별 우선 effective gender와 크리에이터 여부는 라이브 조회 port에 전달된다")
|
||||
fun shouldPassEffectiveGenderAndCreatorRoleToLiveQueryPort() {
|
||||
val member = member(13L).apply {
|
||||
role = MemberRole.CREATOR
|
||||
gender = Gender.MALE
|
||||
auth = Auth(name = "auth", birth = "20000101", uniqueCi = "ci", di = "di", gender = 0)
|
||||
}
|
||||
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
|
||||
|
||||
service.findHomeFollowing(member)
|
||||
|
||||
assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender)
|
||||
assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender)
|
||||
assertTrue(queryPort.onAirIsViewerCreator!!)
|
||||
assertTrue(queryPort.monthlySchedulesIsViewerCreator!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
|
||||
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
|
||||
@@ -91,9 +112,13 @@ class HomeFollowingQueryServiceTest {
|
||||
var followingCreatorsLimit: Int? = null
|
||||
var onAirLivesLimit: Int? = null
|
||||
var onAirCanViewAdultContent: Boolean? = null
|
||||
var onAirEffectiveViewerGender: Gender? = null
|
||||
var onAirIsViewerCreator: Boolean? = null
|
||||
var monthlySchedulesLimit: Int? = null
|
||||
var monthlySchedulesNow: LocalDateTime? = null
|
||||
var monthlySchedulesCanViewAdultContent: Boolean? = null
|
||||
var monthlySchedulesEffectiveViewerGender: Gender? = null
|
||||
var monthlySchedulesIsViewerCreator: Boolean? = null
|
||||
var recentNewsLimit: Int? = null
|
||||
var recentNewsNow: LocalDateTime? = null
|
||||
var recentNewsCanViewAdultContent: Boolean? = null
|
||||
@@ -104,8 +129,16 @@ class HomeFollowingQueryServiceTest {
|
||||
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
|
||||
}
|
||||
|
||||
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
|
||||
override fun findOnAirLives(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
limit: Int
|
||||
): List<HomeFollowingLive> {
|
||||
onAirCanViewAdultContent = canViewAdultContent
|
||||
onAirEffectiveViewerGender = effectiveViewerGender
|
||||
onAirIsViewerCreator = isViewerCreator
|
||||
onAirLivesLimit = limit
|
||||
return emptyList()
|
||||
}
|
||||
@@ -113,10 +146,14 @@ class HomeFollowingQueryServiceTest {
|
||||
override fun findMonthlySchedules(
|
||||
memberId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
effectiveViewerGender: Gender,
|
||||
isViewerCreator: Boolean,
|
||||
now: LocalDateTime,
|
||||
limit: Int
|
||||
): List<HomeFollowingSchedule> {
|
||||
monthlySchedulesCanViewAdultContent = canViewAdultContent
|
||||
monthlySchedulesEffectiveViewerGender = effectiveViewerGender
|
||||
monthlySchedulesIsViewerCreator = isViewerCreator
|
||||
monthlySchedulesNow = now
|
||||
monthlySchedulesLimit = limit
|
||||
return emptyList()
|
||||
|
||||
Reference in New Issue
Block a user