feat(creator-channel): 커뮤니티 탭 응답 조립을 추가한다

This commit is contained in:
2026-06-22 00:01:45 +09:00
parent bd4e865f2e
commit e0e6b34d21
4 changed files with 242 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ package kr.co.vividnext.sodalive.extensions
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul") private val DEFAULT_KST_ZONE_ID: ZoneId = ZoneId.of("Asia/Seoul")
private val UTC_ZONE_ID: ZoneId = ZoneId.of("UTC") 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) .withZoneSameInstant(UTC_ZONE_ID)
.toLocalDateTime() .toLocalDateTime()
} }
fun LocalDateTime.toUtcIso(): String {
return atOffset(ZoneOffset.UTC).toInstant().toString()
}

View File

@@ -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
)
)
}
}

View File

@@ -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<CreatorChannelCommunityPostResponse>,
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
)
}
}
}

View File

@@ -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
)
}
}