diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt new file mode 100644 index 00000000..43fc2c48 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt @@ -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 { + 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 { + 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 { + val window = monthlyScheduleWindow(now) + val liveSchedules = findLiveSchedules(memberId, canViewAdultContent, window) + val audioSchedules = findAudioSchedules(memberId, canViewAdultContent, window) + return (liveSchedules + audioSchedules) + .sortedWith( + compareBy { it.scheduledAtUtc } + .thenBy { it.type.sortOrder } + .thenBy { it.targetId } + ) + .take(limit) + } + + override fun findRecentNews( + memberId: Long, + canViewAdultContent: Boolean, + nowUtc: LocalDateTime, + limit: Int + ): List { + 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 { + 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 { + 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): 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") + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt new file mode 100644 index 00000000..abc981ff --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt @@ -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() + } +}