feat(home): 팔로잉 탭 최근 소식 정책을 보강한다

This commit is contained in:
2026-07-30 19:51:30 +09:00
parent e6f56f24fe
commit f1c2e6c50b
21 changed files with 2145 additions and 19 deletions
@@ -1,12 +1,14 @@
package kr.co.vividnext.sodalive.member
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.member.login.LoginRequest
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxJpaRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
@@ -23,6 +25,7 @@ import javax.persistence.EntityManager
class MemberServiceTest @Autowired constructor(
private val service: MemberService,
private val memberRepository: MemberRepository,
private val creatorFollowingRepository: CreatorFollowingRepository,
private val homeFollowingNewsInboxJpaRepository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
) {
@@ -94,4 +97,88 @@ class MemberServiceTest @Autowired constructor(
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
@Test
@DisplayName("통합 팔로우 API에서 isActive=false이고 기존 관계가 없으면 active 팔로우를 생성하지 않는다")
fun shouldNotCreateActiveFollowingWhenCreatorFollowIsInactiveWithoutExistingRelation() {
val member = memberRepository.save(
Member(
email = "inactive-follower@test.com",
password = "password",
nickname = "inactive-follower"
)
)
val creator = memberRepository.save(
Member(
email = "inactive-creator@test.com",
password = "password",
nickname = "inactive-creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertNull(creatorFollowingRepository.findByCreatorIdAndMemberId(creatorId = creator.id!!, memberId = member.id!!))
}
@Test
@DisplayName("통합 팔로우 API에서 isActive=false이면 기존 inbox를 비활성화하고 재팔로우해도 복구하지 않는다")
fun shouldDeactivateFollowingNewsInboxWhenCreatorFollowIsInactiveAndKeepInactiveAfterRefollow() {
val member = memberRepository.save(
Member(
email = "integrated-follower@test.com",
password = "password",
nickname = "integrated-follower"
)
)
val creator = memberRepository.save(
Member(
email = "integrated-creator@test.com",
password = "password",
nickname = "integrated-creator",
role = MemberRole.CREATOR
)
)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
val inbox = homeFollowingNewsInboxJpaRepository.save(
HomeFollowingNewsInbox(
memberId = member.id!!,
creatorId = creator.id!!,
newsType = FollowingNewsType.COMMUNITY_POST,
sourceKey = "COMMUNITY_POST:2",
targetId = 2L,
occurredAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 2, 0),
creatorNickname = "integrated-creator",
creatorProfileImagePath = null,
title = "title",
body = "body",
thumbnailImagePath = null,
rank = null,
isAdult = false
)
)
entityManager.flush()
entityManager.clear()
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = false, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
val inactiveFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creator.id!!,
memberId = member.id!!
)
assertEquals(false, inactiveFollowing?.isActive)
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
service.creatorFollow(creatorId = creator.id!!, isNotify = true, isActive = true, memberId = member.id!!)
entityManager.flush()
entityManager.clear()
assertEquals(false, homeFollowingNewsInboxJpaRepository.findById(inbox.id!!).get().isActive)
}
}
@@ -14,6 +14,8 @@ import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer
import kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInbox
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessage
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatMessageType
import kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatParticipant
@@ -167,7 +169,9 @@ class HomeFollowingEndToEndTest @Autowired constructor(
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = paidPost.id!!
)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", now.minusHours(1), rank = 7)
val rankedVisibleFromAtUtc = now.minusHours(1)
saveCreatorRankingSnapshot(creator, rankNo = 7, visibleFromAtUtc = rankedVisibleFromAtUtc)
val rankedNews = saveNews(viewer.id!!, creator.id!!, "ranked-news", rankedVisibleFromAtUtc, rank = 7)
val chatRoom = saveDmChatRoom(viewer, creator, now.minusMinutes(10))
entityManager.flush()
entityManager.clear()
@@ -275,6 +279,28 @@ class HomeFollowingEndToEndTest @Autowired constructor(
return news
}
private fun saveCreatorRankingSnapshot(
creator: Member,
rankNo: Int,
visibleFromAtUtc: LocalDateTime
): CreatorRankingSnapshot {
val snapshot = CreatorRankingSnapshot(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creator.id!!,
nickname = creator.nickname,
profileImageUrl = creator.profileImage,
rankNo = rankNo,
finalScore = 100.0 - rankNo,
scorePolicyVersion = "v1",
scoreDetailJson = "{}"
)
entityManager.persist(snapshot)
return snapshot
}
private fun saveCommunityPost(creator: Member, content: String, price: Int, imagePath: String): CreatorCommunity {
val post = CreatorCommunity(
content = content,
@@ -7,13 +7,20 @@ 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.GenderRestriction
import kr.co.vividnext.sodalive.live.room.LiveRoom
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.block.BlockMember
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshot
import kr.co.vividnext.sodalive.v2.ranking.adapter.out.persistence.CreatorRankingSnapshotJob
import kr.co.vividnext.sodalive.v2.ranking.domain.CreatorRankingType
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobStatus
import kr.co.vividnext.sodalive.v2.ranking.port.out.CreatorRankingSnapshotJobTrigger
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
@@ -98,12 +105,50 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
flushAndClear()
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = false, limit = 10)
val lives = repository.findOnAirLives(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
limit = 10
)
assertEquals(listOf(latest.id!!, older.id!!), lives.map { it.liveId })
assertEquals("https://cdn.test/live-profile.png", lives.first().creatorProfileImageUrl)
}
@Test
@DisplayName("On Air는 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
fun shouldFindFollowingOnAirLivesWithViewerGenderAndCreatorJoinPolicy() {
val viewer = saveMember("live-policy-viewer", MemberRole.CREATOR)
val creator = saveMember("live-policy-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 10, 0),
channelName = "male-only",
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 11, 0),
channelName = "creator-hidden",
isAvailableJoinCreator = false
)
val visible = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "visible")
flushAndClear()
val lives = repository.findOnAirLives(
memberId = viewer.id!!,
canViewAdultContent = true,
effectiveViewerGender = Gender.FEMALE,
isViewerCreator = true,
limit = 10
)
assertEquals(listOf(visible.id!!), lives.map { it.liveId })
}
@Test
@DisplayName("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
fun shouldFindMonthlySchedulesInKstWindow() {
@@ -127,6 +172,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 12, 0),
limit = 3
)
@@ -160,6 +207,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 3
)
@@ -181,6 +230,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 3
)
@@ -205,6 +256,8 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
effectiveViewerGender = Gender.NONE,
isViewerCreator = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
@@ -220,6 +273,41 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
)
}
@Test
@DisplayName("라이브 스케줄은 성별 제한과 크리에이터 입장 제한 정책을 반영한다")
fun shouldFindLiveMonthlySchedulesWithViewerGenderAndCreatorJoinPolicy() {
val viewer = saveMember("schedule-policy-viewer", MemberRole.CREATOR)
val creator = saveMember("schedule-policy-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-policy-theme")
saveFollowing(viewer, creator)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 1, 0),
channelName = null,
genderRestriction = GenderRestriction.MALE_ONLY
)
saveLiveRoom(
creator,
LocalDateTime.of(2026, 6, 25, 2, 0),
channelName = null,
isAvailableJoinCreator = false
)
val visibleLive = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 3, 0), channelName = null)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 4, 0))
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = true,
effectiveViewerGender = Gender.FEMALE,
isViewerCreator = true,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
assertEquals(listOf("LIVE:${visibleLive.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
}
@Test
@DisplayName("최근 소식은 노출 가능한 랭킹 inbox 중 rank가 있는 row만 최신순으로 조회한다")
fun shouldFindRecentNewsWithRankedCreatorRankingPayloadOnly() {
@@ -230,6 +318,7 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
saveCreatorRankingSnapshot(creator, rankNo = 3, visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0))
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)
@@ -250,12 +339,236 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
assertEquals(listOf(3), news.map { it.creatorRanking?.rank })
}
@Test
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 공개 배치만 조회한다")
fun shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews() {
val viewer = saveMember("news-ranking-viewer", MemberRole.USER)
val firstCreator = saveMember("news-ranking-first", MemberRole.CREATOR)
val secondCreator = saveMember("news-ranking-second", MemberRole.CREATOR)
saveFollowing(viewer, firstCreator)
saveFollowing(viewer, secondCreator)
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshot(secondCreator, rankNo = 2, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshot(firstCreator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 15, 0, 0))
val oldFirstNews = saveNews(
viewer.id!!,
firstCreator.id!!,
"old-ranking-first",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
val oldSecondNews = saveNews(
viewer.id!!,
secondCreator.id!!,
"old-ranking-second",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 2
)
val latestFirstNews = saveNews(
viewer.id!!,
firstCreator.id!!,
"latest-ranking-first",
LocalDateTime.of(2026, 6, 15, 0, 0),
rank = 1
)
flushAndClear()
val beforeLatestVisible = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 14, 23, 59),
limit = 30
)
val afterLatestVisible = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
limit = 30
)
assertEquals(listOf(oldSecondNews.id!!.toString(), oldFirstNews.id!!.toString()), beforeLatestVisible.map { it.newsId })
assertEquals(listOf(latestFirstNews.id!!.toString()), afterLatestVisible.map { it.newsId })
}
@Test
@DisplayName("최근 소식의 크리에이터 랭킹은 최신 완료 배치가 비어 있어도 과거 배치로 보충하지 않는다")
fun shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty() {
val viewer = saveMember("news-empty-ranking-viewer", MemberRole.USER)
val creator = saveMember("news-empty-ranking-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 8, 0, 0))
saveCreatorRankingSnapshotJob(LocalDateTime.of(2026, 6, 15, 0, 0))
saveNews(
viewer.id!!,
creator.id!!,
"old-ranking-empty-latest",
LocalDateTime.of(2026, 6, 8, 0, 0),
rank = 1
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 15, 0, 0),
limit = 30
)
assertTrue(news.none { it.type == FollowingNewsType.CREATOR_RANKING })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 콘텐츠별 최신 row만 limit 전에 남긴다")
fun shouldFindLatestContentRankingNewsPerContentBeforeLimit() {
val viewer = saveMember("news-content-ranking-viewer", MemberRole.USER)
val creator = saveMember("news-content-ranking-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-ranking-theme")
saveFollowing(viewer, creator)
val firstContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0)).apply {
title = "first ranked content"
coverImage = "content-ranking/first.png"
}
val secondContent = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10)).apply {
title = "second ranked content"
coverImage = "content-ranking/second.png"
}
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-older",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 3,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-same-time-older-id",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
rank = 2,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
val latestFirst = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-first-same-time-latest-id",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
rank = 1,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = firstContent.id!!
)
val latestSecond = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-second",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 8, 59),
rank = 4,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = secondContent.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 2
)
assertEquals(listOf(latestFirst.id!!.toString(), latestSecond.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(firstContent.id!!, secondContent.id!!), news.map { it.contentRanking?.contentId })
assertEquals(listOf(1, 4), news.map { it.contentRanking?.rank })
assertTrue(news.all { it.creatorRanking == null && it.audioContent == null && it.communityPost == null })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 rank가 없는 최신 row가 이전 노출 가능 row를 가리지 않는다")
fun shouldKeepOlderContentRankingNewsWhenNewerRowHasNoRank() {
val viewer = saveMember("news-content-null-rank-viewer", MemberRole.USER)
val creator = saveMember("news-content-null-rank-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-null-rank-theme")
saveFollowing(viewer, creator)
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0))
val visible = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-visible-before-null-rank",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 2,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-null-rank-newer",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
rank = null,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
limit = 30
)
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식의 콘텐츠 랭킹은 성인 최신 row가 비성인 회원의 이전 노출 가능 row를 가리지 않는다")
fun shouldKeepOlderContentRankingNewsWhenNewerRowIsAdultForNonAdultViewer() {
val viewer = saveMember("news-content-adult-row-viewer", MemberRole.USER)
val creator = saveMember("news-content-adult-row-creator", MemberRole.CREATOR)
val theme = saveTheme("news-content-adult-row-theme")
saveFollowing(viewer, creator)
val content = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isAdult = false)
val visible = saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-visible-before-adult-row",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
rank = 2,
isAdult = false,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "content-ranking-adult-newer",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
rank = 1,
isAdult = true,
newsType = FollowingNewsType.CONTENT_RANKING,
targetId = content.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 11, 0),
limit = 30
)
assertEquals(listOf(visible.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 UTC now 이후 visibleFromAtUtc row를 조기 노출하지 않는다")
fun shouldNotExposeNewsVisibleAfterUtcNow() {
val viewer = saveMember("news-utc-viewer", MemberRole.USER)
val creator = saveMember("news-utc-creator", MemberRole.CREATOR)
saveFollowing(viewer, creator)
saveCreatorRankingSnapshot(creator, rankNo = 1, visibleFromAtUtc = 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), rank = 2)
flushAndClear()
@@ -555,14 +868,18 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
isAdult: Boolean = false,
genderRestriction: GenderRestriction = GenderRestriction.ALL,
isAvailableJoinCreator: Boolean = true
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult
isAdult = isAdult,
isAvailableJoinCreator = isAvailableJoinCreator,
genderRestriction = genderRestriction
).apply {
member = creator
this.channelName = channelName
@@ -667,6 +984,43 @@ class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
return news
}
private fun saveCreatorRankingSnapshot(
creator: Member,
rankNo: Int,
visibleFromAtUtc: LocalDateTime,
rankingType: CreatorRankingType = CreatorRankingType.WEEKLY
): CreatorRankingSnapshot {
val snapshot = CreatorRankingSnapshot(
rankingType = rankingType,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
creatorId = creator.id!!,
nickname = creator.nickname,
profileImageUrl = creator.profileImage,
rankNo = rankNo,
finalScore = 100.0 - rankNo,
scorePolicyVersion = "v1",
scoreDetailJson = "{}"
)
entityManager.persist(snapshot)
return snapshot
}
private fun saveCreatorRankingSnapshotJob(visibleFromAtUtc: LocalDateTime): CreatorRankingSnapshotJob {
val job = CreatorRankingSnapshotJob(
rankingType = CreatorRankingType.WEEKLY,
aggregationStartAtUtc = visibleFromAtUtc.minusWeeks(1),
aggregationEndAtUtc = visibleFromAtUtc,
visibleFromAtUtc = visibleFromAtUtc,
trigger = CreatorRankingSnapshotJobTrigger.SCHEDULED,
status = CreatorRankingSnapshotJobStatus.DONE,
processedAt = visibleFromAtUtc.minusHours(1)
)
entityManager.persist(job)
return job
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
@@ -4,6 +4,7 @@ import kr.co.vividnext.sodalive.configs.QueryDslConfig
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.following.CreatorFollowing
import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository
import kr.co.vividnext.sodalive.v2.home.following.domain.FollowingNewsType
import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingNewsInboxRecord
import org.junit.jupiter.api.Assertions.assertDoesNotThrow
@@ -17,7 +18,15 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import org.springframework.test.annotation.DirtiesContext
import org.springframework.test.context.transaction.TestTransaction
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDateTime
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import javax.persistence.EntityManager
@DataJpaTest(
@@ -29,9 +38,13 @@ import javax.persistence.EntityManager
@Import(QueryDslConfig::class)
class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
private val repository: HomeFollowingNewsInboxJpaRepository,
private val entityManager: EntityManager
private val creatorFollowingRepository: CreatorFollowingRepository,
private val entityManager: EntityManager,
transactionManager: PlatformTransactionManager
) {
private val adapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager)
private val transactionalAdapter = HomeFollowingNewsInboxPersistenceAdapter(repository, entityManager, transactionManager)
private val transactionTemplate = TransactionTemplate(transactionManager)
@Test
@DisplayName("insertIgnoreAll은 memberId newsType sourceKey 중복을 예외 없이 무시하고 신규 row만 저장한다")
@@ -115,6 +128,75 @@ class HomeFollowingNewsInboxPersistenceAdapterTest @Autowired constructor(
assertEquals(listOf(activeFollowerWithoutInbox.id!!), followerIds)
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
@DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD)
@DisplayName("publish가 follower 조회 후 insert 전 멈춘 사이 언팔로우가 완료돼도 재팔로우 후 과거 소식이 활성화되지 않는다")
fun shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed() {
val (memberId, creatorId) = transactionTemplate.execute {
val creator = saveMember("concurrent-creator", MemberRole.CREATOR)
val follower = saveMember("concurrent-follower", MemberRole.USER)
saveFollowing(follower, creator, isActive = true)
entityManager.flush()
follower.id!! to creator.id!!
}!!
val followerRead = CountDownLatch(1)
val allowPublishInsert = CountDownLatch(1)
val executor = Executors.newFixedThreadPool(2)
try {
val publish = executor.submit<Int> {
transactionTemplate.execute {
val followerIds = transactionalAdapter.findActiveFollowerIds(creatorId)
followerRead.countDown()
assertTrue(allowPublishInsert.await(5, TimeUnit.SECONDS))
transactionalAdapter.insertIgnoreAll(
followerIds.map { followerId ->
record(memberId = followerId, creatorId = creatorId, sourceKey = "COMMUNITY_POST:delayed")
}
)
}!!
}
assertTrue(followerRead.await(5, TimeUnit.SECONDS))
val unfollow = executor.submit {
transactionTemplate.executeWithoutResult {
val following = creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creatorId,
memberId = memberId
)!!
following.isActive = false
transactionalAdapter.deactivateByMemberIdAndCreatorId(memberId = memberId, creatorId = creatorId)
}
}
Thread.sleep(200)
val unfollowCompletedBeforeInsert = try {
unfollow.get(1, TimeUnit.SECONDS)
true
} catch (_: TimeoutException) {
false
}
allowPublishInsert.countDown()
assertEquals(1, publish.get(5, TimeUnit.SECONDS))
if (!unfollowCompletedBeforeInsert) {
unfollow.get(5, TimeUnit.SECONDS)
}
transactionTemplate.executeWithoutResult {
creatorFollowingRepository.findByCreatorIdAndMemberId(
creatorId = creatorId,
memberId = memberId
)!!.isActive = true
}
val activeRows = transactionTemplate.execute {
repository.findAll().filter { it.memberId == memberId && it.creatorId == creatorId && it.isActive }
}!!
assertEquals(emptyList<HomeFollowingNewsInbox>(), activeRows)
} finally {
executor.shutdownNow()
}
}
private fun saveMember(seed: String, role: MemberRole): Member {
val member = Member(
email = "$seed@test.com",
@@ -1,6 +1,9 @@
package kr.co.vividnext.sodalive.v2.home.following.application
import kr.co.vividnext.sodalive.member.Gender
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.auth.Auth
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator
import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive
@@ -58,6 +61,24 @@ class HomeFollowingQueryServiceTest {
assertEquals(11L, queryPort.memberId)
}
@Test
@DisplayName("본인인증 성별 우선 effective gender와 크리에이터 여부는 라이브 조회 port에 전달된다")
fun shouldPassEffectiveGenderAndCreatorRoleToLiveQueryPort() {
val member = member(13L).apply {
role = MemberRole.CREATOR
gender = Gender.MALE
auth = Auth(name = "auth", birth = "20000101", uniqueCi = "ci", di = "di", gender = 0)
}
Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true)
service.findHomeFollowing(member)
assertEquals(Gender.FEMALE, queryPort.onAirEffectiveViewerGender)
assertEquals(Gender.FEMALE, queryPort.monthlySchedulesEffectiveViewerGender)
assertTrue(queryPort.onAirIsViewerCreator!!)
assertTrue(queryPort.monthlySchedulesIsViewerCreator!!)
}
@Test
@DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다")
fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() {
@@ -91,9 +112,13 @@ class HomeFollowingQueryServiceTest {
var followingCreatorsLimit: Int? = null
var onAirLivesLimit: Int? = null
var onAirCanViewAdultContent: Boolean? = null
var onAirEffectiveViewerGender: Gender? = null
var onAirIsViewerCreator: Boolean? = null
var monthlySchedulesLimit: Int? = null
var monthlySchedulesNow: LocalDateTime? = null
var monthlySchedulesCanViewAdultContent: Boolean? = null
var monthlySchedulesEffectiveViewerGender: Gender? = null
var monthlySchedulesIsViewerCreator: Boolean? = null
var recentNewsLimit: Int? = null
var recentNewsNow: LocalDateTime? = null
var recentNewsCanViewAdultContent: Boolean? = null
@@ -104,8 +129,16 @@ class HomeFollowingQueryServiceTest {
return listOf(HomeFollowingCreator(1L, "creator", "profile"))
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
override fun findOnAirLives(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
limit: Int
): List<HomeFollowingLive> {
onAirCanViewAdultContent = canViewAdultContent
onAirEffectiveViewerGender = effectiveViewerGender
onAirIsViewerCreator = isViewerCreator
onAirLivesLimit = limit
return emptyList()
}
@@ -113,10 +146,14 @@ class HomeFollowingQueryServiceTest {
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
effectiveViewerGender: Gender,
isViewerCreator: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
monthlySchedulesCanViewAdultContent = canViewAdultContent
monthlySchedulesEffectiveViewerGender = effectiveViewerGender
monthlySchedulesIsViewerCreator = isViewerCreator
monthlySchedulesNow = now
monthlySchedulesLimit = limit
return emptyList()