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.audioContent
|
||||
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.live.room.QLiveRoom.liveRoom
|
||||
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.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.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.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
|
||||
@@ -116,50 +121,109 @@ class DefaultHomeFollowingQueryRepository(
|
||||
limit: Int
|
||||
): List<HomeFollowingNews> {
|
||||
val creator = QMember("newsCreator")
|
||||
return queryFactory
|
||||
val newsAudioContent = QAudioContent("recentNewsAudioContent")
|
||||
val newsCommunity = QCreatorCommunity("recentNewsCommunity")
|
||||
val rows = queryFactory
|
||||
.select(
|
||||
homeFollowingNewsInbox.id,
|
||||
homeFollowingNewsInbox.newsType,
|
||||
homeFollowingNewsInbox.creatorProfileImagePath,
|
||||
homeFollowingNewsInbox.creatorNickname,
|
||||
homeFollowingNewsInbox.title,
|
||||
homeFollowingNewsInbox.body,
|
||||
homeFollowingNewsInbox.thumbnailImagePath,
|
||||
creator.profileImage,
|
||||
creator.nickname,
|
||||
homeFollowingNewsInbox.targetId,
|
||||
homeFollowingNewsInbox.occurredAtUtc,
|
||||
homeFollowingNewsInbox.visibleFromAtUtc,
|
||||
homeFollowingNewsInbox.rank
|
||||
homeFollowingNewsInbox.rank,
|
||||
newsAudioContent.title,
|
||||
newsAudioContent.coverImage,
|
||||
newsCommunity.content,
|
||||
newsCommunity.imagePath,
|
||||
newsCommunity.createdAt
|
||||
)
|
||||
.from(homeFollowingNewsInbox)
|
||||
.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(
|
||||
homeFollowingNewsInbox.memberId.eq(memberId),
|
||||
homeFollowingNewsInbox.isActive.isTrue,
|
||||
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
|
||||
creator.isActive.isTrue,
|
||||
creator.role.eq(MemberRole.CREATOR),
|
||||
activeFollowingCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||
adultNewsCondition(canViewAdultContent),
|
||||
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
|
||||
activeNewsTargetCondition()
|
||||
activeNewsTargetCondition(canViewAdultContent)
|
||||
)
|
||||
.orderBy(homeFollowingNewsInbox.visibleFromAtUtc.desc(), homeFollowingNewsInbox.id.desc())
|
||||
.limit(limit.toLong())
|
||||
.fetch()
|
||||
.map { row ->
|
||||
HomeFollowingNews(
|
||||
newsId = row.get(homeFollowingNewsInbox.id)!!.toString(),
|
||||
type = row.get(homeFollowingNewsInbox.newsType)!!,
|
||||
creatorProfileImageUrl = profileImageUrl(row.get(homeFollowingNewsInbox.creatorProfileImagePath)),
|
||||
creatorNickname = row.get(homeFollowingNewsInbox.creatorNickname)!!,
|
||||
title = row.get(homeFollowingNewsInbox.title)!!,
|
||||
body = row.get(homeFollowingNewsInbox.body)!!,
|
||||
thumbnailImageUrl = row.get(homeFollowingNewsInbox.thumbnailImagePath).toCdnUrl(cloudFrontHost),
|
||||
targetId = row.get(homeFollowingNewsInbox.targetId)!!,
|
||||
occurredAtUtc = row.get(homeFollowingNewsInbox.occurredAtUtc)!!.toUtcIso(),
|
||||
visibleFromAtUtc = row.get(homeFollowingNewsInbox.visibleFromAtUtc)!!.toUtcIso(),
|
||||
rank = row.get(homeFollowingNewsInbox.rank)
|
||||
val communityPostIds = rows
|
||||
.filter { it.get(homeFollowingNewsInbox.newsType) == FollowingNewsType.COMMUNITY_POST }
|
||||
.map { it.get(homeFollowingNewsInbox.targetId)!! }
|
||||
val likeCounts = communityLikeCounts(communityPostIds)
|
||||
val commentCounts = communityCommentCounts(communityPostIds)
|
||||
return rows.map { row -> row.toHomeFollowingNews(creator, newsAudioContent, newsCommunity, likeCounts, commentCounts) }
|
||||
}
|
||||
|
||||
private fun Tuple.toHomeFollowingNews(
|
||||
newsCreator: QMember,
|
||||
newsAudioContent: QAudioContent,
|
||||
newsCommunity: QCreatorCommunity,
|
||||
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(
|
||||
@@ -284,7 +348,44 @@ class DefaultHomeFollowingQueryRepository(
|
||||
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 newsCommunity = QCreatorCommunity("newsCommunity")
|
||||
val activeAudioExists = JPAExpressions
|
||||
@@ -292,7 +393,8 @@ class DefaultHomeFollowingQueryRepository(
|
||||
.from(newsAudioContent)
|
||||
.where(
|
||||
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
|
||||
newsAudioContent.isActive.isTrue
|
||||
newsAudioContent.isActive.isTrue,
|
||||
adultAudioNewsTargetCondition(canViewAdultContent, newsAudioContent)
|
||||
)
|
||||
.exists()
|
||||
val activeCommunityExists = JPAExpressions
|
||||
@@ -300,15 +402,44 @@ class DefaultHomeFollowingQueryRepository(
|
||||
.from(newsCommunity)
|
||||
.where(
|
||||
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
|
||||
newsCommunity.isActive.isTrue
|
||||
newsCommunity.isActive.isTrue,
|
||||
newsCommunity.price.loe(0),
|
||||
adultCommunityNewsTargetCondition(canViewAdultContent, newsCommunity)
|
||||
)
|
||||
.exists()
|
||||
|
||||
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.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 {
|
||||
val blockMember = QBlockMember("homeFollowingBlockMember")
|
||||
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.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.LiveRoom
|
||||
import kr.co.vividnext.sodalive.member.Member
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
@@ -219,8 +221,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다")
|
||||
fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() {
|
||||
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
|
||||
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
|
||||
val viewer = saveMember("news-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-creator", MemberRole.CREATOR)
|
||||
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
|
||||
@@ -228,7 +230,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
saveFollowing(viewer, creator)
|
||||
saveFollowing(viewer, blockedCreator)
|
||||
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)
|
||||
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)
|
||||
@@ -244,8 +246,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
limit = 30
|
||||
)
|
||||
|
||||
assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId })
|
||||
assertEquals(listOf(3, null), news.map { it.rank })
|
||||
assertEquals(listOf(latestVisible.id!!.toString()), news.map { it.newsId })
|
||||
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -254,8 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
|
||||
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
|
||||
saveFollowing(viewer, creator)
|
||||
val visibleNow = saveNews(viewer.id!!, creator.id!!, "visible-now", LocalDateTime.of(2026, 6, 25, 14, 30))
|
||||
saveNews(viewer.id!!, creator.id!!, "future-utc", LocalDateTime.of(2026, 6, 25, 14, 31))
|
||||
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()
|
||||
|
||||
val news = repository.findRecentNews(
|
||||
@@ -268,6 +270,198 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
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
|
||||
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
|
||||
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
|
||||
@@ -322,7 +516,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
|
||||
assertEquals(
|
||||
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,
|
||||
theme: AudioContentTheme,
|
||||
releaseDate: LocalDateTime,
|
||||
isActive: Boolean = true
|
||||
isActive: Boolean = true,
|
||||
isAdult: Boolean = false
|
||||
): AudioContent {
|
||||
val content = AudioContent(
|
||||
title = "audio-$releaseDate",
|
||||
@@ -399,17 +594,23 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
this.theme = theme
|
||||
duration = "00:10:00"
|
||||
this.isActive = isActive
|
||||
this.isAdult = isAdult
|
||||
}
|
||||
entityManager.persist(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(
|
||||
content = content,
|
||||
price = 0,
|
||||
isCommentAvailable = true,
|
||||
isAdult = false,
|
||||
isAdult = isAdult,
|
||||
isActive = isActive
|
||||
).apply {
|
||||
member = creator
|
||||
@@ -418,6 +619,24 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
|
||||
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(
|
||||
memberId: Long,
|
||||
creatorId: Long,
|
||||
|
||||
Reference in New Issue
Block a user