diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt index f1dea6c3..8b74fae1 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/extensions/LocalDateTimeExtensions.kt @@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.extensions import java.time.Duration import java.time.LocalDateTime import java.time.ZoneId +import java.time.ZoneOffset private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC") @@ -26,3 +27,7 @@ fun LocalDateTime.convertToUtc(timeZone: ZoneId = DEFAULT_KST_ZONE_ID): LocalDat .withZoneSameInstant(UTC_ZONE_ID) .toLocalDateTime() } + +fun LocalDateTime.toUtcIso(): String { + return atOffset(ZoneOffset.UTC).toInstant().toString() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt new file mode 100644 index 00000000..aea1ac8f --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt @@ -0,0 +1,32 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.application + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto.CreatorChannelCommunityTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional(readOnly = true) +class CreatorChannelCommunityFacade( + private val creatorChannelCommunityQueryService: CreatorChannelCommunityQueryService +) { + fun getCommunityTab( + creatorId: Long, + viewer: Member, + page: Int?, + size: Int?, + now: LocalDateTime = LocalDateTime.now() + ): CreatorChannelCommunityTabResponse { + return CreatorChannelCommunityTabResponse.from( + creatorChannelCommunityQueryService.getCommunityTab( + creatorId = creatorId, + viewer = viewer, + page = page, + size = size, + now = now + ) + ) + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt new file mode 100644 index 00000000..d2920b97 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/dto/CreatorChannelCommunityTabResponse.kt @@ -0,0 +1,67 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import kr.co.vividnext.sodalive.extensions.toUtcIso +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab + +data class CreatorChannelCommunityTabResponse( + val communityPostCount: Int, + val communityPosts: List, + val page: Int, + val size: Int, + @JsonProperty("hasNext") + val hasNext: Boolean +) { + companion object { + fun from(tab: CreatorChannelCommunityTab): CreatorChannelCommunityTabResponse { + return CreatorChannelCommunityTabResponse( + communityPostCount = tab.communityPostCount, + communityPosts = tab.communityPosts.map(CreatorChannelCommunityPostResponse::from), + page = tab.page.page, + size = tab.page.size, + hasNext = tab.hasNext + ) + } + } +} + +data class CreatorChannelCommunityPostResponse( + val postId: Long, + val creatorId: Long, + val creatorNickname: String, + val creatorProfileUrl: String, + val createdAtUtc: String, + val content: String, + val imageUrl: String?, + val audioUrl: String?, + val price: Int, + @JsonProperty("isCommentAvailable") + val isCommentAvailable: Boolean, + val existOrdered: Boolean, + val likeCount: Int, + val commentCount: Int, + @JsonProperty("isPinned") + val isPinned: Boolean +) { + companion object { + fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse { + return CreatorChannelCommunityPostResponse( + postId = post.postId, + creatorId = post.creatorId, + creatorNickname = post.creatorNickname, + creatorProfileUrl = post.creatorProfileUrl, + createdAtUtc = post.createdAt.toUtcIso(), + content = post.content, + imageUrl = post.imageUrl, + audioUrl = post.audioUrl, + price = post.price, + isCommentAvailable = post.isCommentAvailable, + existOrdered = post.existOrdered, + likeCount = post.likeCount, + commentCount = post.commentCount, + isPinned = post.isPinned + ) + } + } +} diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt new file mode 100644 index 00000000..9eea1af3 --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacadeTest.kt @@ -0,0 +1,138 @@ +package kr.co.vividnext.sodalive.v2.api.creator.channel.community.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.community.dto.CreatorChannelCommunityTabResponse +import kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryService +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityPost +import kr.co.vividnext.sodalive.v2.creator.channel.community.domain.CreatorChannelCommunityTab +import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage +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 CreatorChannelCommunityFacadeTest { + @Test + @DisplayName("커뮤니티 탭 응답 DTO는 domain tab 값을 공개 응답 필드로 그대로 매핑한다") + fun shouldMapCommunityTabDomainToPublicResponse() { + val response = CreatorChannelCommunityTabResponse.from(createTab()) + + assertEquals(2, response.communityPostCount) + assertEquals(101L, response.communityPosts.first().postId) + assertEquals(1L, response.communityPosts.first().creatorId) + assertEquals("creator", response.communityPosts.first().creatorNickname) + assertEquals("https://cdn.test/profile.png", response.communityPosts.first().creatorProfileUrl) + assertEquals("2026-06-21T03:30:00Z", response.communityPosts.first().createdAtUtc) + assertEquals("paid content", response.communityPosts.first().content) + assertEquals("https://cdn.test/image.png", response.communityPosts.first().imageUrl) + assertEquals("https://signed.test/audio", response.communityPosts.first().audioUrl) + assertEquals(100, response.communityPosts.first().price) + assertTrue(response.communityPosts.first().isCommentAvailable) + assertTrue(response.communityPosts.first().existOrdered) + assertEquals(7, response.communityPosts.first().likeCount) + assertEquals(3, response.communityPosts.first().commentCount) + assertTrue(response.communityPosts.first().isPinned) + assertNull(response.communityPosts.last().imageUrl) + assertNull(response.communityPosts.last().audioUrl) + 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["communityPosts"][0]["isCommentAvailable"].asBoolean()) + assertTrue(json["communityPosts"][0]["isPinned"].asBoolean()) + } + + @Test + @DisplayName("커뮤니티 탭 facade는 query service 결과를 공개 응답 DTO로 변환한다") + fun shouldMapCommunityTabQueryResultToPublicResponse() { + val service = Mockito.mock(CreatorChannelCommunityQueryService::class.java) + val facade = CreatorChannelCommunityFacade(service) + val viewer = createMember(id = 10L) + val now = LocalDateTime.of(2026, 6, 21, 12, 0) + Mockito.doReturn(createTab()).`when`(service).getCommunityTab( + creatorId = 1L, + viewer = viewer, + page = -1, + size = 100, + now = now + ) + + val response = facade.getCommunityTab( + creatorId = 1L, + viewer = viewer, + page = -1, + size = 100, + now = now + ) + + assertEquals(2, response.communityPostCount) + assertEquals(101L, response.communityPosts.first().postId) + assertEquals("https://cdn.test/profile.png", response.communityPosts.first().creatorProfileUrl) + assertTrue(response.communityPosts.first().existOrdered) + assertFalse(response.communityPosts.last().isCommentAvailable) + assertEquals(1, response.page) + assertEquals(20, response.size) + assertTrue(response.hasNext) + } + + 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(): CreatorChannelCommunityTab { + return CreatorChannelCommunityTab( + communityPostCount = 2, + communityPosts = listOf( + CreatorChannelCommunityPost( + postId = 101L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + imageUrl = "https://cdn.test/image.png", + audioUrl = "https://signed.test/audio", + content = "paid content", + price = 100, + createdAt = LocalDateTime.of(2026, 6, 21, 3, 30), + existOrdered = true, + isCommentAvailable = true, + likeCount = 7, + commentCount = 3, + isPinned = true + ), + CreatorChannelCommunityPost( + postId = 102L, + creatorId = 1L, + creatorNickname = "creator", + creatorProfileUrl = "https://cdn.test/profile.png", + imageUrl = null, + audioUrl = null, + content = "masked...", + price = 50, + createdAt = LocalDateTime.of(2026, 6, 21, 3, 0), + existOrdered = false, + isCommentAvailable = false, + likeCount = 1, + commentCount = 0, + isPinned = false + ) + ), + page = CreatorChannelPage(page = 1, size = 20), + hasNext = true + ) + } +}