test #426
@@ -0,0 +1,52 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class CreatorChannelCommunityQueryPolicy {
|
||||
fun createPage(page: Int?, size: Int?): CreatorChannelPage {
|
||||
return CreatorChannelPage(
|
||||
page = page?.coerceAtLeast(MIN_PAGE) ?: DEFAULT_PAGE,
|
||||
size = size?.coerceIn(MIN_PAGE_SIZE, MAX_PAGE_SIZE) ?: DEFAULT_PAGE_SIZE
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> limitItems(fetched: List<T>, page: CreatorChannelPage): List<T> {
|
||||
return fetched.take(page.size)
|
||||
}
|
||||
|
||||
fun hasNext(fetched: List<*>, page: CreatorChannelPage): Boolean {
|
||||
return fetched.size > page.size
|
||||
}
|
||||
|
||||
fun maskPaidContent(
|
||||
content: String,
|
||||
price: Int,
|
||||
isCreatorSelf: Boolean,
|
||||
existOrdered: Boolean
|
||||
): String {
|
||||
if (price <= 0 || isCreatorSelf || existOrdered) {
|
||||
return content
|
||||
}
|
||||
|
||||
val codePointCount = content.codePointCount(0, content.length)
|
||||
val visibleCodePointCount = if (codePointCount > PAID_CONTENT_PREVIEW_CODE_POINTS) {
|
||||
PAID_CONTENT_PREVIEW_CODE_POINTS
|
||||
} else {
|
||||
codePointCount / 2
|
||||
}
|
||||
val endIndex = content.offsetByCodePoints(0, visibleCodePointCount)
|
||||
return content.substring(0, endIndex) + PAID_CONTENT_MASK_SUFFIX
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_PAGE = 0
|
||||
private const val DEFAULT_PAGE_SIZE = 20
|
||||
private const val MIN_PAGE = 0
|
||||
private const val MIN_PAGE_SIZE = 20
|
||||
private const val MAX_PAGE_SIZE = 50
|
||||
private const val PAID_CONTENT_PREVIEW_CODE_POINTS = 15
|
||||
private const val PAID_CONTENT_MASK_SUFFIX = "..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.live.domain.CreatorChannelPage
|
||||
import java.time.LocalDateTime
|
||||
|
||||
data class CreatorChannelCommunityTab(
|
||||
val communityPostCount: Int,
|
||||
val communityPosts: List<CreatorChannelCommunityPost>,
|
||||
val page: CreatorChannelPage,
|
||||
val hasNext: Boolean
|
||||
)
|
||||
|
||||
data class CreatorChannelCommunityPost(
|
||||
val postId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileUrl: String,
|
||||
val imageUrl: String?,
|
||||
val audioUrl: String?,
|
||||
val content: String,
|
||||
val price: Int,
|
||||
val createdAt: LocalDateTime,
|
||||
val existOrdered: Boolean,
|
||||
val isCommentAvailable: Boolean,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val isPinned: Boolean
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.port.out
|
||||
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import java.time.LocalDateTime
|
||||
|
||||
interface CreatorChannelCommunityQueryPort {
|
||||
fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelCommunityCreatorRecord?
|
||||
|
||||
fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean
|
||||
|
||||
fun countCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
canViewAdultContent: Boolean
|
||||
): Int
|
||||
|
||||
fun findCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
canViewAdultContent: Boolean,
|
||||
offset: Long,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPostRecord>
|
||||
|
||||
fun findHomeCommunityPosts(
|
||||
creatorId: Long,
|
||||
viewerId: Long,
|
||||
isPinned: Boolean,
|
||||
canViewAdultContent: Boolean,
|
||||
limit: Int
|
||||
): List<CreatorChannelCommunityPostRecord>
|
||||
}
|
||||
|
||||
data class CreatorChannelCommunityCreatorRecord(
|
||||
val creatorId: Long,
|
||||
val role: MemberRole,
|
||||
val nickname: String
|
||||
)
|
||||
|
||||
data class CreatorChannelCommunityPostRecord(
|
||||
val postId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfilePath: String?,
|
||||
val imagePath: String?,
|
||||
val audioPath: String?,
|
||||
val content: String,
|
||||
val price: Int,
|
||||
val createdAt: LocalDateTime,
|
||||
val existOrdered: Boolean,
|
||||
val isCommentAvailable: Boolean,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
val isPinned: Boolean
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
package kr.co.vividnext.sodalive.v2.creator.channel.community.domain
|
||||
|
||||
import kr.co.vividnext.sodalive.member.MemberRole
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityCreatorRecord
|
||||
import kr.co.vividnext.sodalive.v2.creator.channel.community.port.out.CreatorChannelCommunityPostRecord
|
||||
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 java.time.LocalDateTime
|
||||
|
||||
class CreatorChannelCommunityQueryPolicyTest {
|
||||
private val policy = CreatorChannelCommunityQueryPolicy()
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 page 정책은 null 요청을 기본값으로 fallback하고 fetch limit을 계산한다")
|
||||
fun shouldFallbackNullPageAndSizeForCommunityTab() {
|
||||
val page = policy.createPage(page = null, size = null)
|
||||
|
||||
assertEquals(0, page.page)
|
||||
assertEquals(20, page.size)
|
||||
assertEquals(0L, page.offset)
|
||||
assertEquals(21, page.fetchLimit)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 page 정책은 최소/최대 범위로 fallback하고 fetch limit을 계산한다")
|
||||
fun shouldFallbackPageAndSizeForCommunityTab() {
|
||||
val minimumPage = policy.createPage(page = -1, size = 10)
|
||||
val maximumPage = policy.createPage(page = 2, size = 100)
|
||||
|
||||
assertEquals(0, minimumPage.page)
|
||||
assertEquals(20, minimumPage.size)
|
||||
assertEquals(0L, minimumPage.offset)
|
||||
assertEquals(21, minimumPage.fetchLimit)
|
||||
assertEquals(2, maximumPage.page)
|
||||
assertEquals(50, maximumPage.size)
|
||||
assertEquals(100L, maximumPage.offset)
|
||||
assertEquals(51, maximumPage.fetchLimit)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 목록 정책은 요청 size만 남기고 다음 페이지 여부를 계산한다")
|
||||
fun shouldLimitItemsAndCalculateHasNext() {
|
||||
val page = policy.createPage(page = 0, size = 20)
|
||||
val fetched = (1..21).toList()
|
||||
|
||||
val items = policy.limitItems(fetched, page)
|
||||
|
||||
assertEquals((1..20).toList(), items)
|
||||
assertTrue(policy.hasNext(fetched, page))
|
||||
assertFalse(policy.hasNext((1..20).toList(), page))
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("유료 커뮤니티 본문은 접근 권한이 없으면 code point 기준으로 마스킹한다")
|
||||
fun shouldMaskPaidContentWhenViewerCannotAccess() {
|
||||
assertEquals(
|
||||
"123456789012345...",
|
||||
policy.maskPaidContent(
|
||||
content = "1234567890123456",
|
||||
price = 100,
|
||||
isCreatorSelf = false,
|
||||
existOrdered = false
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
"1234567...",
|
||||
policy.maskPaidContent(
|
||||
content = "123456789012345",
|
||||
price = 100,
|
||||
isCreatorSelf = false,
|
||||
existOrdered = false
|
||||
)
|
||||
)
|
||||
assertEquals(
|
||||
"가나다라마바사아자차카타파하🙂...",
|
||||
policy.maskPaidContent(
|
||||
content = "가나다라마바사아자차카타파하🙂끝",
|
||||
price = 100,
|
||||
isCreatorSelf = false,
|
||||
existOrdered = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("무료 게시글, 작성자 본인, 구매자는 유료 본문 원문을 볼 수 있다")
|
||||
fun shouldReturnOriginalContentWhenViewerCanAccess() {
|
||||
assertEquals(
|
||||
"free content",
|
||||
policy.maskPaidContent("free content", price = 0, isCreatorSelf = false, existOrdered = false)
|
||||
)
|
||||
assertEquals(
|
||||
"creator content",
|
||||
policy.maskPaidContent("creator content", price = 100, isCreatorSelf = true, existOrdered = false)
|
||||
)
|
||||
assertEquals(
|
||||
"ordered content",
|
||||
policy.maskPaidContent("ordered content", price = 100, isCreatorSelf = false, existOrdered = true)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("커뮤니티 탭 domain model과 port record는 Phase 1 계약 필드를 유지한다")
|
||||
fun shouldKeepDomainAndPortContract() {
|
||||
val createdAt = LocalDateTime.of(2026, 6, 21, 10, 0)
|
||||
val page = policy.createPage(page = 0, size = 20)
|
||||
val tab = CreatorChannelCommunityTab(
|
||||
communityPostCount = 1,
|
||||
communityPosts = listOf(
|
||||
CreatorChannelCommunityPost(
|
||||
postId = 10L,
|
||||
creatorId = 1L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfileUrl = "https://cdn.test/profile.png",
|
||||
imageUrl = null,
|
||||
audioUrl = null,
|
||||
content = "content",
|
||||
price = 100,
|
||||
createdAt = createdAt,
|
||||
existOrdered = false,
|
||||
isCommentAvailable = true,
|
||||
likeCount = 2,
|
||||
commentCount = 3,
|
||||
isPinned = true
|
||||
)
|
||||
),
|
||||
page = page,
|
||||
hasNext = false
|
||||
)
|
||||
val creatorRecord = CreatorChannelCommunityCreatorRecord(
|
||||
creatorId = 1L,
|
||||
role = MemberRole.CREATOR,
|
||||
nickname = "creator"
|
||||
)
|
||||
val postRecord = CreatorChannelCommunityPostRecord(
|
||||
postId = 10L,
|
||||
creatorId = 1L,
|
||||
creatorNickname = "creator",
|
||||
creatorProfilePath = null,
|
||||
imagePath = null,
|
||||
audioPath = null,
|
||||
content = "content",
|
||||
price = 100,
|
||||
createdAt = createdAt,
|
||||
existOrdered = false,
|
||||
isCommentAvailable = true,
|
||||
likeCount = 2,
|
||||
commentCount = 3,
|
||||
isPinned = true
|
||||
)
|
||||
|
||||
assertEquals(1, tab.communityPostCount)
|
||||
assertEquals("creator", tab.communityPosts.first().creatorNickname)
|
||||
assertEquals(page, tab.page)
|
||||
assertFalse(tab.hasNext)
|
||||
assertEquals(MemberRole.CREATOR, creatorRecord.role)
|
||||
assertNull(postRecord.imagePath)
|
||||
assertTrue(postRecord.isPinned)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user