From 5d5547361c54addbdc21d498b6dc6bfeb3beb08a Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 30 Jun 2026 21:35:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=B5=9C=EA=B7=BC=20=EC=86=8C?= =?UTF-8?q?=EC=8B=9D=20=EC=9B=90=EC=B2=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EA=B0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultHomeFollowingQueryRepository.kt | 181 +++++++++++-- ...DefaultHomeFollowingQueryRepositoryTest.kt | 241 +++++++++++++++++- 2 files changed, 386 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt index 43fc2c48..91220210 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt @@ -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 { 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, + commentCounts: Map + ): 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): Map { + 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): Map { + 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): 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): BooleanExpression { val blockMember = QBlockMember("homeFollowingBlockMember") return JPAExpressions diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt index abc981ff..ccb0b49f 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt @@ -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(), 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,