feat(home-following): 팔로잉 탭 조회 repository를 추가한다

This commit is contained in:
2026-06-26 02:47:06 +09:00
parent 91c648ca44
commit 45fc8bd21f
2 changed files with 798 additions and 0 deletions
@@ -0,0 +1,455 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.jpa.impl.JPAQueryFactory
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.live.room.LiveRoom
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 org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import
import java.time.LocalDateTime
import javax.persistence.EntityManager
@DataJpaTest(
properties = [
"spring.cache.type=none",
"spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;NON_KEYWORDS=VALUE"
]
)
@Import(QueryDslConfig::class)
class DefaultHomeFollowingQueryRepositoryTest @Autowired constructor(
private val entityManager: EntityManager,
queryFactory: JPAQueryFactory
) {
private val repository = DefaultHomeFollowingQueryRepository(queryFactory, "https://cdn.test")
@Test
@DisplayName("팔로잉 크리에이터는 활성 팔로우와 활성 크리에이터만 최신 팔로우순으로 조회한다")
fun shouldFindActiveFollowingCreatorsByLatestFollowOrder() {
val viewer = saveMember("following-viewer", MemberRole.USER)
val activeCreator = saveMember("following-active", MemberRole.CREATOR, profileImage = "active.png")
val inactiveCreator = saveMember("following-inactive", MemberRole.CREATOR, isActive = false)
val olderCreator = saveMember("following-older", MemberRole.CREATOR)
val nonCreator = saveMember("following-non-creator", MemberRole.USER)
val olderFollow = saveFollowing(viewer, olderCreator, isActive = true)
val activeFollow = saveFollowing(viewer, activeCreator, isActive = true)
saveFollowing(viewer, inactiveCreator, isActive = true)
saveFollowing(viewer, nonCreator, isActive = true)
saveFollowing(viewer, saveMember("following-disabled", MemberRole.CREATOR), isActive = false)
olderFollow.createdAt = LocalDateTime.of(2026, 6, 24, 0, 0)
activeFollow.createdAt = LocalDateTime.of(2026, 6, 25, 0, 0)
flushAndClear()
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
assertEquals(listOf(activeCreator.id!!, olderCreator.id!!), creators.map { it.creatorId })
assertEquals("https://cdn.test/active.png", creators.first().creatorProfileImageUrl)
}
@Test
@DisplayName("팔로잉 크리에이터는 회원과 크리에이터의 양방향 차단 관계를 제외한다")
fun shouldExcludeBlockedFollowingCreators() {
val viewer = saveMember("blocked-viewer", MemberRole.USER)
val viewerBlockedCreator = saveMember("viewer-blocked", MemberRole.CREATOR)
val creatorBlockedViewer = saveMember("creator-blocked", MemberRole.CREATOR)
val visibleCreator = saveMember("visible", MemberRole.CREATOR)
saveFollowing(viewer, viewerBlockedCreator)
saveFollowing(viewer, creatorBlockedViewer)
saveFollowing(viewer, visibleCreator)
saveBlock(viewer, viewerBlockedCreator)
saveBlock(creatorBlockedViewer, viewer)
flushAndClear()
val creators = repository.findFollowingCreators(memberId = viewer.id!!, limit = 20)
assertEquals(listOf(visibleCreator.id!!), creators.map { it.creatorId })
}
@Test
@DisplayName("On Air는 팔로우한 크리에이터의 진행 중 라이브만 최신순으로 조회하고 성인 라이브를 필터링한다")
fun shouldFindFollowingOnAirLivesWithAdultFilter() {
val viewer = saveMember("live-viewer", MemberRole.USER)
val creator = saveMember("live-creator", MemberRole.CREATOR, profileImage = "live-profile.png")
val otherCreator = saveMember("live-other", MemberRole.CREATOR)
val nonCreator = saveMember("live-non-creator", MemberRole.USER)
saveFollowing(viewer, creator)
saveFollowing(viewer, otherCreator, isActive = false)
saveFollowing(viewer, nonCreator)
val older = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 10, 0), channelName = "older")
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 11, 0), channelName = "adult", isAdult = true)
val latest = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 12, 0), channelName = "latest")
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 25, 13, 0), channelName = null)
saveLiveRoom(otherCreator, LocalDateTime.of(2026, 6, 25, 14, 0), channelName = "other")
saveLiveRoom(nonCreator, LocalDateTime.of(2026, 6, 25, 15, 0), channelName = "non-creator")
flushAndClear()
val lives = repository.findOnAirLives(memberId = viewer.id!!, canViewAdultContent = 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("이달의 스케줄은 KST 오늘 00시부터 다음 달 00시 전까지 라이브와 오디오를 가까운 순으로 조회한다")
fun shouldFindMonthlySchedulesInKstWindow() {
val viewer = saveMember("schedule-viewer", MemberRole.USER)
val creator = saveMember("schedule-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("schedule-blocked", MemberRole.CREATOR)
val nonCreator = saveMember("schedule-non-creator", MemberRole.USER)
val theme = saveTheme("schedule-theme")
saveFollowing(viewer, creator)
saveFollowing(viewer, blockedCreator)
saveFollowing(viewer, nonCreator)
val live = saveLiveRoom(creator, LocalDateTime.of(2026, 6, 24, 15, 0), channelName = null)
val audio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 1, 0), isActive = false)
saveLiveRoom(creator, LocalDateTime.of(2026, 6, 24, 14, 59), channelName = null)
saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 30, 15, 0))
saveLiveRoom(blockedCreator, LocalDateTime.of(2026, 6, 25, 0, 0), channelName = null)
saveAudioContent(nonCreator, theme, LocalDateTime.of(2026, 6, 25, 2, 0))
saveBlock(viewer, blockedCreator)
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 12, 0),
limit = 3
)
assertEquals(listOf("LIVE:${live.id!!}", "AUDIO:${audio.id!!}"), schedules.map { it.scheduleId })
assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), schedules.map { it.type })
assertFalse(schedules.first().isOnAir)
}
@Test
@DisplayName("예약 오디오는 공개 전 비활성 상태여도 duration과 월간 releaseDate가 있으면 스케줄에 포함한다")
fun shouldIncludeInactiveScheduledAudioInMonthlySchedules() {
val viewer = saveMember("schedule-inactive-audio-viewer", MemberRole.USER)
val creator = saveMember("schedule-inactive-audio-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-inactive-audio-theme")
saveFollowing(viewer, creator)
val scheduledAudio = saveAudioContent(
creator = creator,
theme = theme,
releaseDate = LocalDateTime.of(2026, 6, 25, 3, 0),
isActive = false
)
saveAudioContent(
creator = creator,
theme = theme,
releaseDate = LocalDateTime.of(2026, 6, 25, 4, 0),
isActive = false
).duration = null
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 3
)
assertEquals(listOf("AUDIO:${scheduledAudio.id!!}"), schedules.map { it.scheduleId })
}
@Test
@DisplayName("이달의 스케줄은 UTC now를 KST로 변환해 KST 저녁의 같은 날 일정을 포함한다")
fun shouldIncludeSameKstDayScheduleWhenUtcNowIsKstEvening() {
val viewer = saveMember("schedule-evening-viewer", MemberRole.USER)
val creator = saveMember("schedule-evening-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-evening-theme")
saveFollowing(viewer, creator)
val sameKstDayEvening = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 14, 45))
saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 14, 20))
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 3
)
assertTrue(schedules.map { it.scheduleId }.contains("AUDIO:${sameKstDayEvening.id!!}"))
}
@Test
@DisplayName("이달의 스케줄은 같은 시각이면 type과 targetId 순으로 안정 정렬한다")
fun shouldSortMonthlySchedulesByTypeAndTargetIdWhenScheduledAtIsSame() {
val viewer = saveMember("schedule-tie-viewer", MemberRole.USER)
val creator = saveMember("schedule-tie-creator", MemberRole.CREATOR)
val theme = saveTheme("schedule-tie-theme")
saveFollowing(viewer, creator)
val sameTime = LocalDateTime.of(2026, 6, 25, 1, 0)
val firstLive = saveLiveRoom(creator, sameTime, channelName = null)
val secondLive = saveLiveRoom(creator, sameTime, channelName = null)
val firstAudio = saveAudioContent(creator, theme, sameTime)
val secondAudio = saveAudioContent(creator, theme, sameTime)
flushAndClear()
val schedules = repository.findMonthlySchedules(
memberId = viewer.id!!,
canViewAdultContent = false,
now = LocalDateTime.of(2026, 6, 25, 0, 0),
limit = 10
)
assertEquals(
listOf(
"LIVE:${firstLive.id!!}",
"LIVE:${secondLive.id!!}",
"AUDIO:${firstAudio.id!!}",
"AUDIO:${secondAudio.id!!}"
),
schedules.map { it.scheduleId }
)
}
@Test
@DisplayName("최근 소식은 활성 노출 가능 inbox를 최신순으로 조회하고 creatorId 없이 nullable rank만 반환한다")
fun shouldFindRecentNewsWithoutCreatorIdAndWithNullableRank() {
val viewer = saveMember("news-viewer", MemberRole.USER)
val creator = saveMember("news-creator", MemberRole.CREATOR)
val blockedCreator = saveMember("news-blocked", MemberRole.CREATOR)
val nonCreator = saveMember("news-non-creator", MemberRole.USER)
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)
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)
saveNews(viewer.id!!, blockedCreator.id!!, "blocked", LocalDateTime.of(2026, 6, 25, 9, 45))
saveNews(viewer.id!!, nonCreator.id!!, "non-creator", LocalDateTime.of(2026, 6, 25, 9, 15))
saveBlock(viewer, blockedCreator)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 9, 30),
limit = 30
)
assertEquals(listOf(latestVisible.id!!.toString(), oldVisible.id!!.toString()), news.map { it.newsId })
assertEquals(listOf(3, null), news.map { it.rank })
}
@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)
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))
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = false,
nowUtc = LocalDateTime.of(2026, 6, 25, 14, 30),
limit = 30
)
assertEquals(listOf(visibleNow.id!!.toString()), news.map { it.newsId })
}
@Test
@DisplayName("최근 소식은 오디오와 커뮤니티 원천 target이 비활성화되면 제외한다")
fun shouldExcludeRecentNewsWhenSourceTargetIsInactive() {
val viewer = saveMember("news-target-viewer", MemberRole.USER)
val creator = saveMember("news-target-creator", MemberRole.CREATOR)
val theme = saveTheme("news-target-theme")
saveFollowing(viewer, creator)
val activeAudio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 0), isActive = true)
val inactiveAudio = saveAudioContent(creator, theme, LocalDateTime.of(2026, 6, 25, 8, 10), isActive = false)
val activePost = saveCommunityPost(creator, "active-post", isActive = true)
val inactivePost = saveCommunityPost(creator, "inactive-post", isActive = false)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "active-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 0),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = activeAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-audio",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 1),
newsType = FollowingNewsType.AUDIO_CONTENT,
targetId = inactiveAudio.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "active-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 2),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = activePost.id!!
)
saveNews(
memberId = viewer.id!!,
creatorId = creator.id!!,
sourceKey = "inactive-post",
visibleFromAtUtc = LocalDateTime.of(2026, 6, 25, 9, 3),
newsType = FollowingNewsType.COMMUNITY_POST,
targetId = inactivePost.id!!
)
flushAndClear()
val news = repository.findRecentNews(
memberId = viewer.id!!,
canViewAdultContent = true,
nowUtc = LocalDateTime.of(2026, 6, 25, 10, 0),
limit = 30
)
assertEquals(
listOf(activePost.id!!, activeAudio.id!!),
news.map { it.targetId }
)
}
private fun saveMember(seed: String, role: MemberRole, profileImage: String? = null, isActive: Boolean = true): Member {
val member = Member(
email = "$seed@test.com",
password = "password",
nickname = seed,
profileImage = profileImage,
role = role
)
member.isActive = isActive
entityManager.persist(member)
return member
}
private fun saveFollowing(member: Member, creator: Member, isActive: Boolean = true): CreatorFollowing {
val following = CreatorFollowing(isActive = isActive).apply {
this.member = member
this.creator = creator
}
entityManager.persist(following)
return following
}
private fun saveBlock(member: Member, blockedMember: Member): BlockMember {
val block = BlockMember(isActive = true).apply {
this.member = member
this.blockedMember = blockedMember
}
entityManager.persist(block)
return block
}
private fun saveLiveRoom(
creator: Member,
beginDateTime: LocalDateTime,
channelName: String?,
isAdult: Boolean = false
): LiveRoom {
val liveRoom = LiveRoom(
title = "live-${creator.nickname}-$beginDateTime",
notice = "notice",
beginDateTime = beginDateTime,
numberOfPeople = 0,
isAdult = isAdult
).apply {
member = creator
this.channelName = channelName
}
entityManager.persist(liveRoom)
return liveRoom
}
private fun saveTheme(seed: String): AudioContentTheme {
val theme = AudioContentTheme(theme = seed, image = "$seed.png", isActive = true)
entityManager.persist(theme)
return theme
}
private fun saveAudioContent(
creator: Member,
theme: AudioContentTheme,
releaseDate: LocalDateTime,
isActive: Boolean = true
): AudioContent {
val content = AudioContent(
title = "audio-$releaseDate",
detail = "detail",
languageCode = "ko",
releaseDate = releaseDate
).apply {
member = creator
this.theme = theme
duration = "00:10:00"
this.isActive = isActive
}
entityManager.persist(content)
return content
}
private fun saveCommunityPost(creator: Member, content: String, isActive: Boolean): CreatorCommunity {
val post = CreatorCommunity(
content = content,
price = 0,
isCommentAvailable = true,
isAdult = false,
isActive = isActive
).apply {
member = creator
}
entityManager.persist(post)
return post
}
private fun saveNews(
memberId: Long,
creatorId: Long,
sourceKey: String,
visibleFromAtUtc: LocalDateTime,
rank: Int? = null,
isAdult: Boolean = false,
newsType: FollowingNewsType = FollowingNewsType.CREATOR_RANKING,
targetId: Long = creatorId
): HomeFollowingNewsInbox {
val news = HomeFollowingNewsInbox(
memberId = memberId,
creatorId = creatorId,
newsType = newsType,
sourceKey = sourceKey,
targetId = targetId,
occurredAtUtc = visibleFromAtUtc.minusHours(1),
visibleFromAtUtc = visibleFromAtUtc,
creatorNickname = "creator-$creatorId",
creatorProfileImagePath = "profile-$creatorId.png",
title = "title-$sourceKey",
body = "body",
thumbnailImagePath = null,
rank = rank,
isAdult = isAdult
)
entityManager.persist(news)
return news
}
private fun flushAndClear() {
entityManager.flush()
entityManager.clear()
}
}