diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt new file mode 100644 index 00000000..06ecf152 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicy.kt @@ -0,0 +1,42 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.domain + +import java.time.DayOfWeek +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.TemporalAdjusters + +class AudioRankingPeriodPolicy { + fun resolveLastCompletedWeek(now: ZonedDateTime): AudioRankingPeriod { + val nowKst = now.withZoneSameInstant(KST_ZONE) + val thisWeekMonday = nowKst.toLocalDate() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .atStartOfDay() + return AudioRankingPeriod( + startInclusiveKst = thisWeekMonday.minusWeeks(1), + endExclusiveKst = thisWeekMonday + ) + } + + fun toUtcRange(period: AudioRankingPeriod): AudioRankingUtcRange { + return AudioRankingUtcRange( + startInclusiveUtc = period.startInclusiveKst.atZone(KST_ZONE).withZoneSameInstant(UTC_ZONE).toLocalDateTime(), + endExclusiveUtc = period.endExclusiveKst.atZone(KST_ZONE).withZoneSameInstant(UTC_ZONE).toLocalDateTime() + ) + } + + companion object { + private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") + private val UTC_ZONE: ZoneId = ZoneId.of("UTC") + } +} + +data class AudioRankingPeriod( + val startInclusiveKst: LocalDateTime, + val endExclusiveKst: LocalDateTime +) + +data class AudioRankingUtcRange( + val startInclusiveUtc: LocalDateTime, + val endExclusiveUtc: LocalDateTime +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt new file mode 100644 index 00000000..ab407d0a --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/domain/AudioRankingPeriodPolicyTest.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.v2.content.ranking.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZonedDateTime + +class AudioRankingPeriodPolicyTest { + private val policy = AudioRankingPeriodPolicy() + + @Test + @DisplayName("임의의 수요일 KST 기준 지난 주 월요일 00시 이상 이번 주 월요일 00시 미만 기간을 산출한다") + fun shouldResolveLastCompletedWeekFromWednesdayByKstMonday() { + val now = ZonedDateTime.of(2026, 6, 10, 14, 30, 0, 0, ZoneId.of("Asia/Seoul")) + + val period = policy.resolveLastCompletedWeek(now) + + assertEquals(LocalDateTime.of(2026, 6, 1, 0, 0), period.startInclusiveKst) + assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), period.endExclusiveKst) + } + + @Test + @DisplayName("기간 산출은 서버 timezone UTC와 무관하게 KST 기준으로 계산한다") + fun shouldResolveLastCompletedWeekIndependentOfServerTimezone() { + val now = ZonedDateTime.of(2026, 6, 8, 5, 30, 0, 0, ZoneId.of("UTC")) + + val period = policy.resolveLastCompletedWeek(now) + + assertEquals(LocalDateTime.of(2026, 6, 1, 0, 0), period.startInclusiveKst) + assertEquals(LocalDateTime.of(2026, 6, 8, 0, 0), period.endExclusiveKst) + } + + @Test + @DisplayName("KST 기간은 DB 조회용 UTC LocalDateTime 이상/미만 조건으로 변환한다") + fun shouldConvertKstPeriodToUtcRange() { + val period = AudioRankingPeriod( + startInclusiveKst = LocalDateTime.of(2026, 6, 1, 0, 0), + endExclusiveKst = LocalDateTime.of(2026, 6, 8, 0, 0) + ) + + val utcRange = policy.toUtcRange(period) + + assertEquals(LocalDateTime.of(2026, 5, 31, 15, 0), utcRange.startInclusiveUtc) + assertEquals(LocalDateTime.of(2026, 6, 7, 15, 0), utcRange.endExclusiveUtc) + } +}