test #426
@@ -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.Member
|
||||||
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
|
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.AudioRecommendationVisibility
|
||||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.domain.AudioRecommendations
|
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.audio.recommendation.port.out.AudioRecommendationQueryPort
|
||||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
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.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import java.time.Duration
|
||||||
|
import java.time.LocalDate
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class AudioRecommendationQueryService(
|
class AudioRecommendationQueryService(
|
||||||
private val queryPort: AudioRecommendationQueryPort,
|
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 {
|
fun getRecommendations(member: Member?): AudioRecommendations {
|
||||||
val now = LocalDateTime.now()
|
val now = LocalDateTime.now()
|
||||||
val canViewAdultContent = canViewAdultContent(member)
|
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(
|
return AudioRecommendations(
|
||||||
banners = queryPort.findBanners(BANNER_LIMIT, member?.id, canViewAdultContent),
|
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent),
|
||||||
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, member?.id, canViewAdultContent, now),
|
originalSeries = queryPort.findOriginalSeries(ORIGINAL_SERIES_LIMIT, memberId, canViewAdultContent, now),
|
||||||
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, member?.id, canViewAdultContent, now),
|
latestAudios = queryPort.findLatestAudios(LATEST_AUDIO_LIMIT, memberId, canViewAdultContent, now),
|
||||||
newAndHotAudios = emptyList(),
|
newAndHotAudios = queryPort.findAudioCardsByIds(
|
||||||
freeAudios = queryPort.findFreeAudios(FREE_AUDIO_LIMIT, member?.id, canViewAdultContent, now),
|
refreshedNewAndHotSnapshots.map { it.targetId },
|
||||||
pointAudios = queryPort.findPointAudios(POINT_AUDIO_LIMIT, member?.id, canViewAdultContent, now),
|
memberId,
|
||||||
mostCommentedAudios = emptyList(),
|
canViewAdultContent,
|
||||||
recommendedAudios = emptyList()
|
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<RecommendationSnapshotRecord>
|
||||||
|
): List<RecommendationSnapshotRecord> {
|
||||||
|
if (snapshots.isNotEmpty()) return snapshots
|
||||||
|
val today = LocalDate.now(KST_ZONE)
|
||||||
|
val marker = redissonClient.getBucket<String>(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 {
|
private fun canViewAdultContent(member: Member?): Boolean {
|
||||||
if (member == null) return false
|
if (member == null) return false
|
||||||
val preference = memberContentPreferenceService.initializeDefaultPreference(member)
|
return memberContentPreferenceService.getStoredPreference(member).isAdult
|
||||||
return isAdultVisibleByPolicy(member, preference.isAdultContentVisible)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -69,5 +125,12 @@ class AudioRecommendationQueryService(
|
|||||||
const val LATEST_AUDIO_LIMIT = 12
|
const val LATEST_AUDIO_LIMIT = 12
|
||||||
const val FREE_AUDIO_LIMIT = 10
|
const val FREE_AUDIO_LIMIT = 10
|
||||||
const val POINT_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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ class AudioRecommendationScorePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun daysBetween(from: LocalDateTime, now: LocalDateTime): Long {
|
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 {
|
companion object {
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,36 @@
|
|||||||
package kr.co.vividnext.sodalive.v2.audio.recommendation.application
|
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.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.domain.AudioRecommendationVisibility
|
||||||
import kr.co.vividnext.sodalive.v2.audio.recommendation.port.out.AudioRecommendationQueryPort
|
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.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.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.DisplayName
|
import org.junit.jupiter.api.DisplayName
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import org.mockito.Mockito
|
import org.mockito.Mockito
|
||||||
|
import org.redisson.api.RBucket
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class AudioRecommendationQueryServiceTest {
|
class AudioRecommendationQueryServiceTest {
|
||||||
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
||||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::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<String>
|
||||||
|
private val service = AudioRecommendationQueryService(
|
||||||
|
queryPort,
|
||||||
|
preferenceService,
|
||||||
|
snapshotPort,
|
||||||
|
refreshService,
|
||||||
|
redissonClient
|
||||||
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("비회원은 SAFE visibility를 사용한다")
|
@DisplayName("비회원은 SAFE visibility를 사용한다")
|
||||||
@@ -20,6 +38,130 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
assertEquals(AudioRecommendationVisibility.SAFE, service.resolveVisibility(null))
|
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<RecommendationSnapshotRecord>(), listOf(snapshot))
|
||||||
|
.`when`(snapshotPort)
|
||||||
|
.findLatestSnapshots(
|
||||||
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||||
|
0,
|
||||||
|
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||||
|
.`when`(snapshotPort)
|
||||||
|
.findLatestSnapshots(
|
||||||
|
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||||
|
0,
|
||||||
|
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||||
|
.`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<RecommendationSnapshotRecord>())
|
||||||
|
.`when`(snapshotPort)
|
||||||
|
.findLatestSnapshots(
|
||||||
|
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||||
|
0,
|
||||||
|
AudioRecommendationQueryService.NEW_AND_HOT_AUDIO_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||||
|
.`when`(snapshotPort)
|
||||||
|
.findLatestSnapshots(
|
||||||
|
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||||
|
0,
|
||||||
|
AudioRecommendationQueryService.MOST_COMMENTED_AUDIO_LIMIT
|
||||||
|
)
|
||||||
|
Mockito.doReturn(emptyList<RecommendationSnapshotRecord>())
|
||||||
|
.`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
|
@Test
|
||||||
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
|
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
|
||||||
fun shouldMapVisibilityToAudioSectionTypes() {
|
fun shouldMapVisibilityToAudioSectionTypes() {
|
||||||
@@ -48,4 +190,30 @@ class AudioRecommendationQueryServiceTest {
|
|||||||
service.recommendedAudioSectionType(AudioRecommendationVisibility.ALL)
|
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 <T> eqValue(value: T): T {
|
||||||
|
return Mockito.eq(value) ?: value
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun allowLazyRefreshOnce() {
|
||||||
|
Mockito.doReturn(lazyRefreshMarker).`when`(redissonClient).getBucket<String>(Mockito.anyString())
|
||||||
|
Mockito.doReturn(true, false).`when`(lazyRefreshMarker).setIfAbsent(
|
||||||
|
eqValue("1"),
|
||||||
|
eqValue(Duration.ofDays(2))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,15 @@ class AudioRecommendationScorePolicyTest {
|
|||||||
assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now))
|
assertEquals(1.0, policy.commentRecencyMultiplier(now.minusDays(14), now))
|
||||||
assertEquals(0.0, policy.commentRecencyMultiplier(now.minusDays(15), 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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user