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

View File

@@ -0,0 +1,343 @@
package kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence
import com.querydsl.core.Tuple
import com.querydsl.core.types.Expression
import com.querydsl.core.types.dsl.BooleanExpression
import com.querydsl.jpa.JPAExpressions
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.extensions.toUtcIso
import kr.co.vividnext.sodalive.live.room.QLiveRoom.liveRoom
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.QMember
import kr.co.vividnext.sodalive.member.block.QBlockMember
import kr.co.vividnext.sodalive.member.following.QCreatorFollowing.creatorFollowing
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.HomeFollowingCreator
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
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Repository
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
@Repository
class DefaultHomeFollowingQueryRepository(
private val queryFactory: JPAQueryFactory,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) : HomeFollowingQueryRepository {
override fun findFollowingCreators(memberId: Long, limit: Int): List<HomeFollowingCreator> {
val creator = QMember("followingCreator")
return queryFactory
.select(creator.id, creator.nickname, creator.profileImage)
.from(creatorFollowing)
.join(creatorFollowing.creator, creator)
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(creatorFollowing.createdAt.desc(), creatorFollowing.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingCreator(
creatorId = row.get(creator.id)!!,
creatorNickname = row.get(creator.nickname)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage))
)
}
}
override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List<HomeFollowingLive> {
val creator = QMember("onAirCreator")
return queryFactory
.select(liveRoom.id, creator.profileImage, creator.nickname, liveRoom.title, liveRoom.beginDateTime)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.channelName.isNotNull,
liveRoom.channelName.isNotEmpty,
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.orderBy(liveRoom.beginDateTime.desc(), liveRoom.id.desc())
.limit(limit.toLong())
.fetch()
.map { row ->
HomeFollowingLive(
liveId = row.get(liveRoom.id)!!,
creatorProfileImageUrl = profileImageUrl(row.get(creator.profileImage)),
creatorNickname = row.get(creator.nickname)!!,
title = row.get(liveRoom.title)!!,
startedAtUtc = row.get(liveRoom.beginDateTime)!!.toUtcIso()
)
}
}
override fun findMonthlySchedules(
memberId: Long,
canViewAdultContent: Boolean,
now: LocalDateTime,
limit: Int
): List<HomeFollowingSchedule> {
val window = monthlyScheduleWindow(now)
val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window)
val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window)
return (liveSchedules + audioSchedules)
.sortedWith(
compareBy<HomeFollowingSchedule> { it.scheduledAtUtc }
.thenBy { it.type.sortOrder }
.thenBy { it.targetId }
)
.take(limit)
}
override fun findRecentNews(
memberId: Long,
canViewAdultContent: Boolean,
nowUtc: LocalDateTime,
limit: Int
): List<HomeFollowingNews> {
val creator = QMember("newsCreator")
return queryFactory
.select(
homeFollowingNewsInbox.id,
homeFollowingNewsInbox.newsType,
homeFollowingNewsInbox.creatorProfileImagePath,
homeFollowingNewsInbox.creatorNickname,
homeFollowingNewsInbox.title,
homeFollowingNewsInbox.body,
homeFollowingNewsInbox.thumbnailImagePath,
homeFollowingNewsInbox.targetId,
homeFollowingNewsInbox.occurredAtUtc,
homeFollowingNewsInbox.visibleFromAtUtc,
homeFollowingNewsInbox.rank
)
.from(homeFollowingNewsInbox)
.join(creator).on(creator.id.eq(homeFollowingNewsInbox.creatorId))
.where(
homeFollowingNewsInbox.memberId.eq(memberId),
homeFollowingNewsInbox.isActive.isTrue,
homeFollowingNewsInbox.visibleFromAtUtc.loe(nowUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultNewsCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, homeFollowingNewsInbox.creatorId),
activeNewsTargetCondition()
)
.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)
)
}
}
private fun findLiveSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleLiveCreator")
return queryFactory
.select(
liveRoom.id,
creator.id,
creator.profileImage,
creator.nickname,
liveRoom.title,
liveRoom.beginDateTime,
liveRoom.channelName
)
.from(liveRoom)
.join(liveRoom.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
liveRoom.isActive.isTrue,
liveRoom.beginDateTime.goe(window.startUtc),
liveRoom.beginDateTime.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultLiveCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toLiveSchedule(creator) }
}
private fun findAudioSchedules(
memberId: Long,
canViewAdultContent: Boolean,
window: ScheduleWindow
): List<HomeFollowingSchedule> {
val creator = QMember("scheduleAudioCreator")
return queryFactory
.select(
audioContent.id,
creator.id,
creator.profileImage,
creator.nickname,
audioContent.title,
audioContent.releaseDate
)
.from(audioContent)
.join(audioContent.member, creator)
.join(creatorFollowing).on(creatorFollowing.creator.id.eq(creator.id))
.where(
creatorFollowing.member.id.eq(memberId),
creatorFollowing.isActive.isTrue,
audioContent.duration.isNotNull,
audioContent.releaseDate.isNotNull,
audioContent.releaseDate.goe(window.startUtc),
audioContent.releaseDate.lt(window.endUtc),
creator.isActive.isTrue,
creator.role.eq(MemberRole.CREATOR),
adultAudioCondition(canViewAdultContent),
notBlockedCreatorCondition(memberId, creator.id)
)
.fetch()
.map { row -> row.toAudioSchedule(creator) }
}
private fun Tuple.toLiveSchedule(creator: QMember): HomeFollowingSchedule {
val liveId = get(liveRoom.id)!!
val channelName = get(liveRoom.channelName)
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.LIVE}:$liveId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(liveRoom.title)!!,
type = CreatorActivityType.LIVE,
targetId = liveId,
scheduledAtUtc = get(liveRoom.beginDateTime)!!.toUtcIso(),
isOnAir = !channelName.isNullOrBlank()
)
}
private fun Tuple.toAudioSchedule(creator: QMember): HomeFollowingSchedule {
val contentId = get(audioContent.id)!!
return HomeFollowingSchedule(
scheduleId = "${CreatorActivityType.AUDIO}:$contentId",
creatorId = get(creator.id)!!,
creatorProfileImageUrl = profileImageUrl(get(creator.profileImage)),
creatorNickname = get(creator.nickname)!!,
title = get(audioContent.title)!!,
type = CreatorActivityType.AUDIO,
targetId = contentId,
scheduledAtUtc = get(audioContent.releaseDate)!!.toUtcIso(),
isOnAir = false
)
}
private fun monthlyScheduleWindow(now: LocalDateTime): ScheduleWindow {
val kstNow = now.atOffset(ZoneOffset.UTC).atZoneSameInstant(KST_ZONE_ID).toLocalDateTime()
val startKst = kstNow.toLocalDate().atStartOfDay()
val endKst = startKst.toLocalDate().plusMonths(1).withDayOfMonth(1).atStartOfDay()
return ScheduleWindow(startUtc = startKst.toUtcFromKst(), endUtc = endKst.toUtcFromKst())
}
private fun LocalDateTime.toUtcFromKst(): LocalDateTime {
return atZone(KST_ZONE_ID).withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()
}
private fun adultLiveCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else liveRoom.isAdult.isFalse
}
private fun adultAudioCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else audioContent.isAdult.isFalse
}
private fun adultNewsCondition(canViewAdultContent: Boolean): BooleanExpression? {
return if (canViewAdultContent) null else homeFollowingNewsInbox.isAdult.isFalse
}
private fun activeNewsTargetCondition(): BooleanExpression {
val newsAudioContent = QAudioContent("newsAudioContent")
val newsCommunity = QCreatorCommunity("newsCommunity")
val activeAudioExists = JPAExpressions
.selectOne()
.from(newsAudioContent)
.where(
newsAudioContent.id.eq(homeFollowingNewsInbox.targetId),
newsAudioContent.isActive.isTrue
)
.exists()
val activeCommunityExists = JPAExpressions
.selectOne()
.from(newsCommunity)
.where(
newsCommunity.id.eq(homeFollowingNewsInbox.targetId),
newsCommunity.isActive.isTrue
)
.exists()
return homeFollowingNewsInbox.newsType.eq(FollowingNewsType.CREATOR_RANKING)
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.AUDIO_CONTENT).and(activeAudioExists))
.or(homeFollowingNewsInbox.newsType.eq(FollowingNewsType.COMMUNITY_POST).and(activeCommunityExists))
}
private fun notBlockedCreatorCondition(memberId: Long, creatorIdPath: Expression<Long>): BooleanExpression {
val blockMember = QBlockMember("homeFollowingBlockMember")
return JPAExpressions
.selectOne()
.from(blockMember)
.where(
blockMember.isActive.isTrue,
blockMember.member.id.eq(memberId).and(blockMember.blockedMember.id.eq(creatorIdPath))
.or(blockMember.member.id.eq(creatorIdPath).and(blockMember.blockedMember.id.eq(memberId)))
)
.notExists()
}
private fun profileImageUrl(path: String?): String {
return path.toCdnUrl(cloudFrontHost) ?: "$cloudFrontHost/profile/default-profile.png"
}
private val CreatorActivityType.sortOrder: Int
get() = when (this) {
CreatorActivityType.LIVE -> 0
else -> 1
}
private data class ScheduleWindow(
val startUtc: LocalDateTime,
val endUtc: LocalDateTime
)
companion object {
private val KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
}
}

View File

@@ -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()
}
}