test #426
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user