diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt new file mode 100644 index 00000000..711a8657 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt @@ -0,0 +1,39 @@ +package kr.co.vividnext.sodalive.v2.home.following.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowing +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Clock +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class HomeFollowingQueryService( + private val queryPort: HomeFollowingQueryPort, + private val memberContentPreferenceService: MemberContentPreferenceService, + private val nowProvider: () -> LocalDateTime = { LocalDateTime.now(Clock.systemUTC()) } +) { + fun findHomeFollowing(member: Member): HomeFollowing { + val memberId = requireNotNull(member.id) + val canViewAdultContent = memberContentPreferenceService.canViewAdultContent(member) + val now = nowProvider() + + return HomeFollowing( + followingCreators = queryPort.findFollowingCreators(memberId, FOLLOWING_CREATORS_LIMIT), + onAirLives = queryPort.findOnAirLives(memberId, canViewAdultContent, ON_AIR_LIVES_LIMIT), + recentChats = emptyList(), + monthlySchedules = queryPort.findMonthlySchedules(memberId, canViewAdultContent, now, MONTHLY_SCHEDULES_LIMIT), + recentNews = queryPort.findRecentNews(memberId, canViewAdultContent, now, RECENT_NEWS_LIMIT) + ) + } + + companion object { + private const val FOLLOWING_CREATORS_LIMIT = 20 + private const val ON_AIR_LIVES_LIMIT = 10 + private const val MONTHLY_SCHEDULES_LIMIT = 3 + private const val RECENT_NEWS_LIMIT = 30 + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt new file mode 100644 index 00000000..ecaf237e --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt @@ -0,0 +1,137 @@ +package kr.co.vividnext.sodalive.v2.home.following.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceService +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingCreator +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingLive +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingNews +import kr.co.vividnext.sodalive.v2.home.following.domain.HomeFollowingSchedule +import kr.co.vividnext.sodalive.v2.home.following.port.out.HomeFollowingQueryPort +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 +import java.time.ZoneOffset +import java.util.TimeZone + +class HomeFollowingQueryServiceTest { + private val queryPort = RecordingHomeFollowingQueryPort() + private val memberContentPreferenceService = Mockito.mock(MemberContentPreferenceService::class.java) + private val now = LocalDateTime.of(2026, 6, 25, 12, 0) + private val service = HomeFollowingQueryService( + queryPort, + memberContentPreferenceService + ) { now } + + @Test + @DisplayName("팔로잉 탭 조회는 각 섹션의 기본 limit와 고정 now를 port에 전달한다") + fun shouldCallQueryPortWithDefaultLimitsAndNow() { + val member = member(10L) + Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) + + val home = service.findHomeFollowing(member) + + assertEquals(listOf(HomeFollowingCreator(1L, "creator", "profile")), home.followingCreators) + assertEquals(emptyList(), home.recentChats) + assertEquals(20, queryPort.followingCreatorsLimit) + assertEquals(10, queryPort.onAirLivesLimit) + assertEquals(3, queryPort.monthlySchedulesLimit) + assertEquals(30, queryPort.recentNewsLimit) + assertEquals(now, queryPort.monthlySchedulesNow) + assertEquals(now, queryPort.recentNewsNow) + } + + @Test + @DisplayName("성인 콘텐츠 노출 가능 여부는 On Air, 스케줄, 최근 소식 조회에 전달된다") + fun shouldPassAdultContentVisibilityToQueryPort() { + val member = member(11L) + Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(false) + + service.findHomeFollowing(member) + + assertEquals(false, queryPort.onAirCanViewAdultContent) + assertEquals(false, queryPort.monthlySchedulesCanViewAdultContent) + assertEquals(false, queryPort.recentNewsCanViewAdultContent) + assertEquals(11L, queryPort.memberId) + } + + @Test + @DisplayName("기본 now는 JVM 기본 timezone과 무관하게 UTC 기준으로 port에 전달된다") + fun shouldUseUtcNowRegardlessOfJvmDefaultTimezone() { + val originalTimeZone = TimeZone.getDefault() + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) + val defaultQueryPort = RecordingHomeFollowingQueryPort() + val defaultService = HomeFollowingQueryService(defaultQueryPort, memberContentPreferenceService) + val member = member(12L) + Mockito.`when`(memberContentPreferenceService.canViewAdultContent(member)).thenReturn(true) + val beforeUtc = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1) + + defaultService.findHomeFollowing(member) + + val afterUtc = LocalDateTime.now(ZoneOffset.UTC).plusSeconds(1) + val capturedNow = defaultQueryPort.recentNewsNow!! + assertFalse(capturedNow.isBefore(beforeUtc)) + assertFalse(capturedNow.isAfter(afterUtc)) + assertTrue(LocalDateTime.now().isAfter(capturedNow.plusHours(8))) + } finally { + TimeZone.setDefault(originalTimeZone) + } + } + + private fun member(id: Long): Member { + return Member(email = "member-$id@test.com", password = "password", nickname = "member-$id").apply { this.id = id } + } + + private class RecordingHomeFollowingQueryPort : HomeFollowingQueryPort { + var memberId: Long? = null + var followingCreatorsLimit: Int? = null + var onAirLivesLimit: Int? = null + var onAirCanViewAdultContent: Boolean? = null + var monthlySchedulesLimit: Int? = null + var monthlySchedulesNow: LocalDateTime? = null + var monthlySchedulesCanViewAdultContent: Boolean? = null + var recentNewsLimit: Int? = null + var recentNewsNow: LocalDateTime? = null + var recentNewsCanViewAdultContent: Boolean? = null + + override fun findFollowingCreators(memberId: Long, limit: Int): List { + this.memberId = memberId + followingCreatorsLimit = limit + return listOf(HomeFollowingCreator(1L, "creator", "profile")) + } + + override fun findOnAirLives(memberId: Long, canViewAdultContent: Boolean, limit: Int): List { + onAirCanViewAdultContent = canViewAdultContent + onAirLivesLimit = limit + return emptyList() + } + + override fun findMonthlySchedules( + memberId: Long, + canViewAdultContent: Boolean, + now: LocalDateTime, + limit: Int + ): List { + monthlySchedulesCanViewAdultContent = canViewAdultContent + monthlySchedulesNow = now + monthlySchedulesLimit = limit + return emptyList() + } + + override fun findRecentNews( + memberId: Long, + canViewAdultContent: Boolean, + nowUtc: LocalDateTime, + limit: Int + ): List { + recentNewsCanViewAdultContent = canViewAdultContent + recentNewsNow = nowUtc + recentNewsLimit = limit + return emptyList() + } + } +}