diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt new file mode 100644 index 00000000..18ed043b --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt @@ -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) + ) + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt new file mode 100644 index 00000000..b8fc896f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt @@ -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 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 + ) + } +}