feat(home): 최근 소식 원천 데이터를 보강한다
This commit is contained in:
@@ -8,6 +8,8 @@ import com.querydsl.jpa.impl.JPAQueryFactory
|
|||||||
import kr.co.vividnext.sodalive.content.QAudioContent
|
import kr.co.vividnext.sodalive.content.QAudioContent
|
||||||
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
import kr.co.vividnext.sodalive.content.QAudioContent.audioContent
|
||||||
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity
|
import kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.QCreatorCommunity
|
||||||
|
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.extensions.toUtcIso
|
||||||
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -18,7 +20,10 @@ import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
|
|||||||
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox
|
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.QHomeFollowingNewsInbox.homeFollowingNewsInbox
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
|
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.HomeFollowingCreator
|
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.HomeFollowingLive
|
||||||
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews
|
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.home.following.domain.HomeFollowingSchedule
|
||||||
@@ -116,50 +121,109 @@ class DefaultHomeFollowingQueryRepository(
|
|||||||
limit: Int
|
limit: Int
|
||||||
): List<HomeFollowingNews> {
|
): List<HomeFollowingNews> {
|
||||||
val creator = QMember("newsCreator")
|
val creator = QMember("newsCreator")
|
||||||
return queryFactory
|
val newsAudioContent = QAudioContent("recentNewsAudioContent")
|
||||||
|
val newsCommunity = QCreatorCommunity("recentNewsCommunity")
|
||||||
|
val rows = queryFactory
|
||||||
.select(
|
.select(
|
||||||
homeFollowingNewsInbox.id,
|
homeFollowingNewsInbox.id,
|
||||||
homeFollowingNewsInbox.newsType,
|
homeFollowingNewsInbox.newsType,
|
||||||
homeFollowingNewsInbox.creatorProfileImagePath,
|
creator.profileImage,
|
||||||
homeFollowingNewsInbox.creatorNickname,
|
creator.nickname,
|
||||||
homeFollowingNewsInbox.title,
|
|
||||||
homeFollowingNewsInbox.body,
|
|
||||||
homeFollowingNewsInbox.thumbnailImagePath,
|
|
||||||
homeFollowingNewsInbox.targetId,
|
homeFollowingNewsInbox.targetId,
|
||||||
homeFollowingNewsInbox.occurredAtUtc,
|
|
||||||
homeFollowingNewsInbox.visibleFromAtUtc,
|
homeFollowingNewsInbox.visibleFromAtUtc,
|
||||||
homeFollowingNewsInbox.rank
|
homeFollowingNewsInbox.rank,
|
||||||
|
newsAudioContent.title,
|
||||||
|
newsAudioContent.coverImage,
|
||||||
|
newsCommunity.content,
|
||||||
|
newsCommunity.imagePath,
|
||||||
|
newsCommunity.createdAt
|
||||||
)
|
)
|
||||||
.from(homeFollowingNewsInbox)
|
.from(homeFollowingNewsInbox)
|
||||||
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
|
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
|
||||||
|
.leftJoin(newsAudioContent).on(
|
||||||
|
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT),
|
||||||
|
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId)
|
||||||
|
)
|
||||||
|
.leftJoin(newsCommunity).on(
|
||||||
|
homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST),
|
||||||
|
newsCommunity.id.eq(homeFollowingNewsInbox.targetId)
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
homeFollowingNewsInbox.memberId.eq(memberId),
|
homeFollowingNewsInbox.memberId.eq(memberId),
|
||||||
homeFollowingNewsInbox.isActive.isTrue,
|
homeFollowingNewsInbox.isActive.isTrue,
|
||||||
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
|
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
|
||||||
creator.isActive.isTrue,
|
creator.isActive.isTrue,
|
||||||
creator.role.eq(MemberRole.CREATOR),
|
creator.role.eq(MemberRole.CREATOR),
|
||||||
|
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||||
adultNewsCondition(canViewAdultContent),
|
adultNewsCondition(canViewAdultContent),
|
||||||
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
|
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||||
activeNewsTargetCondition()
|
activeNewsTargetCondition(canViewAdultContent)
|
||||||
)
|
)
|
||||||
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
|
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
|
||||||
.limit(limit.toLong())
|
.limit(limit.toLong())
|
||||||
.fetch()
|
.fetch()
|
||||||
.map { row ->
|
val communityPostIds = rows
|
||||||
HomeFollowingNews(
|
.filter { it.get(homeFollowingNewsInbox.newsType) == FollowingNewsType.COMMUNITY_POST }
|
||||||
newsId = row.get(homeFollowingNewsInbox.id)!!.toString(),
|
.map { it.get(homeFollowingNewsInbox.targetId)!! }
|
||||||
type = row.get(homeFollowingNewsInbox.newsType)!!,
|
val likeCounts = communityLikeCounts(communityPostIds)
|
||||||
creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)),
|
val commentCounts = communityCommentCounts(communityPostIds)
|
||||||
creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!,
|
return rows.map { row -> row.toHomeFollowingNews(creator, newsAudioContent, newsCommunity, likeCounts, commentCounts) }
|
||||||
title = row.get(homeFollowingNewsInbox.title)!!,
|
}
|
||||||
body = row.get(homeFollowingNewsInbox.body)!!,
|
|
||||||
thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost),
|
private fun Tuple.toHomeFollowingNews(
|
||||||
targetId = row.get(homeFollowingNewsInbox.targetId)!!,
|
newsCreator: QMember,
|
||||||
occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(),
|
newsAudioContent: QAudioContent,
|
||||||
visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(),
|
newsCommunity: QCreatorCommunity,
|
||||||
rank = row.get(homeFollowingNewsInbox.rank)
|
likeCounts: Map<Long, Int>,
|
||||||
|
commentCounts: Map<Long, Int>
|
||||||
|
): HomeFollowingNews {
|
||||||
|
val type = get(homeFollowingNewsInbox.newsType)!!
|
||||||
|
val targetId = get(homeFollowingNewsInbox.targetId)!!
|
||||||
|
val creatorProfileImageUrl = profileImageUrl(get(newsCreator.profileImage))
|
||||||
|
val creatorNickname = get(newsCreator.nickname)!!
|
||||||
|
val visibleFromAtUtc = get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso()
|
||||||
|
val rank = get(homeFollowingNewsInbox.rank)
|
||||||
|
|
||||||
|
return HomeFollowingNews(
|
||||||
|
newsId = get(homeFollowingNewsInbox.id)!!.toString(),
|
||||||
|
type = type,
|
||||||
|
visibleFromAtUtc = visibleFromAtUtc,
|
||||||
|
creatorRanking = if (type == FollowingNewsType.CREATOR_RANKING && rank != null) {
|
||||||
|
HomeFollowingCreatorRankingNews(
|
||||||
|
rank = rank,
|
||||||
|
creatorId = targetId,
|
||||||
|
nickname = creatorNickname,
|
||||||
|
profileImageUrl = creatorProfileImageUrl
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
audioContent = if (type == FollowingNewsType.AUDIO_CONTENT) {
|
||||||
|
HomeFollowingContentNews(
|
||||||
|
contentId = targetId,
|
||||||
|
contentImageUrl = get(newsAudioContent.coverImage).toCdnUrl(cloudFrontHost),
|
||||||
|
title = get(newsAudioContent.title)!!,
|
||||||
|
creatorProfileImageUrl = creatorProfileImageUrl,
|
||||||
|
creatorNickname = creatorNickname
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
communityPost = if (type == FollowingNewsType.COMMUNITY_POST) {
|
||||||
|
HomeFollowingCommunityPostNews(
|
||||||
|
postId = targetId,
|
||||||
|
creatorProfileImage = creatorProfileImageUrl,
|
||||||
|
creatorNickname = creatorNickname,
|
||||||
|
imageUrl = get(newsCommunity.imagePath).toCdnUrl(cloudFrontHost),
|
||||||
|
content = get(newsCommunity.content)!!,
|
||||||
|
createdAt = get(newsCommunity.createdAt)!!.toUtcIso(),
|
||||||
|
likeCount = likeCounts[targetId] ?: 0,
|
||||||
|
commentCount = commentCounts[targetId] ?: 0
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findLiveSchedules(
|
private fun findLiveSchedules(
|
||||||
@@ -284,7 +348,44 @@ class DefaultHomeFollowingQueryRepository(
|
|||||||
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
|
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeNewsTargetCondition(): BooleanExpression {
|
private fun communityLikeCounts(postIds: List<Long>): Map<Long, Int> {
|
||||||
|
if (postIds.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunityLike.creatorCommunity.id, creatorCommunityLike.id.count())
|
||||||
|
.from(creatorCommunityLike)
|
||||||
|
.where(
|
||||||
|
creatorCommunityLike.creatorCommunity.id.`in`(postIds),
|
||||||
|
creatorCommunityLike.isActive.isTrue
|
||||||
|
)
|
||||||
|
.groupBy(creatorCommunityLike.creatorCommunity.id)
|
||||||
|
.fetch()
|
||||||
|
.associate {
|
||||||
|
it.get(creatorCommunityLike.creatorCommunity.id)!! to
|
||||||
|
(it.get(creatorCommunityLike.id.count())?.toInt() ?: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun communityCommentCounts(postIds: List<Long>): Map<Long, Int> {
|
||||||
|
if (postIds.isEmpty()) return emptyMap()
|
||||||
|
|
||||||
|
return queryFactory
|
||||||
|
.select(creatorCommunityComment.creatorCommunity.id, creatorCommunityComment.id.count())
|
||||||
|
.from(creatorCommunityComment)
|
||||||
|
.where(
|
||||||
|
creatorCommunityComment.creatorCommunity.id.`in`(postIds),
|
||||||
|
creatorCommunityComment.isActive.isTrue,
|
||||||
|
creatorCommunityComment.parent.isNull
|
||||||
|
)
|
||||||
|
.groupBy(creatorCommunityComment.creatorCommunity.id)
|
||||||
|
.fetch()
|
||||||
|
.associate {
|
||||||
|
it.get(creatorCommunityComment.creatorCommunity.id)!! to
|
||||||
|
(it.get(creatorCommunityComment.id.count())?.toInt() ?: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeNewsTargetCondition(canViewAdultContent: Boolean): BooleanExpression {
|
||||||
val newsAudioContent = QAudioContent("newsAudioContent")
|
val newsAudioContent = QAudioContent("newsAudioContent")
|
||||||
val newsCommunity = QCreatorCommunity("newsCommunity")
|
val newsCommunity = QCreatorCommunity("newsCommunity")
|
||||||
val activeAudioExists = JPAExpressions
|
val activeAudioExists = JPAExpressions
|
||||||
@@ -292,7 +393,8 @@ class DefaultHomeFollowingQueryRepository(
|
|||||||
.from(newsAudioContent)
|
.from(newsAudioContent)
|
||||||
.where(
|
.where(
|
||||||
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
|
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
|
||||||
newsAudioContent.isActive.isTrue
|
newsAudioContent.isActive.isTrue,
|
||||||
|
adultAudioNewsTargetCondition(canViewAdultContent, newsAudioContent)
|
||||||
)
|
)
|
||||||
.exists()
|
.exists()
|
||||||
val activeCommunityExists = JPAExpressions
|
val activeCommunityExists = JPAExpressions
|
||||||
@@ -300,15 +402,44 @@ class DefaultHomeFollowingQueryRepository(
|
|||||||
.from(newsCommunity)
|
.from(newsCommunity)
|
||||||
.where(
|
.where(
|
||||||
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
|
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
|
||||||
newsCommunity.isActive.isTrue
|
newsCommunity.isActive.isTrue,
|
||||||
|
newsCommunity.price.loe(0),
|
||||||
|
adultCommunityNewsTargetCondition(canViewAdultContent, newsCommunity)
|
||||||
)
|
)
|
||||||
.exists()
|
.exists()
|
||||||
|
|
||||||
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
|
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
|
||||||
|
.and(homeFollowingNewsInbox.rank.isNotNull)
|
||||||
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
|
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
|
||||||
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
|
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun adultAudioNewsTargetCondition(
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
newsAudioContent: QAudioContent
|
||||||
|
): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else newsAudioContent.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun adultCommunityNewsTargetCondition(
|
||||||
|
canViewAdultContent: Boolean,
|
||||||
|
newsCommunity: QCreatorCommunity
|
||||||
|
): BooleanExpression? {
|
||||||
|
return if (canViewAdultContent) null else newsCommunity.isAdult.isFalse
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeFollowingCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
|
||||||
|
return JPAExpressions
|
||||||
|
.selectOne()
|
||||||
|
.from(creatorFollowing)
|
||||||
|
.where(
|
||||||
|
creatorFollowing.member.id.eq(memberId),
|
||||||
|
creatorFollowing.creator.id.eq(creatorIdPath),
|
||||||
|
creatorFollowing.isActive.isTrue
|
||||||
|
)
|
||||||
|
.exists()
|
||||||
|
}
|
||||||
|
|
||||||
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
|
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
|
||||||
val blockMember = QBlockMember("homeFollowingBlockMember")
|
val blockMember = QBlockMember("homeFollowingBlockMember")
|
||||||
return JPAExpressions
|
return JPAExpressions
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
|||||||
import kr.co.vividnext.sodalive.content.AudioContent
|
import kr.co.vividnext.sodalive.content.AudioContent
|
||||||
import kr.co.vividnext.sodalive.content.theme.AudioContentTheme
|
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.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.LiveRoom
|
import kr.co.vividnext.sodalive.live.room.LiveRoom
|
||||||
import kr.co.vividnext.sodalive.member.Member
|
import kr.co.vividnext.sodalive.member.Member
|
||||||
import kr.co.vividnext.sodalive.member.MemberRole
|
import kr.co.vividnext.sodalive.member.MemberRole
|
||||||
@@ -219,8 +221,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다")
|
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
|
||||||
fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() {
|
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
|
||||||
val viewer = saveMember("news-viewer", MemberRole.USER)
|
val viewer = saveMember("news-viewer", MemberRole.USER)
|
||||||
val creator = saveMember("news-creator", MemberRole.CREATOR)
|
val creator = saveMember("news-creator", MemberRole.CREATOR)
|
||||||
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
|
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
|
||||||
@@ -228,7 +230,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
saveFollowing(viewer, creator)
|
saveFollowing(viewer, creator)
|
||||||
saveFollowing(viewer, blockedCreator)
|
saveFollowing(viewer, blockedCreator)
|
||||||
saveFollowing(viewer, nonCreator)
|
saveFollowing(viewer, nonCreator)
|
||||||
val oldVisible = saveNews(viewer.id!!, creator.id!!, "old", LocalDateTime.of(2026, 6, 25, 8, 0), rank = null)
|
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)
|
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)
|
saveNews(viewer.id!!, creator.id!!, "future", LocalDateTime.of(2026, 6, 25, 10, 0), rank = 1)
|
||||||
saveNews(viewer.id!!, creator.id!!, "adult", LocalDateTime.of(2026, 6, 25, 9, 30), isAdult = true)
|
saveNews(viewer.id!!, creator.id!!, "adult", LocalDateTime.of(2026, 6, 25, 9, 30), isAdult = true)
|
||||||
@@ -244,8 +246,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
limit = 30
|
limit = 30
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId })
|
assertEquals(listOf(latestVisible.id!!.toString()), news.map { it.newsId })
|
||||||
assertEquals(listOf(3, null), news.map { it.rank })
|
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -254,8 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
|
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
|
||||||
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
|
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
|
||||||
saveFollowing(viewer, creator)
|
saveFollowing(viewer, creator)
|
||||||
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", 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))
|
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31), rank = 2)
|
||||||
flushAndClear()
|
flushAndClear()
|
||||||
|
|
||||||
val news = repository.findRecentNews(
|
val news = repository.findRecentNews(
|
||||||
@@ -268,6 +270,198 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId })
|
assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 소식은 타입별 원천 데이터를 nested payload로 채운다")
|
||||||
|
fun shouldPopulateRecentNewsNestedPayloadsFromSourceTargets() {
|
||||||
|
val viewer = saveMember("news-payload-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("news-payload-creator", MemberRole.CREATOR, profileImage = "payload-profile.png")
|
||||||
|
val otherMember = saveMember("news-payload-other", MemberRole.USER)
|
||||||
|
val theme = saveTheme("news-payload-theme")
|
||||||
|
saveFollowing(viewer, creator)
|
||||||
|
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 7, 0)).apply {
|
||||||
|
title = "source audio title"
|
||||||
|
coverImage = "audio/source-cover.png"
|
||||||
|
}
|
||||||
|
val post = saveCommunityPost(creator, "source community content", isActive = true).apply {
|
||||||
|
imagePath = "community/source-image.png"
|
||||||
|
}
|
||||||
|
saveCommunityLike(viewer, post, isActive = true)
|
||||||
|
saveCommunityLike(otherMember, post, isActive = true)
|
||||||
|
saveCommunityLike(otherMember, post, isActive = false)
|
||||||
|
saveCommunityComment(viewer, post, isActive = true)
|
||||||
|
val inactiveComment = saveCommunityComment(otherMember, post, isActive = false)
|
||||||
|
val childComment = saveCommunityComment(otherMember, post, isActive = true)
|
||||||
|
childComment.parent = inactiveComment
|
||||||
|
post.createdAt = LocalDateTime.of(2026, 6, 25, 6, 30)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "payload-audio",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||||
|
newsType = FollowingNewsType.AUDIO_CONTENT,
|
||||||
|
targetId = audio.id!!
|
||||||
|
)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "payload-post",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
|
||||||
|
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||||
|
targetId = post.id!!
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val news = repository.findRecentNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
canViewAdultContent = true,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||||
|
limit = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
val communityNews = news.first { it.type == FollowingNewsType.COMMUNITY_POST }.communityPost!!
|
||||||
|
assertEquals(post.id!!, communityNews.postId)
|
||||||
|
assertEquals("https://cdn.test/payload-profile.png", communityNews.creatorProfileImage)
|
||||||
|
assertEquals("news-payload-creator", communityNews.creatorNickname)
|
||||||
|
assertEquals("https://cdn.test/community/source-image.png", communityNews.imageUrl)
|
||||||
|
assertEquals("source community content", communityNews.content)
|
||||||
|
assertEquals("2026-06-25T06:30:00Z", communityNews.createdAt)
|
||||||
|
assertEquals(2, communityNews.likeCount)
|
||||||
|
assertEquals(1, communityNews.commentCount)
|
||||||
|
|
||||||
|
val audioNews = news.first { it.type == FollowingNewsType.AUDIO_CONTENT }.audioContent!!
|
||||||
|
assertEquals(audio.id!!, audioNews.contentId)
|
||||||
|
assertEquals("https://cdn.test/audio/source-cover.png", audioNews.contentImageUrl)
|
||||||
|
assertEquals("source audio title", audioNews.title)
|
||||||
|
assertEquals("https://cdn.test/payload-profile.png", audioNews.creatorProfileImageUrl)
|
||||||
|
assertEquals("news-payload-creator", audioNews.creatorNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 소식은 현재 활성 팔로우가 아닌 크리에이터 inbox를 제외한다")
|
||||||
|
fun shouldExcludeRecentNewsWhenFollowingIsInactive() {
|
||||||
|
val viewer = saveMember("news-inactive-following-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("news-inactive-following-creator", MemberRole.CREATOR)
|
||||||
|
saveFollowing(viewer, creator, isActive = false)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "inactive-following-news",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||||
|
rank = 1
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val news = repository.findRecentNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
canViewAdultContent = true,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||||
|
limit = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(news.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 소식은 유료 커뮤니티 게시글을 제외한다")
|
||||||
|
fun shouldExcludePaidCommunityPostRecentNews() {
|
||||||
|
val viewer = saveMember("news-paid-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("news-paid-creator", MemberRole.CREATOR)
|
||||||
|
saveFollowing(viewer, creator)
|
||||||
|
val freePost = saveCommunityPost(creator, "free-post", isActive = true)
|
||||||
|
val negativeFreePost = saveCommunityPost(creator, "negative-free-post", isActive = true).apply { price = -1 }
|
||||||
|
val paidPost = saveCommunityPost(creator, "paid-post", isActive = true).apply { price = 10 }
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "free-post",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||||
|
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||||
|
targetId = freePost.id!!
|
||||||
|
)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "negative-free-post",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
|
||||||
|
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||||
|
targetId = negativeFreePost.id!!
|
||||||
|
)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "paid-post",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 2),
|
||||||
|
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||||
|
targetId = paidPost.id!!
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val news = repository.findRecentNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
canViewAdultContent = true,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||||
|
limit = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(negativeFreePost.id!!, freePost.id!!), news.map { it.communityPost?.postId })
|
||||||
|
assertTrue(news.all { it.audioContent == null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("최근 소식은 비성인 사용자의 오디오와 커뮤니티 원천 target 현재 성인 상태를 반영해 제외한다")
|
||||||
|
fun shouldExcludeAdultSourceTargetsForNonAdultViewer() {
|
||||||
|
val viewer = saveMember("news-adult-source-viewer", MemberRole.USER)
|
||||||
|
val creator = saveMember("news-adult-source-creator", MemberRole.CREATOR)
|
||||||
|
val theme = saveTheme("news-adult-source-theme")
|
||||||
|
saveFollowing(viewer, creator)
|
||||||
|
val adultAudio = saveAudioContent(
|
||||||
|
creator,
|
||||||
|
theme,
|
||||||
|
LocalDateTime.of(2026, 6, 25, 8, 0),
|
||||||
|
isActive = true,
|
||||||
|
isAdult = true
|
||||||
|
)
|
||||||
|
val adultPost = saveCommunityPost(creator, "adult-post", isActive = true, isAdult = true)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "adult-audio",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
|
||||||
|
isAdult = false,
|
||||||
|
newsType = FollowingNewsType.AUDIO_CONTENT,
|
||||||
|
targetId = adultAudio.id!!
|
||||||
|
)
|
||||||
|
saveNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
creatorId = creator.id!!,
|
||||||
|
sourceKey = "adult-post",
|
||||||
|
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
|
||||||
|
isAdult = false,
|
||||||
|
newsType = FollowingNewsType.COMMUNITY_POST,
|
||||||
|
targetId = adultPost.id!!
|
||||||
|
)
|
||||||
|
flushAndClear()
|
||||||
|
|
||||||
|
val hiddenNews = repository.findRecentNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
canViewAdultContent = false,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||||
|
limit = 30
|
||||||
|
)
|
||||||
|
val visibleNews = repository.findRecentNews(
|
||||||
|
memberId = viewer.id!!,
|
||||||
|
canViewAdultContent = true,
|
||||||
|
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
|
||||||
|
limit = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(emptyList<Long>(), hiddenNews.map { it.communityPost?.postId ?: it.audioContent?.contentId })
|
||||||
|
assertEquals(
|
||||||
|
listOf(adultPost.id!!, adultAudio.id!!),
|
||||||
|
visibleNews.map { it.communityPost?.postId ?: it.audioContent?.contentId }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
|
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
|
||||||
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
|
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
|
||||||
@@ -322,7 +516,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(activePost.id!!, activeAudio.id!!),
|
listOf(activePost.id!!, activeAudio.id!!),
|
||||||
news.map { it.targetId }
|
news.map { it.communityPost?.postId ?: it.audioContent?.contentId }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +581,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
creator: Member,
|
creator: Member,
|
||||||
theme: AudioContentTheme,
|
theme: AudioContentTheme,
|
||||||
releaseDate: LocalDateTime,
|
releaseDate: LocalDateTime,
|
||||||
isActive: Boolean = true
|
isActive: Boolean = true,
|
||||||
|
isAdult: Boolean = false
|
||||||
): AudioContent {
|
): AudioContent {
|
||||||
val content = AudioContent(
|
val content = AudioContent(
|
||||||
title = "audio-$releaseDate",
|
title = "audio-$releaseDate",
|
||||||
@@ -399,17 +594,23 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
this.theme = theme
|
this.theme = theme
|
||||||
duration = "00:10:00"
|
duration = "00:10:00"
|
||||||
this.isActive = isActive
|
this.isActive = isActive
|
||||||
|
this.isAdult = isAdult
|
||||||
}
|
}
|
||||||
entityManager.persist(content)
|
entityManager.persist(content)
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveCommunityPost(creator: Member, content: String, isActive: Boolean): CreatorCommunity {
|
private fun saveCommunityPost(
|
||||||
|
creator: Member,
|
||||||
|
content: String,
|
||||||
|
isActive: Boolean,
|
||||||
|
isAdult: Boolean = false
|
||||||
|
): CreatorCommunity {
|
||||||
val post = CreatorCommunity(
|
val post = CreatorCommunity(
|
||||||
content = content,
|
content = content,
|
||||||
price = 0,
|
price = 0,
|
||||||
isCommentAvailable = true,
|
isCommentAvailable = true,
|
||||||
isAdult = false,
|
isAdult = isAdult,
|
||||||
isActive = isActive
|
isActive = isActive
|
||||||
).apply {
|
).apply {
|
||||||
member = creator
|
member = creator
|
||||||
@@ -418,6 +619,24 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
|||||||
return post
|
return post
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityLike(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityLike {
|
||||||
|
val like = CreatorCommunityLike(isActive = isActive).apply {
|
||||||
|
this.member = member
|
||||||
|
creatorCommunity = post
|
||||||
|
}
|
||||||
|
entityManager.persist(like)
|
||||||
|
return like
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCommunityComment(member: Member, post: CreatorCommunity, isActive: Boolean): CreatorCommunityComment {
|
||||||
|
val comment = CreatorCommunityComment(comment = "comment", isActive = isActive).apply {
|
||||||
|
this.member = member
|
||||||
|
creatorCommunity = post
|
||||||
|
}
|
||||||
|
entityManager.persist(comment)
|
||||||
|
return comment
|
||||||
|
}
|
||||||
|
|
||||||
private fun saveNews(
|
private fun saveNews(
|
||||||
memberId: Long,
|
memberId: Long,
|
||||||
creatorId: Long,
|
creatorId: Long,
|
||||||
|
|||||||
Reference in New Issue
Block a user