feat(audio-recommendation): 추천 snapshot 저장소를 추가한다
This commit is contained in:
+197
-1
@@ -4,6 +4,8 @@ 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
|
||||
@@ -12,6 +14,10 @@ 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.audio.recommendation.domain.AudioRecommendationScorePolicy
|
||||
import kr.co.vividnext.sodalive.v2.audio.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
|
||||
@@ -32,7 +38,7 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
private val entityManager: EntityManager,
|
||||
queryFactory: JPAQueryFactory
|
||||
) {
|
||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, "https://cdn.test")
|
||||
private val repository = DefaultAudioRecommendationQueryRepository(queryFactory, entityManager, "https://cdn.test")
|
||||
|
||||
@Test
|
||||
@DisplayName("배너는 홈 추천 배너와 같은 활성/탭/차단 정책과 CDN URL을 적용한다")
|
||||
@@ -152,6 +158,155 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
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",
|
||||
@@ -237,6 +392,47 @@ class DefaultAudioRecommendationQueryRepositoryTest @Autowired constructor(
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user