diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt new file mode 100644 index 00000000..dde7d19d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicy.kt @@ -0,0 +1,37 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.domain + +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +import java.time.LocalDateTime + +class CreatorChannelHomeQueryPolicy { + fun limitSchedules( + schedules: List, + now: LocalDateTime + ): List { + return schedules + .filter { it.scheduledAt > now } + .sortedWith(compareBy { it.scheduledAt }.thenBy { it.type.scheduleOrder() }) + .take(3) + } + + fun excludeLatestAudioContent( + audioContents: List, + latestAudioContentId: Long? + ): List { + return audioContents.filter { it.audioContentId != latestAudioContentId } + } + + fun markFirstAudioContent(audioContents: List): List { + val firstAudioContentId = audioContents + .minWithOrNull(compareBy { it.publishedAt }.thenBy { it.audioContentId }) + ?.audioContentId + + return audioContents.map { audioContent -> + audioContent.copy(isFirstContent = audioContent.audioContentId == firstAudioContentId) + } + } + + private fun CreatorActivityType.scheduleOrder(): Int { + return if (this == CreatorActivityType.LIVE) 0 else 1 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt new file mode 100644 index 00000000..c15d651f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/domain/CreatorChannelHomeQueryPolicyTest.kt @@ -0,0 +1,117 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.domain + +import kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityType +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 java.time.LocalDateTime + +class CreatorChannelHomeQueryPolicyTest { + private val policy = CreatorChannelHomeQueryPolicy() + + @Test + @DisplayName("스케줄은 예약 시각 오름차순 최대 3개만 남긴다") + fun shouldLimitSchedulesToEarliestThree() { + val now = LocalDateTime.of(2026, 6, 12, 9, 0) + val schedules = listOf( + schedule(targetId = 4L, scheduledAt = LocalDateTime.of(2026, 6, 12, 13, 0)), + schedule(targetId = 2L, scheduledAt = LocalDateTime.of(2026, 6, 12, 11, 0)), + schedule(targetId = 1L, scheduledAt = LocalDateTime.of(2026, 6, 12, 10, 0)), + schedule(targetId = 3L, scheduledAt = LocalDateTime.of(2026, 6, 12, 12, 0)) + ) + + val limited = policy.limitSchedules(schedules, now) + + assertEquals(listOf(1L, 2L, 3L), limited.map { it.targetId }) + } + + @Test + @DisplayName("스케줄은 현재 시각 이후 예약만 남긴다") + fun shouldOnlyKeepSchedulesAfterNow() { + val now = LocalDateTime.of(2026, 6, 12, 10, 0) + val schedules = listOf( + schedule(targetId = 1L, scheduledAt = now.minusMinutes(1)), + schedule(targetId = 2L, scheduledAt = now), + schedule(targetId = 3L, scheduledAt = now.plusMinutes(1)) + ) + + val limited = policy.limitSchedules(schedules, now) + + assertEquals(listOf(3L), limited.map { it.targetId }) + } + + @Test + @DisplayName("같은 예약 시각이면 라이브가 오디오보다 먼저 온다") + fun shouldSortLiveBeforeAudioWhenScheduledAtIsSame() { + val scheduledAt = LocalDateTime.of(2026, 6, 12, 10, 0) + val schedules = listOf( + schedule(targetId = 2L, scheduledAt = scheduledAt, type = CreatorActivityType.AUDIO), + schedule(targetId = 1L, scheduledAt = scheduledAt, type = CreatorActivityType.LIVE) + ) + + val limited = policy.limitSchedules(schedules, scheduledAt.minusMinutes(1)) + + assertEquals(listOf(CreatorActivityType.LIVE, CreatorActivityType.AUDIO), limited.map { it.type }) + } + + @Test + @DisplayName("오디오 목록에서는 latestAudioContentId와 같은 콘텐츠를 제외한다") + fun shouldExcludeLatestAudioContent() { + val audioContents = listOf(audioContent(1L), audioContent(2L), audioContent(3L)) + + val filtered = policy.excludeLatestAudioContent(audioContents, latestAudioContentId = 2L) + + assertEquals(listOf(1L, 3L), filtered.map { it.audioContentId }) + } + + @Test + @DisplayName("오디오 콘텐츠의 첫 공개 콘텐츠 여부는 공개 시각 오름차순, 동일 시각이면 id 오름차순으로 판정한다") + fun shouldMarkFirstAudioContentByPublishedAtAndId() { + val publishedAt = LocalDateTime.of(2026, 6, 12, 10, 0) + val audioContents = listOf( + audioContent(3L, publishedAt = publishedAt.plusDays(1)), + audioContent(2L, publishedAt = publishedAt), + audioContent(1L, publishedAt = publishedAt) + ) + + val marked = policy.markFirstAudioContent(audioContents) + + assertTrue(marked.first { it.audioContentId == 1L }.isFirstContent) + assertFalse(marked.first { it.audioContentId == 2L }.isFirstContent) + assertFalse(marked.first { it.audioContentId == 3L }.isFirstContent) + } + + private fun schedule( + targetId: Long, + scheduledAt: LocalDateTime, + type: CreatorActivityType = CreatorActivityType.LIVE + ): CreatorChannelSchedule { + return CreatorChannelSchedule( + scheduledAt = scheduledAt, + title = "schedule-$targetId", + type = type, + targetId = targetId + ) + } + + private fun audioContent( + audioContentId: Long, + publishedAt: LocalDateTime = LocalDateTime.of(2026, 6, 12, 10, 0) + ): CreatorChannelAudioContent { + return CreatorChannelAudioContent( + audioContentId = audioContentId, + title = "audio-$audioContentId", + duration = null, + imageUrl = null, + price = 0, + isAdult = false, + isPointAvailable = false, + isFirstContent = false, + publishedAt = publishedAt, + seriesName = null, + isOriginalSeries = null + ) + } +}