From 99f61ed13ec771cc829836bba58ff142cb82ba67 Mon Sep 17 00:00:00 2001 From: Klaus Date: Sat, 27 Jun 2026 00:06:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(home-live):=20=ED=98=84=EC=9E=AC=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=20=EC=A4=91=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?facade=EB=A5=BC=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 --- .../live/application/HomeOnAirLiveFacade.kt | 64 +++++++++++++ .../application/HomeOnAirLiveFacadeTest.kt | 91 +++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt new file mode 100644 index 00000000..3600b29e --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt @@ -0,0 +1,64 @@ +package kr.co.vividnext.sodalive.v2.api.home.live.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLivePageResponse +import kr.co.vividnext.sodalive.v2.api.home.live.dto.HomeOnAirLiveResponse +import kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryService +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneOffset + +@Component +class HomeOnAirLiveFacade( + private val queryService: HomeRecommendationQueryService, + private val memberContentPreferenceService: MemberContentPreferenceService, + @Value("\${cloud.aws.cloud-front.host}") + private val cloudFrontHost: String +) { + fun getOnAirLives(member: Member, page: Int): HomeOnAirLivePageResponse { + val normalizedPage = page.coerceIn(0, MAX_PAGE) + val fetched = queryService.findLiveRecommendations( + offset = normalizedPage * PAGE_SIZE, + limit = PAGE_SIZE + 1, + memberId = member.id, + includeAdultLives = memberContentPreferenceService.canViewAdultContent(member) + ) + val items = fetched.take(PAGE_SIZE).map { it.toResponse() } + + return HomeOnAirLivePageResponse( + items = items, + page = normalizedPage, + size = PAGE_SIZE, + hasNext = fetched.size > PAGE_SIZE + ) + } + + private fun HomeLiveRecommendationRecord.toResponse() = HomeOnAirLiveResponse( + roomId = liveRoomId, + creatorNickname = creatorNickname, + creatorProfileImage = profileImageUrl(creatorProfileImage), + title = title, + price = price, + beginDateTimeUtc = beginDateTime.toUtcIso() + ) + + private fun profileImageUrl(path: String?): String { + return imageUrl(path) ?: "$cloudFrontHost/profile/default-profile.png" + } + + private fun imageUrl(path: String?): String? { + return if (path.isNullOrBlank()) null else "$cloudFrontHost/$path" + } + + private fun LocalDateTime.toUtcIso(): String { + return atOffset(ZoneOffset.UTC).toInstant().toString() + } + + companion object { + private const val PAGE_SIZE = 20 + private const val MAX_PAGE = 10_000 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt new file mode 100644 index 00000000..85e6724f --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacadeTest.kt @@ -0,0 +1,91 @@ +package kr.co.vividnext.sodalive.v2.api.home.live.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.recommendation.application.HomeRecommendationQueryService +import kr.co.vividnext.sodalive.v2.recommendation.port.out.HomeLiveRecommendationRecord +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito +import java.time.LocalDateTime + +class HomeOnAirLiveFacadeTest { + private val queryService = Mockito.mock(HomeRecommendationQueryService::class.java) + private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val facade = HomeOnAirLiveFacade(queryService, preferenceService, "https://cdn.test") + + @Test + fun shouldReturnFixedSizePageAndHasNext() { + val member = createMember(100L) + Mockito.doReturn(true).`when`(preferenceService).canViewAdultContent(member) + Mockito.doReturn((1L..21L).map { record(it) }).`when`(queryService).findLiveRecommendations( + eqValue(0), + eqValue(21), + eqValue(member.id), + eqValue(true) + ) + + val response = facade.getOnAirLives(member, page = 0) + + assertEquals(0, response.page) + assertEquals(20, response.size) + assertEquals(true, response.hasNext) + assertEquals(20, response.items.size) + Mockito.verify(queryService).findLiveRecommendations(eqValue(0), eqValue(21), eqValue(member.id), eqValue(true)) + } + + @Test + fun shouldUseDefaultProfileImageWhenCreatorProfileImageIsBlank() { + val member = createMember(100L) + Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member) + Mockito.doReturn(listOf(record(1L, creatorProfileImage = null))).`when`(queryService).findLiveRecommendations( + eqValue(0), + eqValue(21), + eqValue(member.id), + eqValue(false) + ) + + val response = facade.getOnAirLives(member, page = 0) + + assertEquals("https://cdn.test/profile/default-profile.png", response.items.single().creatorProfileImage) + } + + @Test + fun shouldMapBeginDateTimeToUtcIsoString() { + val member = createMember(100L) + Mockito.doReturn(false).`when`(preferenceService).canViewAdultContent(member) + Mockito.doReturn(listOf(record(1L, beginDateTime = LocalDateTime.of(2026, 6, 26, 12, 30)))).`when`(queryService) + .findLiveRecommendations(eqValue(0), eqValue(21), eqValue(member.id), eqValue(false)) + + val response = facade.getOnAirLives(member, page = 0) + + assertEquals("2026-06-26T12:30:00Z", response.items.single().beginDateTimeUtc) + } + + private fun record( + id: Long, + creatorProfileImage: String? = "profile.png", + beginDateTime: LocalDateTime = LocalDateTime.of(2026, 6, 26, 12, 30) + ) = HomeLiveRecommendationRecord( + liveRoomId = id, + creatorNickname = "creator-$id", + creatorProfileImage = creatorProfileImage, + title = "live-$id", + price = id.toInt(), + beginDateTime = beginDateTime + ) + + 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 eqValue(value: T): T { + return Mockito.eq(value) ?: value + } +}