From ab67e36d96d62b28c210e140fdd16921b4a9ad0c Mon Sep 17 00:00:00 2001 From: Klaus Date: Tue, 23 Jun 2026 21:06:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(audio-recommendation):=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EC=A1=B0=ED=9A=8C=20snapshot=20fallback=EC=9D=84?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AudioRecommendationQueryService.kt | 91 +++++++-- .../domain/AudioRecommendationScorePolicy.kt | 2 +- .../in/web/AudioRecommendationEndToEndTest.kt | 179 ++++++++++++++++++ .../AudioRecommendationQueryServiceTest.kt | 170 ++++++++++++++++- .../AudioRecommendationScorePolicyTest.kt | 11 ++ 5 files changed, 437 insertions(+), 16 deletions(-) create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt index b3c234eb..aa7575eb 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryService.kt @@ -2,33 +2,67 @@ package kr.co.vividnext.sodalive.v2.audio.recommendation.application import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService -import kr.co.vividnext.sodalive.member.contentpreference.isAdultVisibleByPolicy import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendations import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord +import org.redisson.api.RedissonClient import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional +import java.time.Duration +import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneId @Service class AudioRecommendationQueryService( private val queryPort: AudioRecommendationQueryPort, - private val memberContentPreferenceService: MemberContentPreferenceService + private val memberContentPreferenceService: MemberContentPreferenceService, + private val snapshotPort: RecommendationSnapshotPort, + private val snapshotRefreshService: AudioRecommendationSnapshotRefreshService, + private val redissonClient: RedissonClient ) { - @Transactional(readOnly = true) fun getRecommendations(member: Member?): AudioRecommendations { val now = LocalDateTime.now() val canViewAdultContent = canViewAdultContent(member) + val visibility = if (canViewAdultContent) AudioRecommendationVisibility.ALL else AudioRecommendationVisibility.SAFE + val memberId = member?.id + val newAndHotSectionType = newAndHotSectionType(visibility) + val newAndHotSnapshots = snapshotPort.findLatestSnapshots(newAndHotSectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) + val mostCommentedSnapshots = snapshotPort.findLatestSnapshots( + mostCommentedSectionType(visibility), + limit = MOST_COMMENTED_AUDIO_LIMIT + ) + val recommendedSnapshots = snapshotPort.findLatestSnapshots( + recommendedAudioSectionType(visibility), + limit = RECOMMENDED_AUDIO_LIMIT + ) + val refreshedNewAndHotSnapshots = refreshMissingNewAndHotSnapshots(newAndHotSectionType, newAndHotSnapshots) + return AudioRecommendations( - banners = queryPort.findBanners(BANNER_LIMIT, member?.id, canViewAdultContent), - originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, member?.id, canViewAdultContent, now), - latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, member?.id, canViewAdultContent, now), - newAndHotAudios = emptyList(), - freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, member?.id, canViewAdultContent, now), - pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, member?.id, canViewAdultContent, now), - mostCommentedAudios = emptyList(), - recommendedAudios = emptyList() + banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent), + originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now), + latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now), + newAndHotAudios = queryPort.findAudioCardsByIds( + refreshedNewAndHotSnapshots.map { it.targetId }, + memberId, + canViewAdultContent, + now + ), + freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, memberId, canViewAdultContent, now), + pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, memberId, canViewAdultContent, now), + mostCommentedAudios = queryPort.findCommentedAudiosByIds( + mostCommentedSnapshots.map { it.targetId }, + memberId, + canViewAdultContent + ), + recommendedAudios = queryPort.findAudioCardsByIds( + recommendedSnapshots.map { it.targetId }, + memberId, + canViewAdultContent, + now + ) ) } @@ -57,10 +91,32 @@ class AudioRecommendationQueryService( } } + private fun refreshMissingNewAndHotSnapshots( + sectionType: RecommendedSectionType, + snapshots: List + ): List { + if (snapshots.isNotEmpty()) return snapshots + val today = LocalDate.now(KST_ZONE) + val marker = redissonClient.getBucket(newAndHotLazyRefreshMarkerKey(today)) + if (!marker.setIfAbsent(LAZY_REFRESH_ATTEMPTED_VALUE, LAZY_REFRESH_MARKER_TTL)) { + return snapshots + } + runCatching { + snapshotRefreshService.refreshDailySnapshots() + }.onFailure { ex -> + marker.delete() + throw ex + } + return snapshotPort.findLatestSnapshots(sectionType, limit = NEW_AND_HOT_AUDIO_LIMIT) + } + + private fun newAndHotLazyRefreshMarkerKey(date: LocalDate): String { + return "$LAZY_REFRESH_MARKER_KEY_PREFIX:$date" + } + private fun canViewAdultContent(member: Member?): Boolean { if (member == null) return false - val preference = memberContentPreferenceService.initializeDefaultPreference(member) - return isAdultVisibleByPolicy(member, preference.isAdultContentVisible) + return memberContentPreferenceService.getStoredPreference(member).isAdult } companion object { @@ -69,5 +125,12 @@ class AudioRecommendationQueryService( const val LATEST_AUDIO_LIMIT = 12 const val FREE_AUDIO_LIMIT = 10 const val POINT_AUDIO_LIMIT = 10 + const val NEW_AND_HOT_AUDIO_LIMIT = 12 + const val MOST_COMMENTED_AUDIO_LIMIT = 5 + const val RECOMMENDED_AUDIO_LIMIT = 10 + private const val LAZY_REFRESH_MARKER_KEY_PREFIX = "audio-recommendation:new-and-hot:lazy-refresh-attempted" + private const val LAZY_REFRESH_ATTEMPTED_VALUE = "1" + private val LAZY_REFRESH_MARKER_TTL: Duration = Duration.ofDays(2) + private val KST_ZONE: ZoneId = ZoneId.of("Asia/Seoul") } } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt index 1481e99d..a0bbb137 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicy.kt @@ -65,7 +65,7 @@ class AudioRecommendationScorePolicy { } private fun daysBetween(from: LocalDateTime, now: LocalDateTime): Long { - return ChronoUnit.DAYS.between(from.toLocalDate(), now.toLocalDate()).coerceAtLeast(0) + return ChronoUnit.DAYS.between(from, now).coerceAtLeast(0) } companion object { diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt new file mode 100644 index 00000000..75ab8b6c --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/audio/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt @@ -0,0 +1,179 @@ +package kr.co.vividnext.sodalive.v2.api.audio.recommendation.adapter.`in`.web + +import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre +import kr.co.vividnext.sodalive.content.AudioContent +import kr.co.vividnext.sodalive.content.comment.AudioContentComment +import kr.co.vividnext.sodalive.content.theme.AudioContentTheme +import kr.co.vividnext.sodalive.creator.admin.content.series.Series +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesContent +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshot +import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +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.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import javax.persistence.EntityManager + +@SpringBootTest( + properties = [ + "cloud.aws.cloud-front.host=https://cdn.test", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:audio-recommendation-e2e;MODE=MySQL;NON_KEYWORDS=VALUE;DB_CLOSE_ON_EXIT=FALSE" + ] +) +@AutoConfigureMockMvc +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class AudioRecommendationEndToEndTest @Autowired constructor( + private val mockMvc: MockMvc, + private val entityManager: EntityManager, + private val transactionTemplate: TransactionTemplate +) { + @Test + @DisplayName("오디오 추천 API는 controller-service-repository를 거쳐 추천 섹션 응답을 반환한다") + fun shouldReturnRecommendationsThroughControllerServiceAndRepository() { + val fixture = createFixture() + + mockMvc.perform(get("/api/v2/audio/recommendations")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.originalSeries").isArray) + .andExpect(jsonPath("$.data.originalSeries[0].seriesId").value(fixture.seriesId)) + .andExpect(jsonPath("$.data.latestAudios").isArray) + .andExpect(jsonPath("$.data.latestAudios[0].audioContentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.latestAudios[0].isOriginalSeries").value(true)) + .andExpect(jsonPath("$.data.recommendedAudios").isArray) + .andExpect(jsonPath("$.data.recommendedAudios[0].audioContentId").value(fixture.audioContentId)) + .andExpect(jsonPath("$.data.mostCommentedAudios[0].latestComment").value("latest e2e comment")) + .andExpect( + jsonPath("$.data.mostCommentedAudios[0].latestCommentWriterProfileImageUrl") + .value("https://cdn.test/comment-writer.png") + ) + } + + private fun createFixture(): Fixture { + return transactionTemplate.execute { + val now = LocalDateTime.now().minusHours(1) + val creator = saveMember("audio-recommendation-e2e-creator", MemberRole.CREATOR) + val writer = saveMember("audio-recommendation-e2e-writer", MemberRole.USER, profileImage = "comment-writer.png") + val theme = saveTheme() + val audio = saveAudio(creator, theme, now) + val series = saveSeries(creator) + saveSeriesContent(series, audio) + saveComment(audio, writer, "latest e2e comment", now.plusMinutes(10)) + saveSnapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, audio.id!!, now) + saveSnapshot(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, audio.id!!, now) + saveSnapshot(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, audio.id!!, now) + entityManager.flush() + entityManager.clear() + + Fixture( + seriesId = series.id!!, + audioContentId = audio.id!! + ) + }!! + } + + private fun saveMember(nickname: String, role: MemberRole, profileImage: String? = "$nickname.png"): Member { + val member = Member( + email = "$nickname@test.com", + password = "password", + nickname = nickname, + profileImage = profileImage, + role = role + ) + entityManager.persist(member) + return member + } + + private fun saveTheme(): AudioContentTheme { + val theme = AudioContentTheme(theme = "recommendation-e2e-theme", image = "theme.png", isActive = true) + entityManager.persist(theme) + return theme + } + + private fun saveAudio(creator: Member, theme: AudioContentTheme, releaseDate: LocalDateTime): AudioContent { + val audio = AudioContent( + title = "audio-recommendation-e2e", + detail = "detail", + languageCode = "ko", + releaseDate = releaseDate, + isAdult = false, + price = 0, + isPointAvailable = true + ) + audio.member = creator + audio.theme = theme + audio.isActive = true + audio.coverImage = "audio-recommendation-e2e.png" + audio.duration = "00:10" + entityManager.persist(audio) + return audio + } + + private fun saveSeries(creator: Member): Series { + val genre = SeriesGenre("recommendation-e2e-genre") + entityManager.persist(genre) + val series = Series( + title = "recommendation-e2e-series", + introduction = "intro", + isOriginal = true, + isAdult = false, + isActive = true + ) + series.member = creator + series.genre = genre + series.coverImage = "series.png" + entityManager.persist(series) + return series + } + + private fun saveSeriesContent(series: Series, audio: AudioContent) { + val seriesContent = SeriesContent() + seriesContent.series = series + seriesContent.content = audio + entityManager.persist(seriesContent) + } + + private fun saveComment( + audio: AudioContent, + writer: Member, + commentBody: String, + createdAt: LocalDateTime + ): AudioContentComment { + val comment = AudioContentComment(comment = commentBody, languageCode = "ko", isActive = true) + comment.audioContent = audio + comment.member = writer + comment.createdAt = createdAt + comment.updatedAt = createdAt + entityManager.persist(comment) + return comment + } + + private fun saveSnapshot(sectionType: RecommendedSectionType, targetId: Long, snapshotAt: LocalDateTime) { + entityManager.persist( + RecommendationSnapshot( + sectionType = sectionType, + targetId = targetId, + score = 1.0, + snapshotAt = snapshotAt, + randomTieBreaker = 0.0 + ) + ) + } + + private data class Fixture( + val seriesId: Long, + val audioContentId: Long + ) +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt index 31697da8..ac760836 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/application/AudioRecommendationQueryServiceTest.kt @@ -1,18 +1,36 @@ package kr.co.vividnext.sodalive.v2.audio.recommendation.application +import kr.co.vividnext.sodalive.content.ContentType import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.member.contentpreference.ViewerContentPreference import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendationVisibility import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort +import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotRecord import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.mockito.Mockito +import org.redisson.api.RBucket +import org.redisson.api.RedissonClient +import java.time.Duration +import java.time.LocalDateTime class AudioRecommendationQueryServiceTest { private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java) private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) - private val service = AudioRecommendationQueryService(queryPort, preferenceService) + private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java) + private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java) + private val redissonClient = Mockito.mock(RedissonClient::class.java) + private val lazyRefreshMarker = Mockito.mock(RBucket::class.java) as RBucket + private val service = AudioRecommendationQueryService( + queryPort, + preferenceService, + snapshotPort, + refreshService, + redissonClient + ) @Test @DisplayName("비회원은 SAFE visibility를 사용한다") @@ -20,6 +38,130 @@ class AudioRecommendationQueryServiceTest { assertEquals(AudioRecommendationVisibility.SAFE, service.resolveVisibility(null)) } + @Test + @DisplayName("조회 서비스는 SAFE 스냅샷을 lazy refresh 후 상세 섹션으로 조립한다") + fun shouldBuildRecommendationsFromSafeSnapshotsWithLazyRefresh() { + val snapshot = RecommendationSnapshotRecord( + sectionType = RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + targetId = 1L, + score = 10.0, + snapshotAt = LocalDateTime.now(), + randomTieBreaker = 1.0 + ) + Mockito.doReturn(emptyList(), listOf(snapshot)) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + ) + Mockito.doReturn(emptyList()) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT + ) + Mockito.doReturn(emptyList()) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT + ) + allowLazyRefreshOnce() + + val recommendations = service.getRecommendations(null) + + assertEquals(0, recommendations.mostCommentedAudios.size) + Mockito.verify(refreshService).refreshDailySnapshots() + Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT + ) + Mockito.verify(snapshotPort, Mockito.times(1)).findLatestSnapshots( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT + ) + Mockito.verify(queryPort).findBanners(AudioRecommendationQueryService.BANNER_LIMIT, null, false) + Mockito.verify(queryPort).findAudioCardsByIds( + eqValue(listOf(1L)), + Mockito.isNull(), + eqValue(false), + anyLocalDateTime() + ) + } + + @Test + @DisplayName("New & Hot lazy refresh는 보강 후에도 비어 있으면 같은 KST 날짜에 다시 실행하지 않는다") + fun shouldAttemptEmptyNewAndHotLazyRefreshOncePerKstDate() { + Mockito.doReturn(emptyList()) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + ) + Mockito.doReturn(emptyList()) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT + ) + Mockito.doReturn(emptyList()) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, + 0, + AudioRecommendationQueryService.RECOMMENDED_AUDIO_LIMIT + ) + allowLazyRefreshOnce() + + service.getRecommendations(null) + service.getRecommendations(null) + + Mockito.verify(refreshService, Mockito.times(1)).refreshDailySnapshots() + } + + @Test + @DisplayName("인증 회원 성인 정책은 조회용 저장 preference를 사용한다") + fun shouldUseStoredPreferenceForMemberAdultVisibility() { + val member = kr.co.vividnext.sodalive.member.Member( + email = "adult@test.com", + password = "password", + nickname = "adult", + role = kr.co.vividnext.sodalive.member.MemberRole.USER + ) + Mockito.doReturn( + ViewerContentPreference( + countryCode = "KR", + isAdultContentVisible = true, + contentType = ContentType.ALL, + isAdult = true + ) + ).`when`(preferenceService).getStoredPreference(member) + Mockito.doReturn(listOf(snapshot(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, 10L))) + .`when`(snapshotPort) + .findLatestSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + ) + + service.getRecommendations(member) + + Mockito.verify(preferenceService).getStoredPreference(member) + Mockito.verify(preferenceService, Mockito.never()).initializeDefaultPreference(member) + Mockito.verify(snapshotPort).findLatestSnapshots( + RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, + 0, + AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT + ) + } + @Test @DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다") fun shouldMapVisibilityToAudioSectionTypes() { @@ -48,4 +190,30 @@ class AudioRecommendationQueryServiceTest { service.recommendedAudioSectionType(AudioRecommendationVisibility.ALL) ) } + + private fun anyLocalDateTime(): LocalDateTime { + return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.now() + } + + private fun snapshot(sectionType: RecommendedSectionType, targetId: Long): RecommendationSnapshotRecord { + return RecommendationSnapshotRecord( + sectionType = sectionType, + targetId = targetId, + score = 10.0, + snapshotAt = LocalDateTime.now(), + randomTieBreaker = 1.0 + ) + } + + private fun eqValue(value: T): T { + return Mockito.eq(value) ?: value + } + + private fun allowLazyRefreshOnce() { + Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket(Mockito.anyString()) + Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent( + eqValue("1"), + eqValue(Duration.ofDays(2)) + ) + } } diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt index ea296ea6..27235f7d 100644 --- a/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/domain/AudioRecommendationScorePolicyTest.kt @@ -41,4 +41,15 @@ class AudioRecommendationScorePolicyTest { assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now)) assertEquals(0.0, policy.commentRecencyMultiplier(now.minusDays(15), now)) } + + @Test + @DisplayName("최신성 일수는 날짜 경계가 아니라 24시간 경과 기준으로 계산한다") + fun shouldCalculateRecencyDaysByElapsedTwentyFourHours() { + val releaseDate = LocalDateTime.of(2026, 6, 19, 23, 59, 59) + val snapshotAt = LocalDateTime.of(2026, 6, 23, 0, 0) + + assertEquals(1.3, policy.newAndHotRecencyMultiplier(releaseDate, snapshotAt)) + assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(releaseDate, snapshotAt)) + assertEquals(1.3, policy.commentRecencyMultiplier(releaseDate, snapshotAt)) + } }