diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt new file mode 100644 index 00000000..68c208fc --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt @@ -0,0 +1,171 @@ +package kr.co.vividnext.sodalive.v2.content.all.application + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.i18n.LangContext +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAll +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicy +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentPage +import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class MainContentAllQueryService( + private val queryPort: MainContentAllQueryPort, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val queryPolicy: MainContentAllQueryPolicy = MainContentAllQueryPolicy(), + private val langContext: LangContext +) { + fun getContents( + type: String?, + sort: String?, + dayOfWeek: String?, + page: Int?, + size: Int?, + member: Member? + ): MainContentAll { + val resolvedType = queryPolicy.resolveType(type) + val resolvedSort = queryPolicy.resolveSort(sort) + val resolvedDayOfWeek = queryPolicy.resolveDayOfWeek(resolvedType, dayOfWeek) + val resolvedPage = queryPolicy.createPage(page, size) + val now = LocalDateTime.now() + val memberId = member?.id + val canViewAdultContent = canViewAdultContent(member) + + return when (resolvedType) { + MainContentAllType.AUDIO -> getAudioContents( + type = resolvedType, + sort = resolvedSort, + dayOfWeek = resolvedDayOfWeek, + page = resolvedPage, + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now + ) + + MainContentAllType.FREE -> getAudioContents( + type = resolvedType, + sort = resolvedSort, + dayOfWeek = resolvedDayOfWeek, + page = resolvedPage, + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now, + onlyFree = true + ) + + MainContentAllType.POINT -> getAudioContents( + type = resolvedType, + sort = resolvedSort, + dayOfWeek = resolvedDayOfWeek, + page = resolvedPage, + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now, + onlyPointAvailable = true + ) + + MainContentAllType.SERIES -> getSeriesContents( + type = resolvedType, + sort = resolvedSort, + dayOfWeek = resolvedDayOfWeek, + page = resolvedPage, + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now + ) + + MainContentAllType.ORIGINAL -> getSeriesContents( + type = resolvedType, + sort = resolvedSort, + dayOfWeek = null, + page = resolvedPage, + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now, + onlyOriginal = true + ) + } + } + + private fun getAudioContents( + type: MainContentAllType, + sort: ContentSort, + dayOfWeek: SeriesPublishedDaysOfWeek?, + page: MainContentPage, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyFree: Boolean = false, + onlyPointAvailable: Boolean = false + ): MainContentAll { + val totalCount = queryPort.countAudios(memberId, canViewAdultContent, now, onlyFree, onlyPointAvailable) + val audios = queryPort.findAudios( + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now, + sort = sort, + offset = page.offset, + limit = page.size + 1, + onlyFree = onlyFree, + onlyPointAvailable = onlyPointAvailable + ) + + return MainContentAll( + type = type, + totalCount = totalCount, + audios = queryPolicy.limitItems(audios, page), + series = emptyList(), + sort = sort, + dayOfWeek = dayOfWeek, + page = page, + hasNext = queryPolicy.hasNext(audios, page) + ) + } + + private fun getSeriesContents( + type: MainContentAllType, + sort: ContentSort, + dayOfWeek: SeriesPublishedDaysOfWeek?, + page: MainContentPage, + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyOriginal: Boolean = false + ): MainContentAll { + val totalCount = queryPort.countSeries(memberId, canViewAdultContent, now, onlyOriginal, dayOfWeek) + val series = queryPort.findSeries( + memberId = memberId, + canViewAdultContent = canViewAdultContent, + now = now, + sort = sort, + offset = page.offset, + limit = page.size + 1, + onlyOriginal = onlyOriginal, + dayOfWeek = dayOfWeek, + locale = langContext.lang.code + ) + + return MainContentAll( + type = type, + totalCount = totalCount, + audios = emptyList(), + series = queryPolicy.limitItems(series, page), + sort = sort, + dayOfWeek = dayOfWeek, + page = page, + hasNext = queryPolicy.hasNext(series, page) + ) + } + + private fun canViewAdultContent(member: Member?): Boolean { + if (member == null) return false + return memberContentPreferenceService.canViewAdultContent(member) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt new file mode 100644 index 00000000..ac2bbcd1 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt @@ -0,0 +1,48 @@ +package kr.co.vividnext.sodalive.v2.content.all.port.out + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries +import java.time.LocalDateTime + +interface MainContentAllQueryPort { + fun countAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyFree: Boolean = false, + onlyPointAvailable: Boolean = false + ): Int + + fun findAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyFree: Boolean = false, + onlyPointAvailable: Boolean = false + ): List + + fun countSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyOriginal: Boolean = false, + dayOfWeek: SeriesPublishedDaysOfWeek? = null + ): Int + + fun findSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyOriginal: Boolean = false, + dayOfWeek: SeriesPublishedDaysOfWeek? = null, + locale: String + ): List +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt new file mode 100644 index 00000000..20ca8570 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt @@ -0,0 +1,254 @@ +package kr.co.vividnext.sodalive.v2.content.all.application + +import kr.co.vividnext.sodalive.creator.admin.content.series.SeriesPublishedDaysOfWeek +import kr.co.vividnext.sodalive.i18n.Lang +import kr.co.vividnext.sodalive.i18n.LangContext +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.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllAudio +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllQueryPolicy +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllSeries +import kr.co.vividnext.sodalive.v2.content.all.domain.MainContentAllType +import kr.co.vividnext.sodalive.v2.content.all.port.out.MainContentAllQueryPort +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class MainContentAllQueryServiceTest { + @Test + @DisplayName("AUDIO 타입은 audio port를 기본 필터로 호출한다") + fun shouldQueryAudiosForAudioType() { + val port = FakeMainContentAllQueryPort() + val service = createService(port) + + val tab = service.getContents(type = "AUDIO", sort = "POPULAR", dayOfWeek = "MON", page = 1, size = 20, member = null) + + assertEquals(MainContentAllType.AUDIO, tab.type) + assertEquals("audio", port.lastListKind) + assertEquals(ContentSort.POPULAR, port.lastSort) + assertEquals(20L, port.lastOffset) + assertEquals(21, port.lastLimit) + assertFalse(port.lastOnlyFree) + assertFalse(port.lastOnlyPointAvailable) + assertEquals(20, tab.audios.size) + assertTrue(tab.hasNext) + assertEquals(emptyList(), tab.series) + assertEquals(null, tab.dayOfWeek) + } + + @Test + @DisplayName("FREE 타입은 audio port를 무료 필터로 호출한다") + fun shouldQueryAudiosForFreeType() { + val port = FakeMainContentAllQueryPort() + val service = createService(port) + + val tab = service.getContents(type = "FREE", sort = "LATEST", dayOfWeek = null, page = 0, size = 20, member = null) + + assertEquals(MainContentAllType.FREE, tab.type) + assertEquals("audio", port.lastListKind) + assertTrue(port.lastOnlyFree) + assertFalse(port.lastOnlyPointAvailable) + assertEquals(21, port.lastLimit) + assertEquals(20, tab.audios.size) + assertTrue(tab.hasNext) + assertEquals(emptyList(), tab.series) + } + + @Test + @DisplayName("POINT 타입은 audio port를 포인트 사용 가능 필터로 호출한다") + fun shouldQueryAudiosForPointType() { + val port = FakeMainContentAllQueryPort() + val service = createService(port) + + val tab = service.getContents(type = "POINT", sort = "PRICE_LOW", dayOfWeek = null, page = 0, size = 20, member = null) + + assertEquals(MainContentAllType.POINT, tab.type) + assertEquals("audio", port.lastListKind) + assertEquals(ContentSort.PRICE_LOW, port.lastSort) + assertFalse(port.lastOnlyFree) + assertTrue(port.lastOnlyPointAvailable) + assertEquals(21, port.lastLimit) + assertEquals(20, tab.audios.size) + assertTrue(tab.hasNext) + assertEquals(emptyList(), tab.series) + } + + @Test + @DisplayName("SERIES는 요일을 전달하고 ORIGINAL은 original 필터와 dayOfWeek null을 전달한다") + fun shouldQuerySeriesByType() { + val seriesPort = FakeMainContentAllQueryPort() + val service = createService(seriesPort, lang = Lang.JA) + + val seriesTab = service.getContents("SERIES", "POPULAR", "MON", 0, 20, null) + + assertEquals(MainContentAllType.SERIES, seriesTab.type) + assertEquals("series", seriesPort.lastListKind) + assertEquals(SeriesPublishedDaysOfWeek.MON, seriesPort.lastDayOfWeek) + assertEquals("ja", seriesPort.lastLocale) + assertFalse(seriesPort.lastOnlyOriginal) + + val originalPort = FakeMainContentAllQueryPort() + val originalService = createService(originalPort) + + val originalTab = originalService.getContents("ORIGINAL", "POPULAR", "MON", 0, 20, null) + + assertEquals(MainContentAllType.ORIGINAL, originalTab.type) + assertEquals("series", originalPort.lastListKind) + assertEquals(null, originalPort.lastDayOfWeek) + assertTrue(originalPort.lastOnlyOriginal) + } + + @Test + @DisplayName("비회원은 성인 콘텐츠 비노출로 조회하고 회원은 preference 결과를 전달한다") + fun shouldPassAdultVisibilityByMember() { + val anonymousPort = FakeMainContentAllQueryPort() + createService(anonymousPort).getContents("AUDIO", null, null, null, null, null) + + assertEquals(null, anonymousPort.lastMemberId) + assertFalse(anonymousPort.lastCanViewAdultContent) + + val member = Member( + email = "viewer@test.com", + password = "password", + nickname = "viewer", + role = MemberRole.USER + ).apply { id = 10L } + val memberPort = FakeMainContentAllQueryPort() + val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) + + createService(memberPort, preferenceService).getContents("AUDIO", null, null, null, null, member) + + assertEquals(10L, memberPort.lastMemberId) + assertTrue(memberPort.lastCanViewAdultContent) + Mockito.verify(preferenceService).canViewAdultContent(member) + } + + private fun createService( + port: MainContentAllQueryPort, + preferenceService: MemberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java), + lang: Lang = Lang.EN + ): MainContentAllQueryService { + val langContext = LangContext() + langContext.setLang(lang) + return MainContentAllQueryService( + queryPort = port, + memberContentPreferenceService = preferenceService, + queryPolicy = MainContentAllQueryPolicy(), + langContext = langContext + ) + } +} + +private class FakeMainContentAllQueryPort : MainContentAllQueryPort { + var lastListKind: String? = null + var lastMemberId: Long? = null + var lastCanViewAdultContent: Boolean = false + var lastSort: ContentSort? = null + var lastOffset: Long? = null + var lastLimit: Int? = null + var lastOnlyFree: Boolean = false + var lastOnlyPointAvailable: Boolean = false + var lastOnlyOriginal: Boolean = false + var lastDayOfWeek: SeriesPublishedDaysOfWeek? = null + var lastLocale: String? = null + + override fun countAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyFree: Boolean, + onlyPointAvailable: Boolean + ): Int { + lastMemberId = memberId + lastCanViewAdultContent = canViewAdultContent + lastOnlyFree = onlyFree + lastOnlyPointAvailable = onlyPointAvailable + return 30 + } + + override fun findAudios( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyFree: Boolean, + onlyPointAvailable: Boolean + ): List { + lastListKind = "audio" + lastMemberId = memberId + lastCanViewAdultContent = canViewAdultContent + lastSort = sort + lastOffset = offset + lastLimit = limit + lastOnlyFree = onlyFree + lastOnlyPointAvailable = onlyPointAvailable + return (1L..limit.toLong()).map { id -> + MainContentAllAudio( + audioContentId = id, + title = "audio-$id", + imageUrl = null, + price = 0, + isAdult = false, + isPointAvailable = true, + isFirstContent = id == 1L, + isOriginalSeries = false, + creatorNickname = "creator" + ) + } + } + + override fun countSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + onlyOriginal: Boolean, + dayOfWeek: SeriesPublishedDaysOfWeek? + ): Int { + lastMemberId = memberId + lastCanViewAdultContent = canViewAdultContent + lastOnlyOriginal = onlyOriginal + lastDayOfWeek = dayOfWeek + return 10 + } + + override fun findSeries( + memberId: Long?, + canViewAdultContent: Boolean, + now: LocalDateTime, + sort: ContentSort, + offset: Long, + limit: Int, + onlyOriginal: Boolean, + dayOfWeek: SeriesPublishedDaysOfWeek?, + locale: String + ): List { + lastListKind = "series" + lastMemberId = memberId + lastCanViewAdultContent = canViewAdultContent + lastSort = sort + lastOffset = offset + lastLimit = limit + lastOnlyOriginal = onlyOriginal + lastDayOfWeek = dayOfWeek + lastLocale = locale + return listOf( + MainContentAllSeries( + seriesId = 1L, + title = "series", + coverImageUrl = null, + creatorNickname = "creator", + isOriginal = onlyOriginal, + isAdult = false + ) + ) + } +}