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