feat(home): 최근 소식 원천 데이터를 보강한다

This commit is contained in:
2026-06-30 21:35:13 +09:00
parent 17b1305a95
commit 5d5547361c
2 changed files with 386 additions and 36 deletions

View File

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

View File

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