feat(content): 콘텐츠 전체보기 facade를 추가한다

This commit is contained in:
2026-06-27 06:41:06 +09:00
parent ef9ddae94b
commit 4e2b63acf4
2 changed files with 189 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewItemResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponse
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
class ContentOverviewFacade(
private val audioRecommendationQueryService: AudioRecommendationQueryService,
private val homeRecommendationQueryService: HomeRecommendationQueryService,
private val memberContentPreferenceService: MemberContentPreferenceService,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String,
private val queryPolicy: ContentOverviewQueryPolicy = ContentOverviewQueryPolicy()
) {
fun getContents(type: String?, page: Int?, size: Int?, member: Member): ContentOverviewPageResponse {
val resolvedType = queryPolicy.resolveType(type)
val resolvedPage = queryPolicy.createPage(page, size)
return when (resolvedType) {
ContentOverviewType.NEW_AND_HOT_AUDIO -> getNewAndHotContents(member, resolvedPage)
ContentOverviewType.FIRST_AUDIO_CONTENT -> getFirstAudioContents(member, resolvedPage)
}
}
private fun getNewAndHotContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse {
val fetched = audioRecommendationQueryService.findNewAndHotAudios(
member = member,
offset = page.offset,
limit = page.size + 1
)
return ContentOverviewPageResponse(
type = ContentOverviewType.NEW_AND_HOT_AUDIO,
items = queryPolicy.pageItems(fetched, page).map { ContentOverviewItemResponse.fromNewAndHot(it) },
page = page.page,
size = page.size,
hasNext = queryPolicy.hasNext(fetched, page)
)
}
private fun getFirstAudioContents(member: Member, page: ContentOverviewPage): ContentOverviewPageResponse {
val fetched = homeRecommendationQueryService.findFirstAudioContents(
now = LocalDateTime.now(),
offset = page.offset,
limit = page.size + 1,
memberId = member.id,
includeAdultContents = memberContentPreferenceService.canViewAdultContent(member)
)
return ContentOverviewPageResponse(
type = ContentOverviewType.FIRST_AUDIO_CONTENT,
items = queryPolicy.pageItems(fetched, page).map {
ContentOverviewItemResponse.fromFirstAudioContent(
audio = it,
coverImage = it.coverImage.toCdnUrl(cloudFrontHost),
isAdult = it.isAdult,
isOriginalSeries = it.isOriginalSeries
)
},
page = page.page,
size = page.size,
hasNext = queryPolicy.hasNext(fetched, page)
)
}
}

View File

@@ -0,0 +1,117 @@
package kr.co.vividnext.sodalive.v2.api.content.overview.application
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService
import kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewType
import kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryService
import kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioCard
import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService
import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeFirstAudioContentRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import java.time.LocalDateTime
class ContentOverviewFacadeTest {
private val audioRecommendationQueryService = Mockito.mock(AudioRecommendationQueryService::class.java)
private val homeRecommendationQueryService = Mockito.mock(HomeRecommendationQueryService::class.java)
private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
private val facade = ContentOverviewFacade(
audioRecommendationQueryService = audioRecommendationQueryService,
homeRecommendationQueryService = homeRecommendationQueryService,
memberContentPreferenceService = memberContentPreferenceService,
cloudFrontHost = "https://cdn.test",
queryPolicy = ContentOverviewQueryPolicy()
)
@Test
@DisplayName("New & Hot 전체보기는 size + 1 조회 결과를 공통 페이지 응답으로 변환한다")
fun shouldReturnNewAndHotPage() {
val member = member(id = 10L)
Mockito.doReturn((1L..21L).map { audioCard(it) }).`when`(audioRecommendationQueryService)
.findNewAndHotAudios(member, offset = 0L, limit = 21)
val response = facade.getContents("NEW_AND_HOT_AUDIO", page = 0, size = 20, member = member)
assertEquals(ContentOverviewType.NEW_AND_HOT_AUDIO, response.type)
assertEquals((1L..20L).toList(), response.items.map { it.contentId })
assertEquals("https://cdn.test/audio1.png", response.items[0].coverImage)
assertEquals(0, response.page)
assertEquals(20, response.size)
assertEquals(true, response.hasNext)
}
@Test
@DisplayName("첫 번째 오디오 콘텐츠 전체보기는 adult visibility와 offset을 반영해 조회한다")
fun shouldReturnFirstAudioContentPage() {
val member = member(id = 10L)
Mockito.doReturn(true).`when`(memberContentPreferenceService).canViewAdultContent(member)
Mockito.doReturn(listOf(firstAudio(1L), firstAudio(2L))).`when`(homeRecommendationQueryService)
.findFirstAudioContents(
anyLocalDateTime(),
eqValue(20L),
eqValue(21),
eqValue(member.id),
eqValue(true)
)
val response = facade.getContents("FIRST_AUDIO_CONTENT", page = 1, size = 20, member = member)
assertEquals(ContentOverviewType.FIRST_AUDIO_CONTENT, response.type)
assertEquals(listOf(1L, 2L), response.items.map { it.contentId })
assertEquals("https://cdn.test/cover/audio1.png", response.items[0].coverImage)
assertEquals(true, response.items[0].isFirstContent)
assertEquals(false, response.hasNext)
}
private fun member(id: Long): Member {
return Member(
email = "viewer$id@test.com",
password = "password",
nickname = "viewer$id",
role = MemberRole.USER
).apply {
this.id = id
}
}
private fun audioCard(id: Long): AudioCard {
return AudioCard(
audioContentId = id,
title = "audio$id",
duration = "00:01",
imageUrl = "https://cdn.test/audio$id.png",
price = id.toInt(),
isAdult = false,
isPointAvailable = true,
isFirstContent = true,
isOriginalSeries = false,
creatorNickname = "creator$id"
)
}
private fun anyLocalDateTime(): LocalDateTime {
return Mockito.any(LocalDateTime::class.java) ?: LocalDateTime.of(2026, 6, 27, 0, 0)
}
private fun <T> eqValue(value: T): T {
return Mockito.eq(value) ?: value
}
private fun firstAudio(id: Long): HomeFirstAudioContentRecord {
return HomeFirstAudioContentRecord(
contentId = id,
creatorId = id + 100,
creatorNickname = "creator$id",
creatorProfileImage = null,
title = "first audio$id",
price = id.toInt(),
coverImage = "cover/audio$id.png",
isPointAvailable = true,
isAdult = false,
isOriginalSeries = false
)
}
}