refactor(audio-recommendation): 추천 패키지를 content 기준으로 이동한다
This commit is contained in:
+454
@@ -0,0 +1,454 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence
|
||||
|
||||
import com.querydsl.jpa.impl.JPAQueryFactory
|
||||
import kr.co.vividnext.sodalive.admin.content.series.genre.SeriesGenre
|
||||
import kr.co.vividnext.sodalive.configs.QueryDslConfig
|
||||
import kr.co.vividnext.sodalive.content.AudioContent
|
||||
import kr.co.vividnext.sodalive.content.comment.AudioContentComment
|
||||
import kr.co.vividnext.sodalive.content.like.AudioContentLike
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBanner
|
||||
import kr.co.vividnext.sodalive.content.main.banner.AudioContentBannerType
|
||||
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.member.block.BlockMember
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicy
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.CreatorContentViewHistory
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
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 DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
queryFactory: JPAQueryFactory
|
||||
) {
|
||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
||||
|
||||
@Test
|
||||
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
|
||||
fun shouldFindBannersWithHomeBannerPolicy() {
|
||||
val viewer = saveMember("viewer", MemberRole.USER)
|
||||
val visibleCreator = saveMember("visible-creator", MemberRole.CREATOR)
|
||||
val blockedCreator = saveMember("blocked-creator", MemberRole.CREATOR)
|
||||
val visibleBanner = saveBanner("visible.png", AudioContentBannerType.CREATOR, 1, creator = visibleCreator)
|
||||
val adultBanner = saveBanner("adult.png", AudioContentBannerType.LINK, 2, isAdult = true, link = "https://adult.test")
|
||||
saveBanner("inactive.png", AudioContentBannerType.LINK, 2, isActive = false, link = "https://inactive.test")
|
||||
saveBanner("blocked.png", AudioContentBannerType.CREATOR, 3, creator = blockedCreator)
|
||||
saveBlock(viewer, blockedCreator)
|
||||
flushAndClear()
|
||||
|
||||
val banners = repository.findBanners(limit = 20, memberId = viewer.id, canViewAdultContent = false)
|
||||
|
||||
assertEquals(
|
||||
listOf("https://cdn.test/${visibleBanner.thumbnailImage}", "https://cdn.test/${adultBanner.thumbnailImage}"),
|
||||
banners.map { it.imageUrl }
|
||||
)
|
||||
assertEquals(visibleCreator.id, banners.first().creatorId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("오리지널 시리즈는 활성 원본 시리즈를 최신순으로 반환하고 성인/차단 조건을 적용한다")
|
||||
fun shouldFindOriginalSeriesWithVisibilityConditions() {
|
||||
val viewer = saveMember("series-viewer", MemberRole.USER)
|
||||
val visibleCreator = saveMember("series-visible", MemberRole.CREATOR)
|
||||
val blockedCreator = saveMember("series-blocked", MemberRole.CREATOR)
|
||||
val visibleSeries = (1..13).map { index ->
|
||||
saveSeries("visible-series-$index", visibleCreator, isOriginal = true, coverImage = "series-$index.png")
|
||||
}
|
||||
saveSeries("normal-series", visibleCreator, isOriginal = false)
|
||||
saveSeries("adult-series", visibleCreator, isOriginal = true, isAdult = true)
|
||||
saveSeries("blocked-series", blockedCreator, isOriginal = true)
|
||||
saveBlock(viewer, blockedCreator)
|
||||
flushAndClear()
|
||||
|
||||
val series = repository.findOriginalSeries(12, viewer.id, canViewAdultContent = false, now = LocalDateTime.now())
|
||||
|
||||
assertEquals(12, series.size)
|
||||
assertEquals(visibleSeries.map { it.id }.asReversed().take(12), series.map { it.seriesId })
|
||||
assertEquals("https://cdn.test/series-13.png", series.first().coverImageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신/무료/포인트 오디오는 공개 조건과 공통 AudioCard enrichment를 적용한다")
|
||||
fun shouldFindRealtimeAudioCardsWithCommonEnrichment() {
|
||||
val now = LocalDateTime.of(2026, 6, 23, 12, 0)
|
||||
val creator = saveMember("audio-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val first = saveAudio(
|
||||
creator = creator,
|
||||
theme = theme,
|
||||
title = "first",
|
||||
releaseDate = now.minusDays(3),
|
||||
price = 0,
|
||||
isPointAvailable = true,
|
||||
coverImage = "first.png"
|
||||
)
|
||||
val latest = saveAudio(
|
||||
creator = creator,
|
||||
theme = theme,
|
||||
title = "latest",
|
||||
releaseDate = now.minusDays(1),
|
||||
price = 10,
|
||||
isPointAvailable = false,
|
||||
coverImage = "latest.png"
|
||||
)
|
||||
saveAudio(creator, theme, "adult", now.minusHours(1), isAdult = true)
|
||||
saveAudio(creator, theme, "future", now.plusDays(1))
|
||||
saveAudio(creator, theme, "inactive", now.minusHours(2)).isActive = false
|
||||
saveAudio(creator, theme, "no-duration", now.minusHours(3)).duration = null
|
||||
saveAudio(creator, theme, "no-release-date", now.minusHours(4)).releaseDate = null
|
||||
val inactiveCreator = saveMember("inactive-audio-creator", MemberRole.CREATOR, isActive = false)
|
||||
saveAudio(inactiveCreator, theme, "inactive-creator", now.minusHours(5))
|
||||
val viewer = saveMember("blocked-audio-viewer", MemberRole.USER)
|
||||
val blockedCreator = saveMember("blocked-audio-creator", MemberRole.CREATOR)
|
||||
saveAudio(blockedCreator, theme, "blocked", now.minusHours(6))
|
||||
saveBlock(viewer, blockedCreator)
|
||||
val originalSeries = saveSeries("original", creator, isOriginal = true)
|
||||
saveSeriesContent(originalSeries, latest)
|
||||
val limitCreator = saveMember("limit-audio-creator", MemberRole.CREATOR)
|
||||
repeat(11) { index ->
|
||||
saveAudio(
|
||||
creator = limitCreator,
|
||||
theme = theme,
|
||||
title = "free-point-$index",
|
||||
releaseDate = now.minusDays(10).minusMinutes(index.toLong()),
|
||||
price = 0,
|
||||
isPointAvailable = true
|
||||
)
|
||||
}
|
||||
flushAndClear()
|
||||
|
||||
val latestAudios = repository.findLatestAudios(12, viewer.id, canViewAdultContent = false, now = now)
|
||||
val freeAudios = repository.findFreeAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
||||
val pointAudios = repository.findPointAudios(10, viewer.id, canViewAdultContent = false, now = now)
|
||||
|
||||
assertEquals(12, latestAudios.size)
|
||||
assertEquals(listOf(latest.id, first.id), latestAudios.take(2).map { it.audioContentId })
|
||||
assertEquals(10, freeAudios.size)
|
||||
assertEquals(true, freeAudios.all { it.price == 0 })
|
||||
assertEquals(10, pointAudios.size)
|
||||
assertEquals(true, pointAudios.all { it.isPointAvailable })
|
||||
val latestCard = latestAudios.first()
|
||||
assertEquals("latest", latestCard.title)
|
||||
assertEquals("00:01", latestCard.duration)
|
||||
assertEquals("https://cdn.test/latest.png", latestCard.imageUrl)
|
||||
assertEquals(10, latestCard.price)
|
||||
assertEquals(false, latestCard.isAdult)
|
||||
assertEquals(false, latestCard.isPointAvailable)
|
||||
assertEquals(false, latestCard.isFirstContent)
|
||||
assertEquals(true, latestCard.isOriginalSeries)
|
||||
assertEquals(creator.nickname, latestCard.creatorNickname)
|
||||
assertEquals(true, latestAudios[1].isFirstContent)
|
||||
assertEquals(false, latestAudios[1].isOriginalSeries)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("New & Hot 후보는 조회/좋아요/댓글/최신성 점수순으로 산정하고 SAFE는 성인을 제외한다")
|
||||
fun shouldFindNewAndHotSnapshotsWithVisibility() {
|
||||
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||
val windowStart = snapshotAt.minusDays(2).toLocalDate().atStartOfDay()
|
||||
val creator = saveMember("snapshot-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val visible = saveAudio(creator, theme, "visible-hot", snapshotAt.minusDays(1))
|
||||
val adult = saveAudio(creator, theme, "adult-hot", snapshotAt.minusDays(1), isAdult = true)
|
||||
repeat(2) { saveView(visible, snapshotAt.minusHours(it.toLong())) }
|
||||
saveLike(visible, snapshotAt.minusHours(1))
|
||||
saveComment(visible, creator, "visible-comment", snapshotAt.minusHours(1))
|
||||
repeat(5) { saveView(adult, snapshotAt.minusHours(it.toLong())) }
|
||||
flushAndClear()
|
||||
|
||||
val safe = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 12)
|
||||
val all = repository.findNewAndHotSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.ALL, 12)
|
||||
|
||||
assertEquals(listOf(visible.id), safe.map { it.targetId })
|
||||
assertEquals(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, safe.first().sectionType)
|
||||
assertEquals(listOf(adult.id, visible.id), all.map { it.targetId })
|
||||
val expectedScore = AudioRecommendationScorePolicy().calculateNewAndHotScore(
|
||||
viewCount = 2,
|
||||
likeCount = 1,
|
||||
commentCount = 1,
|
||||
releaseDate = visible.releaseDate!!,
|
||||
now = snapshotAt
|
||||
)
|
||||
assertEquals(expectedScore, safe.first().score)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최근 댓글 많은 오디오는 댓글 점수 후보와 최신 댓글 상세를 반환한다")
|
||||
fun shouldFindMostCommentedSnapshotsAndCommentedAudios() {
|
||||
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
|
||||
val viewer = saveMember("comment-viewer", MemberRole.USER)
|
||||
val creator = saveMember("comment-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("comment-writer", MemberRole.USER).apply { profileImage = "writer.png" }
|
||||
val blockedWriter = saveMember("blocked-writer", MemberRole.USER)
|
||||
val inactiveWriter = saveMember("inactive-comment-writer", MemberRole.USER, isActive = false)
|
||||
val theme = saveTheme()
|
||||
val first = saveAudio(creator, theme, "first-commented", snapshotAt.minusDays(2), coverImage = "commented.png")
|
||||
val second = saveAudio(creator, theme, "second-commented", snapshotAt.minusDays(2))
|
||||
val hiddenOnly = saveAudio(creator, theme, "hidden-only", snapshotAt.minusDays(2))
|
||||
val invisibleOnly = saveAudio(creator, theme, "invisible-only", snapshotAt.minusDays(2))
|
||||
saveComment(first, writer, "old", snapshotAt.minusDays(2))
|
||||
saveComment(first, writer, "latest", snapshotAt.minusHours(1))
|
||||
saveComment(first, blockedWriter, "blocked-latest", snapshotAt.minusMinutes(30))
|
||||
saveComment(first, writer, "inactive", snapshotAt.minusMinutes(1), isActive = false)
|
||||
saveComment(second, blockedWriter, "blocked", snapshotAt.minusHours(2))
|
||||
val parent = saveComment(hiddenOnly, writer, "parent", snapshotAt.minusDays(1), isSecret = true)
|
||||
saveComment(hiddenOnly, writer, "reply", snapshotAt.minusHours(1), parent = parent)
|
||||
saveComment(invisibleOnly, inactiveWriter, "inactive-writer", snapshotAt.minusHours(2))
|
||||
saveComment(invisibleOnly, blockedWriter, "blocked-writer", snapshotAt.minusHours(1))
|
||||
saveBlock(creator, blockedWriter)
|
||||
saveBlock(viewer, blockedWriter)
|
||||
flushAndClear()
|
||||
|
||||
val snapshots = repository.findMostCommentedSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 5)
|
||||
val commented = repository.findCommentedAudiosByIds(
|
||||
contentIds = listOf(first.id!!, second.id!!),
|
||||
memberId = viewer.id,
|
||||
canViewAdultContent = false
|
||||
)
|
||||
|
||||
assertEquals(listOf(first.id), snapshots.map { it.targetId })
|
||||
assertEquals(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshots.first().sectionType)
|
||||
assertEquals(listOf(first.id), commented.map { it.audioContentId })
|
||||
assertEquals("latest", commented.first().latestComment)
|
||||
assertEquals("https://cdn.test/writer.png", commented.first().latestCommentWriterProfileImageUrl)
|
||||
assertEquals("https://cdn.test/commented.png", commented.first().imageUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("댓글 상세는 공개 최상위 댓글만 노출하고 동일 시각이면 id가 큰 댓글 하나를 선택한다")
|
||||
fun shouldFindLatestVisibleTopLevelCommentWithIdTieBreaker() {
|
||||
val now = LocalDateTime.now().plusDays(1)
|
||||
val viewer = saveMember("tie-viewer", MemberRole.USER)
|
||||
val creator = saveMember("tie-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("tie-writer", MemberRole.USER)
|
||||
val theme = saveTheme()
|
||||
val audio = saveAudio(creator, theme, "tie-commented", now.minusDays(1))
|
||||
val sameCreatedAt = now.minusHours(1)
|
||||
saveComment(audio, writer, "same-time-first", sameCreatedAt)
|
||||
saveComment(audio, writer, "same-time-second", sameCreatedAt)
|
||||
saveComment(audio, writer, "secret-latest", now.minusMinutes(20), isSecret = true)
|
||||
val parent = saveComment(audio, writer, "public-parent", now.minusHours(2))
|
||||
saveComment(audio, writer, "reply-latest", now.minusMinutes(10), parent = parent)
|
||||
flushAndClear()
|
||||
|
||||
val commented = repository.findCommentedAudiosByIds(
|
||||
contentIds = listOf(audio.id!!),
|
||||
memberId = viewer.id,
|
||||
canViewAdultContent = false
|
||||
)
|
||||
|
||||
assertEquals(1, commented.size)
|
||||
assertEquals("same-time-second", commented.single().latestComment)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("댓글 상세는 크리에이터와 댓글 작성자 간 차단 댓글을 최신 댓글에서 제외한다")
|
||||
fun shouldExcludeCreatorBlockedWriterFromLatestCommentDetail() {
|
||||
val now = LocalDateTime.now().plusDays(1)
|
||||
val viewer = saveMember("creator-block-comment-viewer", MemberRole.USER)
|
||||
val creator = saveMember("creator-block-comment-creator", MemberRole.CREATOR)
|
||||
val writer = saveMember("creator-block-comment-writer", MemberRole.USER)
|
||||
val blockedWriter = saveMember("creator-block-comment-blocked-writer", MemberRole.USER)
|
||||
val theme = saveTheme()
|
||||
val audio = saveAudio(creator, theme, "creator-block-commented", now.minusDays(1))
|
||||
saveComment(audio, writer, "visible-comment", now.minusHours(2))
|
||||
saveComment(audio, blockedWriter, "creator-blocked-latest", now.minusHours(1))
|
||||
saveBlock(creator, blockedWriter)
|
||||
flushAndClear()
|
||||
|
||||
val commented = repository.findCommentedAudiosByIds(
|
||||
contentIds = listOf(audio.id!!),
|
||||
memberId = viewer.id,
|
||||
canViewAdultContent = false
|
||||
)
|
||||
|
||||
assertEquals(1, commented.size)
|
||||
assertEquals("visible-comment", commented.single().latestComment)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 오디오는 playCount가 아니라 조회 이력 기반 점수로 산정한다")
|
||||
fun shouldFindRecommendedAudioSnapshotsWithoutPlayCount() {
|
||||
val snapshotAt = LocalDateTime.now().plusDays(1)
|
||||
val windowStart = snapshotAt.minusDays(6).toLocalDate().atStartOfDay()
|
||||
val creator = saveMember("recommended-creator", MemberRole.CREATOR)
|
||||
val theme = saveTheme()
|
||||
val viewed = saveAudio(creator, theme, "viewed", snapshotAt.minusDays(1))
|
||||
val playCountOnly = saveAudio(creator, theme, "play-count-only", snapshotAt.minusDays(1)).apply { playCount = 999 }
|
||||
repeat(3) { saveView(viewed, snapshotAt.minusHours(it.toLong())) }
|
||||
flushAndClear()
|
||||
|
||||
val snapshots = repository.findRecommendedAudioSnapshots(windowStart, snapshotAt, AudioRecommendationVisibility.SAFE, 10)
|
||||
|
||||
assertEquals(viewed.id, snapshots.first().targetId)
|
||||
assertEquals(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshots.first().sectionType)
|
||||
assertEquals(
|
||||
true,
|
||||
snapshots.indexOfFirst { it.targetId == viewed.id } <
|
||||
snapshots.indexOfFirst { it.targetId == playCountOnly.id }
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveMember(nickname: String, role: MemberRole, isActive: Boolean = true): Member {
|
||||
val member = Member(
|
||||
email = "$nickname@test.com",
|
||||
password = "password",
|
||||
nickname = nickname,
|
||||
role = role,
|
||||
isActive = isActive
|
||||
)
|
||||
entityManager.persist(member)
|
||||
return member
|
||||
}
|
||||
|
||||
private fun saveTheme(): AudioContentTheme {
|
||||
val theme = AudioContentTheme(theme = "theme", image = "theme.png", isActive = true)
|
||||
entityManager.persist(theme)
|
||||
return theme
|
||||
}
|
||||
|
||||
private fun saveBanner(
|
||||
thumbnailImage: String,
|
||||
type: AudioContentBannerType,
|
||||
orders: Int,
|
||||
isActive: Boolean = true,
|
||||
isAdult: Boolean = false,
|
||||
creator: Member? = null,
|
||||
link: String? = null
|
||||
): AudioContentBanner {
|
||||
val banner = AudioContentBanner(
|
||||
thumbnailImage = thumbnailImage,
|
||||
type = type,
|
||||
isAdult = isAdult,
|
||||
isActive = isActive,
|
||||
orders = orders
|
||||
)
|
||||
banner.creator = creator
|
||||
banner.link = link
|
||||
entityManager.persist(banner)
|
||||
return banner
|
||||
}
|
||||
|
||||
private fun saveSeries(
|
||||
title: String,
|
||||
creator: Member,
|
||||
isOriginal: Boolean,
|
||||
isAdult: Boolean = false,
|
||||
coverImage: String? = null
|
||||
): Series {
|
||||
val genre = SeriesGenre("genre-$title")
|
||||
entityManager.persist(genre)
|
||||
val series = Series(title = title, introduction = "intro", isOriginal = isOriginal, isAdult = isAdult, isActive = true)
|
||||
series.genre = genre
|
||||
series.member = creator
|
||||
series.coverImage = coverImage
|
||||
entityManager.persist(series)
|
||||
return series
|
||||
}
|
||||
|
||||
private fun saveAudio(
|
||||
creator: Member,
|
||||
theme: AudioContentTheme,
|
||||
title: String,
|
||||
releaseDate: LocalDateTime,
|
||||
price: Int = 0,
|
||||
isAdult: Boolean = false,
|
||||
isPointAvailable: Boolean = false,
|
||||
coverImage: String? = null
|
||||
): AudioContent {
|
||||
val audio = AudioContent(
|
||||
title = title,
|
||||
detail = "detail",
|
||||
languageCode = "ko",
|
||||
price = price,
|
||||
releaseDate = releaseDate,
|
||||
isAdult = isAdult,
|
||||
isPointAvailable = isPointAvailable
|
||||
)
|
||||
audio.isActive = true
|
||||
audio.duration = "00:01"
|
||||
audio.coverImage = coverImage
|
||||
audio.member = creator
|
||||
audio.theme = theme
|
||||
entityManager.persist(audio)
|
||||
return audio
|
||||
}
|
||||
|
||||
private fun saveView(audio: AudioContent, viewedAt: LocalDateTime) {
|
||||
entityManager.persist(
|
||||
CreatorContentViewHistory(memberId = 1L, contentId = audio.id!!, genreId = 1L, viewedAt = viewedAt)
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveLike(audio: AudioContent, createdAt: LocalDateTime) {
|
||||
val like = AudioContentLike(memberId = 1L)
|
||||
like.audioContent = audio
|
||||
like.createdAt = createdAt
|
||||
like.updatedAt = createdAt
|
||||
entityManager.persist(like)
|
||||
}
|
||||
|
||||
private fun saveComment(
|
||||
audio: AudioContent,
|
||||
writer: Member,
|
||||
commentBody: String,
|
||||
createdAt: LocalDateTime,
|
||||
isActive: Boolean = true,
|
||||
isSecret: Boolean = false,
|
||||
parent: AudioContentComment? = null
|
||||
): AudioContentComment {
|
||||
val comment = AudioContentComment(
|
||||
comment = commentBody,
|
||||
languageCode = "ko",
|
||||
isSecret = isSecret,
|
||||
isActive = isActive
|
||||
)
|
||||
comment.audioContent = audio
|
||||
comment.member = writer
|
||||
comment.parent = parent
|
||||
comment.createdAt = createdAt
|
||||
comment.updatedAt = createdAt
|
||||
entityManager.persist(comment)
|
||||
entityManager.flush()
|
||||
comment.createdAt = createdAt
|
||||
comment.updatedAt = createdAt
|
||||
return comment
|
||||
}
|
||||
|
||||
private fun saveSeriesContent(series: Series, audio: AudioContent) {
|
||||
val seriesContent = SeriesContent()
|
||||
seriesContent.series = series
|
||||
seriesContent.content = audio
|
||||
entityManager.persist(seriesContent)
|
||||
}
|
||||
|
||||
private fun saveBlock(member: Member, blockedMember: Member) {
|
||||
val block = BlockMember(isActive = true)
|
||||
block.member = member
|
||||
block.blockedMember = blockedMember
|
||||
entityManager.persist(block)
|
||||
}
|
||||
|
||||
private fun flushAndClear() {
|
||||
entityManager.flush()
|
||||
entityManager.clear()
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshService
|
||||
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.RLock
|
||||
import org.redisson.api.RedissonClient
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AudioRecommendationSnapshotSchedulerTest {
|
||||
private val refreshService = Mockito.mock(AudioRecommendationSnapshotRefreshService::class.java)
|
||||
private val redissonClient = Mockito.mock(RedissonClient::class.java)
|
||||
private val lock = Mockito.mock(RLock::class.java)
|
||||
private val scheduler = AudioRecommendationSnapshotScheduler(refreshService, redissonClient)
|
||||
|
||||
@Test
|
||||
@DisplayName("스케줄러는 매일 00:00 KST cron을 사용한다")
|
||||
fun shouldUseMidnightKstCron() {
|
||||
val annotation = AudioRecommendationSnapshotScheduler::class.java
|
||||
.getDeclaredMethod("refreshDailySnapshots")
|
||||
.getAnnotation(Scheduled::class.java)
|
||||
|
||||
assertEquals("0 0 0 * * *", annotation.cron)
|
||||
assertEquals("Asia/Seoul", annotation.zone)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("락 획득 성공 시에만 refresh를 호출하고 보유 중이면 unlock한다")
|
||||
fun shouldRefreshOnlyWhenLockAcquired() {
|
||||
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
|
||||
Mockito.doReturn(true).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
|
||||
Mockito.doReturn(true).`when`(lock).isHeldByCurrentThread
|
||||
|
||||
scheduler.refreshDailySnapshots()
|
||||
|
||||
Mockito.verify(refreshService).refreshDailySnapshots()
|
||||
Mockito.verify(lock).unlock()
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("락 획득 실패 시 refresh와 unlock을 호출하지 않는다")
|
||||
fun shouldSkipWhenLockNotAcquired() {
|
||||
Mockito.doReturn(lock).`when`(redissonClient).getLock(AudioRecommendationSnapshotScheduler.LOCK_KEY)
|
||||
Mockito.doReturn(false).`when`(lock).tryLock(0, -1, TimeUnit.SECONDS)
|
||||
Mockito.doReturn(false).`when`(lock).isHeldByCurrentThread
|
||||
|
||||
scheduler.refreshDailySnapshots()
|
||||
|
||||
Mockito.verify(refreshService, Mockito.never()).refreshDailySnapshots()
|
||||
Mockito.verify(lock, Mockito.never()).unlock()
|
||||
}
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.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.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.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 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
|
||||
@DisplayName("비회원은 SAFE visibility를 사용한다")
|
||||
fun shouldResolveSafeVisibilityForAnonymous() {
|
||||
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
|
||||
@DisplayName("visibility와 섹션 조합은 신규 RecommendedSectionType으로 매핑된다")
|
||||
fun shouldMapVisibilityToAudioSectionTypes() {
|
||||
assertEquals(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE,
|
||||
service.newAndHotSectionType(AudioRecommendationVisibility.SAFE)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL,
|
||||
service.newAndHotSectionType(AudioRecommendationVisibility.ALL)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE,
|
||||
service.mostCommentedSectionType(AudioRecommendationVisibility.SAFE)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL,
|
||||
service.mostCommentedSectionType(AudioRecommendationVisibility.ALL)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_SAFE,
|
||||
service.recommendedAudioSectionType(AudioRecommendationVisibility.SAFE)
|
||||
)
|
||||
assertEquals(
|
||||
RecommendedSectionType.RECOMMENDED_AUDIO_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))
|
||||
)
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.application
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationVisibility
|
||||
import kr.co.vividnext.sodalive.v2.content.recommendation.port.out.AudioRecommendationQueryPort
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.domain.RecommendedSectionType
|
||||
import kr.co.vividnext.sodalive.v2.recommendation.port.out.RecommendationSnapshotPort
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
class AudioRecommendationSnapshotRefreshServiceTest {
|
||||
private val snapshotPort = Mockito.mock(RecommendationSnapshotPort::class.java)
|
||||
private val queryPort = Mockito.mock(AudioRecommendationQueryPort::class.java)
|
||||
private val service = AudioRecommendationSnapshotRefreshService(snapshotPort, queryPort)
|
||||
|
||||
@Test
|
||||
@DisplayName("일 배치는 KST 전날 23:59:59 기준으로 여섯 오디오 스냅샷을 교체한다")
|
||||
fun shouldRefreshAllAudioSnapshotsWithKstPreviousDaySnapshotAt() {
|
||||
val now = LocalDateTime.of(2026, 6, 24, 0, 0)
|
||||
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
|
||||
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
Mockito.verify(queryPort).findNewAndHotSnapshots(
|
||||
newAndHotWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.ALL,
|
||||
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.NEW_AND_HOT_AUDIO_ALL, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.MOST_COMMENTED_AUDIO_ALL, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList())
|
||||
Mockito.verify(snapshotPort).replaceSnapshots(RecommendedSectionType.RECOMMENDED_AUDIO_ALL, snapshotAt, emptyList())
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("일 배치는 ZonedDateTime 입력의 zone과 무관하게 KST 날짜 경계 기준으로 스냅샷 시각을 계산한다")
|
||||
fun shouldRefreshSnapshotsByKstBoundaryFromZonedDateTime() {
|
||||
val now = ZonedDateTime.of(2026, 6, 24, 0, 0, 0, 0, ZoneId.of("Asia/Seoul"))
|
||||
val snapshotAt = LocalDateTime.of(2026, 6, 23, 23, 59, 59)
|
||||
val newAndHotWindowStart = LocalDateTime.of(2026, 6, 21, 0, 0)
|
||||
val mostCommentedWindowStart = LocalDateTime.of(2026, 6, 17, 0, 0)
|
||||
|
||||
service.refreshDailySnapshots(now)
|
||||
|
||||
Mockito.verify(queryPort).findNewAndHotSnapshots(
|
||||
newAndHotWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.NEW_AND_HOT_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findMostCommentedSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.ALL,
|
||||
AudioRecommendationSnapshotRefreshService.MOST_COMMENTED_LIMIT
|
||||
)
|
||||
Mockito.verify(queryPort).findRecommendedAudioSnapshots(
|
||||
mostCommentedWindowStart,
|
||||
snapshotAt,
|
||||
AudioRecommendationVisibility.SAFE,
|
||||
AudioRecommendationSnapshotRefreshService.RECOMMENDED_AUDIO_LIMIT
|
||||
)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.content.recommendation.domain
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.DisplayName
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class AudioRecommendationScorePolicyTest {
|
||||
private val policy = AudioRecommendationScorePolicy()
|
||||
private val now = LocalDateTime.of(2026, 6, 23, 12, 0)
|
||||
|
||||
@Test
|
||||
@DisplayName("New & Hot 점수는 원본 count를 정규화하지 않고 가중합으로 계산한다")
|
||||
fun shouldCalculateNewAndHotScoreWithoutNormalization() {
|
||||
val score = policy.calculateNewAndHotScore(10, 4, 2, now.minusDays(2), now)
|
||||
|
||||
assertEquals(485.5, score)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("추천 오디오 점수는 원본 count와 최신성 배수를 가중합으로 계산한다")
|
||||
fun shouldCalculateRecommendedAudioScoreWithoutNormalization() {
|
||||
val score = policy.calculateRecommendedAudioScore(10, 4, 2, now.minusDays(10), now)
|
||||
|
||||
assertEquals(601.0, score)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("최신성 배수는 정책별 일수 경계를 적용한다")
|
||||
fun shouldReturnRecencyMultipliersByPolicyBoundaries() {
|
||||
assertEquals(1.3, policy.newAndHotRecencyMultiplier(now.minusDays(3), now))
|
||||
assertEquals(1.15, policy.newAndHotRecencyMultiplier(now.minusDays(7), now))
|
||||
assertEquals(1.0, policy.newAndHotRecencyMultiplier(now.minusDays(14), now))
|
||||
assertEquals(0.8, policy.newAndHotRecencyMultiplier(now.minusDays(15), now))
|
||||
assertEquals(1.3, policy.recommendedAudioRecencyMultiplier(now.minusDays(3), now))
|
||||
assertEquals(1.15, policy.recommendedAudioRecencyMultiplier(now.minusDays(7), now))
|
||||
assertEquals(1.1, policy.recommendedAudioRecencyMultiplier(now.minusDays(30), now))
|
||||
assertEquals(1.0, policy.recommendedAudioRecencyMultiplier(now.minusDays(31), now))
|
||||
assertEquals(1.3, policy.commentRecencyMultiplier(now.minusDays(3), now))
|
||||
assertEquals(1.15, policy.commentRecencyMultiplier(now.minusDays(7), now))
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user