test #426

Merged
klaus merged 415 commits from test into main 2026-06-27 00:35:30 +00:00
2 changed files with 374 additions and 2 deletions
Showing only changes of commit 2848f07573 - Show all commits

View File

@@ -1,14 +1,36 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.extensions.removeDeletedNicknamePrefix
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.common.domain.toCdnUrl
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalk
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkReply
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkTab
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
import org.springframework.beans.factory.ObjectProvider
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime import java.time.LocalDateTime
@Service @Service
@Transactional(readOnly = true) @Transactional(readOnly = true)
class CreatorChannelFanTalkQueryService { class CreatorChannelFanTalkQueryService(
private val queryPortProvider: ObjectProvider<CreatorChannelFanTalkQueryPort>,
private val queryPolicy: CreatorChannelFanTalkQueryPolicy,
private val messageSource: SodaMessageSource,
private val langContext: LangContext,
@Value("\${cloud.aws.cloud-front.host}")
private val cloudFrontHost: String
) {
fun getFanTalkTab( fun getFanTalkTab(
creatorId: Long, creatorId: Long,
viewer: Member, viewer: Member,
@@ -16,6 +38,79 @@ class CreatorChannelFanTalkQueryService {
size: Int?, size: Int?,
now: LocalDateTime = LocalDateTime.now() now: LocalDateTime = LocalDateTime.now()
): CreatorChannelFanTalkTab { ): CreatorChannelFanTalkTab {
throw UnsupportedOperationException("CreatorChannelFanTalkQueryService is implemented in Phase 3") val fanTalkPage = queryPolicy.createPage(page, size)
val queryPort = queryPortProvider.getObject()
val viewerId = viewer.id!!
val creator = queryPort.findCreator(creatorId, viewerId)
?: throw SodaException(messageKey = "member.validation.user_not_found")
if (queryPort.existsBlockedBetween(viewerId, creatorId)) {
val messageTemplate = messageSource
.getMessage("explorer.creator.blocked_access", langContext.lang)
.orEmpty()
throw SodaException(message = String.format(messageTemplate, creator.nickname))
} }
validateCreatorRole(creator)
val fetchedFanTalks = queryPort.findFanTalks(
creatorId = creatorId,
viewerId = viewerId,
offset = fanTalkPage.offset,
limit = fanTalkPage.fetchLimit
)
val fanTalkRecords = queryPolicy.limitItems(fetchedFanTalks, fanTalkPage)
val repliesByParentId = findRepliesByParentId(queryPort, creatorId, fanTalkRecords)
return CreatorChannelFanTalkTab(
fanTalkCount = queryPort.countFanTalks(creatorId, viewerId),
fanTalks = fanTalkRecords.map { it.toDomain(repliesByParentId[it.fanTalkId].orEmpty()) },
page = fanTalkPage,
hasNext = queryPolicy.hasNext(fetchedFanTalks, fanTalkPage)
)
}
private fun validateCreatorRole(creator: CreatorChannelFanTalkCreatorRecord) {
when (creator.role) {
MemberRole.CREATOR -> return
else -> throw SodaException(messageKey = "member.validation.creator_not_found")
}
}
private fun findRepliesByParentId(
queryPort: CreatorChannelFanTalkQueryPort,
creatorId: Long,
fanTalkRecords: List<CreatorChannelFanTalkRecord>
): Map<Long, List<CreatorChannelFanTalkReply>> {
val parentFanTalkIds = fanTalkRecords.map { it.fanTalkId }
if (parentFanTalkIds.isEmpty()) return emptyMap()
return queryPort.findCreatorReplies(creatorId, parentFanTalkIds)
.groupBy(
keySelector = { it.parentFanTalkId },
valueTransform = { it.toDomain() }
)
}
private fun CreatorChannelFanTalkRecord.toDomain(
creatorReplies: List<CreatorChannelFanTalkReply>
) = CreatorChannelFanTalk(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
createdAt = createdAt,
creatorReplies = creatorReplies
)
private fun CreatorChannelFanTalkReplyRecord.toDomain() = CreatorChannelFanTalkReply(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname.removeDeletedNicknamePrefix(),
writerProfileImageUrl = writerProfileImagePath.toCdnUrl(cloudFrontHost) ?: defaultProfileImageUrl(),
content = content,
createdAt = createdAt
)
private fun defaultProfileImageUrl(): String = "$cloudFrontHost/profile/default-profile.png"
} }

View File

@@ -0,0 +1,277 @@
package kr.co.vividnext.sodalive.v2.creator.channel.fantalk.application
import kr.co.vividnext.sodalive.common.SodaException
import kr.co.vividnext.sodalive.i18n.Lang
import kr.co.vividnext.sodalive.i18n.LangContext
import kr.co.vividnext.sodalive.i18n.SodaMessageSource
import kr.co.vividnext.sodalive.member.Member
import kr.co.vividnext.sodalive.member.MemberProvider
import kr.co.vividnext.sodalive.member.MemberRole
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.domain.CreatorChannelFanTalkQueryPolicy
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkCreatorRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkQueryPort
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkRecord
import kr.co.vividnext.sodalive.v2.creator.channel.fantalk.port.out.CreatorChannelFanTalkReplyRecord
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.ObjectProvider
import java.time.LocalDateTime
class CreatorChannelFanTalkQueryServiceTest {
@Test
@DisplayName("creatorId에 해당하는 회원이 없으면 user_not_found 예외를 던진다")
fun shouldThrowUserNotFoundWhenCreatorMemberDoesNotExist() {
val port = FakeCreatorChannelFanTalkQueryPort().apply { creator = null }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
}
assertEquals("member.validation.user_not_found", exception.messageKey)
}
@Test
@DisplayName("대상 회원 role이 CREATOR가 아니면 creator_not_found 예외를 던진다")
fun shouldThrowCreatorNotFoundWhenMemberIsNotCreator() {
val port = FakeCreatorChannelFanTalkQueryPort().apply { creator = creator?.copy(role = MemberRole.USER) }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
}
assertEquals("member.validation.creator_not_found", exception.messageKey)
}
@Test
@DisplayName("차단 관계가 있으면 기존 차단 메시지 예외를 던진다")
fun shouldThrowBlockedAccessWhenViewerAndTargetAreBlocked() {
val port = FakeCreatorChannelFanTalkQueryPort().apply { blocked = true }
val service = createService(port)
val viewer = createMember(id = 10L)
val exception = assertThrows(SodaException::class.java) {
service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
}
assertNull(exception.messageKey)
assertEquals("Channel access is restricted at creator's request.", exception.message)
}
@Test
@DisplayName("FanTalk 탭 서비스는 요청 fallback과 조회 컨텍스트를 port에 전달하고 탭을 조립한다")
fun shouldResolveRequestFallbacksAndAssembleFanTalkTab() {
val port = FakeCreatorChannelFanTalkQueryPort().apply {
fanTalkCount = 60
fanTalks = (1L..21L).map { fanTalkRecord(it) }
creatorReplies = listOf(
fanTalkReplyRecord(fanTalkId = 101L, parentFanTalkId = 1L),
fanTalkReplyRecord(fanTalkId = 102L, parentFanTalkId = 2L),
fanTalkReplyRecord(fanTalkId = 103L, parentFanTalkId = 21L)
)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getFanTalkTab(
creatorId = 1L,
viewer = viewer,
page = -1,
size = 10,
now = LocalDateTime.of(2026, 6, 21, 10, 0)
)
assertEquals(60, tab.fanTalkCount)
assertEquals(0, tab.page.page)
assertEquals(20, tab.page.size)
assertEquals(0L, port.listOffset)
assertEquals(21, port.listLimit)
assertEquals(20, tab.fanTalks.size)
assertTrue(tab.hasNext)
assertEquals(
(1L..20L).toList(),
port.replyParentFanTalkIds
)
assertEquals(101L, tab.fanTalks[0].creatorReplies.single().fanTalkId)
assertEquals(102L, tab.fanTalks[1].creatorReplies.single().fanTalkId)
assertEquals(emptyList<Long>(), tab.fanTalks[19].creatorReplies.map { it.fanTalkId })
}
@Test
@DisplayName("FanTalk 목록이 비어 있으면 답글 조회 없이 빈 목록과 hasNext=false를 반환한다")
fun shouldReturnEmptyFanTalksWithoutFindingReplies() {
val port = FakeCreatorChannelFanTalkQueryPort().apply {
fanTalkCount = 5
fanTalks = emptyList()
}
val service = createService(port)
val viewer = createMember(id = 10L)
val tab = service.getFanTalkTab(1L, viewer, 3, 20, LocalDateTime.of(2026, 6, 21, 10, 0))
assertEquals(5, tab.fanTalkCount)
assertEquals(emptyList<Long>(), tab.fanTalks.map { it.fanTalkId })
assertEquals(false, tab.hasNext)
assertNull(port.replyParentFanTalkIds)
}
@Test
@DisplayName("FanTalk과 creator reply 작성자의 프로필 URL과 탈퇴 닉네임 prefix를 변환한다")
fun shouldConvertWriterProfileUrlsAndDeletedNicknamePrefixes() {
val port = FakeCreatorChannelFanTalkQueryPort().apply {
fanTalks = listOf(
fanTalkRecord(
fanTalkId = 1L,
writerNickname = "deleted_fan",
writerProfileImagePath = "profile/fan.png"
),
fanTalkRecord(
fanTalkId = 2L,
writerNickname = "normal",
writerProfileImagePath = null
)
)
creatorReplies = listOf(
fanTalkReplyRecord(
fanTalkId = 101L,
parentFanTalkId = 1L,
writerNickname = "deleted_creator",
writerProfileImagePath = "https://images.test/creator.png"
),
fanTalkReplyRecord(
fanTalkId = 102L,
parentFanTalkId = 2L,
writerNickname = "creator",
writerProfileImagePath = " "
)
)
}
val service = createService(port)
val viewer = createMember(id = 10L)
val fanTalks = service.getFanTalkTab(1L, viewer, null, null, LocalDateTime.of(2026, 6, 21, 10, 0))
.fanTalks
assertEquals("fan", fanTalks[0].writerNickname)
assertEquals("https://cdn.test/profile/fan.png", fanTalks[0].writerProfileImageUrl)
assertEquals("creator", fanTalks[0].creatorReplies.single().writerNickname)
assertEquals("https://images.test/creator.png", fanTalks[0].creatorReplies.single().writerProfileImageUrl)
assertEquals("normal", fanTalks[1].writerNickname)
assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].writerProfileImageUrl)
assertEquals("https://cdn.test/profile/default-profile.png", fanTalks[1].creatorReplies.single().writerProfileImageUrl)
}
private fun createService(port: FakeCreatorChannelFanTalkQueryPort): CreatorChannelFanTalkQueryService {
val langContext = LangContext()
langContext.setLang(Lang.EN)
return CreatorChannelFanTalkQueryService(
queryPortProvider = FixedCreatorChannelFanTalkQueryPortProvider(port),
queryPolicy = CreatorChannelFanTalkQueryPolicy(),
messageSource = SodaMessageSource(),
langContext = langContext,
cloudFrontHost = "https://cdn.test"
)
}
private fun createMember(id: Long): Member {
return Member(
email = "member$id@test.com",
password = "password",
nickname = "member$id",
provider = MemberProvider.EMAIL
).apply { this.id = id }
}
}
private class FixedCreatorChannelFanTalkQueryPortProvider(
private val port: CreatorChannelFanTalkQueryPort
) : ObjectProvider<CreatorChannelFanTalkQueryPort> {
override fun getObject(vararg args: Any?): CreatorChannelFanTalkQueryPort = port
override fun getIfAvailable(): CreatorChannelFanTalkQueryPort = port
override fun getIfUnique(): CreatorChannelFanTalkQueryPort = port
override fun getObject(): CreatorChannelFanTalkQueryPort = port
}
private class FakeCreatorChannelFanTalkQueryPort : CreatorChannelFanTalkQueryPort {
var creator: CreatorChannelFanTalkCreatorRecord? = CreatorChannelFanTalkCreatorRecord(
creatorId = 1L,
role = MemberRole.CREATOR,
nickname = "creator"
)
var blocked = false
var fanTalkCount = 1
var fanTalks = listOf(fanTalkRecord(1L))
var creatorReplies = emptyList<CreatorChannelFanTalkReplyRecord>()
var listOffset: Long? = null
var listLimit: Int? = null
var replyParentFanTalkIds: List<Long>? = null
override fun findCreator(creatorId: Long, viewerId: Long?): CreatorChannelFanTalkCreatorRecord? = creator
override fun existsBlockedBetween(viewerId: Long, creatorId: Long): Boolean = blocked
override fun countFanTalks(creatorId: Long, viewerId: Long): Int = fanTalkCount
override fun findFanTalks(
creatorId: Long,
viewerId: Long,
offset: Long,
limit: Int
): List<CreatorChannelFanTalkRecord> {
listOffset = offset
listLimit = limit
return fanTalks
}
override fun findCreatorReplies(
creatorId: Long,
parentFanTalkIds: List<Long>
): List<CreatorChannelFanTalkReplyRecord> {
replyParentFanTalkIds = parentFanTalkIds
return creatorReplies
}
}
private fun fanTalkRecord(
fanTalkId: Long,
writerId: Long = 10L + fanTalkId,
writerNickname: String = "fan-$fanTalkId",
writerProfileImagePath: String? = "profile/$fanTalkId.png"
): CreatorChannelFanTalkRecord {
return CreatorChannelFanTalkRecord(
fanTalkId = fanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImagePath = writerProfileImagePath,
content = "content-$fanTalkId",
createdAt = LocalDateTime.of(2026, 6, 21, 10, 0).plusMinutes(fanTalkId)
)
}
private fun fanTalkReplyRecord(
fanTalkId: Long,
parentFanTalkId: Long,
writerId: Long = 1L,
writerNickname: String = "creator",
writerProfileImagePath: String? = "profile/creator.png"
): CreatorChannelFanTalkReplyRecord {
return CreatorChannelFanTalkReplyRecord(
fanTalkId = fanTalkId,
parentFanTalkId = parentFanTalkId,
writerId = writerId,
writerNickname = writerNickname,
writerProfileImagePath = writerProfileImagePath,
content = "reply-$fanTalkId",
createdAt = LocalDateTime.of(2026, 6, 21, 11, 0).plusMinutes(fanTalkId)
)
}