From dd68e6462897d58747324f4b8b708ed77d0f29a0 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 20 Jun 2026 04:35:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(creator-channel):=20=EC=8B=9C=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=ED=83=AD=20=EC=9D=91=EB=8B=B5=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/CreatorChannelSeriesFacade.kt | 34 +++++ .../dto/CreatorChannelSeriesTabResponse.kt | 64 +++++++++ .../CreatorChannelSeriesFacadeTest.kt | 133 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt new file mode 100644 index 00000000..5fee5eed --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacade.kt @@ -0,0 +1,34 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelSeriesFacade( + private val creatorChannelSeriesQueryService: CreatorChannelSeriesQueryService +) { + fun getSeriesTab( + creatorId: Long, + viewer: Member, + sort: String?, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelSeriesTabResponse { + return CreatorChannelSeriesTabResponse.from( + creatorChannelSeriesQueryService.getSeriesTab( + creatorId = creatorId, + viewer = viewer, + sort = sort, + page = page, + size = size, + now = now + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt new file mode 100644 index 00000000..a06ab270 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/dto/CreatorChannelSeriesTabResponse.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab + +data class CreatorChannelSeriesTabResponse( + val seriesCount: Int, + val series: List, + val sort: ContentSort, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelSeriesTab): CreatorChannelSeriesTabResponse { + return CreatorChannelSeriesTabResponse( + seriesCount = tab.seriesCount, + series = tab.series.map(CreatorChannelSeriesResponse::from), + sort = tab.sort, + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelSeriesResponse( + val seriesId: Long, + val title: String, + val coverImageUrl: String?, + val publishedDaysOfWeek: String, + @JsonProperty("isOriginal") + val isOriginal: Boolean, + @JsonProperty("isAdult") + val isAdult: Boolean, + @JsonProperty("isProceeding") + val isProceeding: Boolean, + val contentCount: Int, + val purchasedContentCount: Int?, + val paidContentCount: Int?, + val purchasedPaidContentRate: Int? +) { + companion object { + fun from(series: CreatorChannelSeries): CreatorChannelSeriesResponse { + return CreatorChannelSeriesResponse( + seriesId = series.seriesId, + title = series.title, + coverImageUrl = series.coverImageUrl, + publishedDaysOfWeek = series.publishedDaysOfWeek, + isOriginal = series.isOriginal, + isAdult = series.isAdult, + isProceeding = series.isProceeding, + contentCount = series.contentCount, + purchasedContentCount = series.purchasedContentCount, + paidContentCount = series.paidContentCount, + purchasedPaidContentRate = series.purchasedPaidContentRate + ) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt new file mode 100644 index 00000000..edc813f0 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/application/CreatorChannelSeriesFacadeTest.kt @@ -0,0 +1,133 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.series.application + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.v2.api.creator.channel.series.dto.CreatorChannelSeriesTabResponse +import kr.co.vividnext.sodalive.v2.common.domain.ContentSort +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +import kr.co.vividnext.sodalive.v2.creator.channel.series.application.CreatorChannelSeriesQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeries +import kr.co.vividnext.sodalive.v2.creator.channel.series.domain.CreatorChannelSeriesTab +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +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 CreatorChannelSeriesFacadeTest { + @Test + @DisplayName("시리즈 탭 응답 DTO는 domain tab 값을 공개 응답 필드로 그대로 매핑한다") + fun shouldMapSeriesTabDomainToPublicResponse() { + val response = CreatorChannelSeriesTabResponse.from(createTab()) + + assertEquals(2, response.seriesCount) + assertEquals(101L, response.series.first().seriesId) + assertEquals("series", response.series.first().title) + assertEquals("https://cdn.test/cover.png", response.series.first().coverImageUrl) + assertEquals("Every Mon, Thu", response.series.first().publishedDaysOfWeek) + assertTrue(response.series.first().isOriginal) + assertFalse(response.series.first().isAdult) + assertTrue(response.series.first().isProceeding) + assertEquals(5, response.series.first().contentCount) + assertEquals(3, response.series.first().purchasedContentCount) + assertEquals(4, response.series.first().paidContentCount) + assertEquals(75, response.series.first().purchasedPaidContentRate) + assertEquals(ContentSort.OWNED, response.sort) + assertEquals(1, response.page) + assertEquals(20, response.size) + assertTrue(response.hasNext) + + val mapper = ObjectMapper().registerModule(KotlinModule.Builder().build()) + val json = mapper.readTree(mapper.writeValueAsString(response)) + assertTrue(json["hasNext"].asBoolean()) + assertTrue(json["series"][0]["isOriginal"].asBoolean()) + assertFalse(json["series"][0]["isAdult"].asBoolean()) + assertTrue(json["series"][0]["isProceeding"].asBoolean()) + } + + @Test + @DisplayName("시리즈 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다") + fun shouldMapSeriesTabQueryResultToPublicResponse() { + val service = Mockito.mock(CreatorChannelSeriesQueryService::class.java) + val facade = CreatorChannelSeriesFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 20, 10, 0) + Mockito.doReturn(createTab()).`when`(service).getSeriesTab( + creatorId = 1L, + viewer = viewer, + sort = "OWNED", + page = 1, + size = 20, + now = now + ) + + val response = facade.getSeriesTab( + creatorId = 1L, + viewer = viewer, + sort = "OWNED", + page = 1, + size = 20, + now = now + ) + + assertEquals(2, response.seriesCount) + assertEquals(101L, response.series.first().seriesId) + assertEquals(75, response.series.first().purchasedPaidContentRate) + assertEquals(ContentSort.OWNED, response.sort) + assertEquals(1, response.page) + assertEquals(20, response.size) + assertTrue(response.hasNext) + assertNull(response.series.last().purchasedPaidContentRate) + } + + private fun createMember(id: Long): Member { + return Member( + email = "viewer$id@test.com", + password = "password", + nickname = "viewer$id", + role = MemberRole.USER + ).apply { this.id = id } + } + + private fun createTab(): CreatorChannelSeriesTab { + return CreatorChannelSeriesTab( + seriesCount = 2, + series = listOf( + CreatorChannelSeries( + seriesId = 101L, + title = "series", + coverImageUrl = "https://cdn.test/cover.png", + publishedDaysOfWeek = "Every Mon, Thu", + isOriginal = true, + isAdult = false, + isProceeding = true, + contentCount = 5, + purchasedContentCount = 3, + paidContentCount = 4, + purchasedPaidContentRate = 75 + ), + CreatorChannelSeries( + seriesId = 102L, + title = "creator series", + coverImageUrl = null, + publishedDaysOfWeek = "Random", + isOriginal = false, + isAdult = false, + isProceeding = false, + contentCount = 1, + purchasedContentCount = null, + paidContentCount = null, + purchasedPaidContentRate = null + ) + ), + sort = ContentSort.OWNED, + page = CreatorChannelPage(page = 1, size = 20), + hasNext = true + ) + } +}