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