feat(creator-channel): 커뮤니티 상세 상태 관리를 추가한다

This commit is contained in:
2026-07-09 00:53:06 +09:00
parent 9e524d626a
commit c04d603ac0
2 changed files with 951 additions and 0 deletions

View File

@@ -0,0 +1,447 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community.detail
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.CreatorChannelEvent
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
class CreatorChannelCommunityDetailViewModel(
private val repository: CreatorChannelRepository,
private val legacyRepository: CreatorCommunityRepository,
private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter
) : BaseViewModel() {
private val _detailStateLiveData = MutableLiveData<CreatorChannelCommunityDetailUiState>()
val detailStateLiveData: LiveData<CreatorChannelCommunityDetailUiState>
get() = _detailStateLiveData
private val _commentWrittenEventLiveData = MutableLiveData<Boolean>()
val commentWrittenEventLiveData: LiveData<Boolean>
get() = _commentWrittenEventLiveData
private val _postChangedEventLiveData = MutableLiveData<Boolean>()
val postChangedEventLiveData: LiveData<Boolean>
get() = _postChangedEventLiveData
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
get() = _toastLiveData
private var postId: Long = 0L
private var detailRequestGeneration = 0
private var commentRequestGeneration = 0
private var isLoadingComments = false
private var isTogglingLike = false
private var isSendingComment = false
private var isModifyingComment = false
fun loadDetail(postId: Long) {
if (postId <= 0) return
this.postId = postId
val generation = ++detailRequestGeneration
commentRequestGeneration++
isLoadingComments = false
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Loading
compositeDisposable.add(
repository.getCommunityPostDetail(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
if (generation == detailRequestGeneration) {
handleDetailResponse(response)
}
},
{
if (generation == detailRequestGeneration) {
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(it.message)
}
}
)
)
}
fun loadMoreComments() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.isCommentAvailable || !content.hasNextComment || isLoadingComments) return
loadComments(page = content.commentPage + 1, append = true)
}
fun toggleLike() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (isTogglingLike) return
val previousPost = content.post
val nextPost = previousPost.copy(
isLiked = !previousPost.isLiked,
likeCount = (previousPost.likeCount + if (previousPost.isLiked) -1 else 1).coerceAtLeast(0)
)
isTogglingLike = true
_detailStateLiveData.value = content.copy(post = nextPost)
compositeDisposable.add(
legacyRepository.communityPostLike(postId, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isTogglingLike = false
if (response.success) {
_postChangedEventLiveData.value = true
} else {
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(post = previousPost)
}
},
{
isTogglingLike = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(post = previousPost)
}
)
)
}
fun updateCommentInput(input: String) {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
if (!content.isCommentAvailable) return
_detailStateLiveData.value = content.copy(commentInput = input)
}
fun submitComment() {
val content = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
val comment = content.commentInput.trim()
if (!content.isCommentAvailable || comment.isBlank() || isSendingComment) return
isSendingComment = true
_detailStateLiveData.value = content.copy(isSendingComment = true)
compositeDisposable.add(
legacyRepository.registerComment(
postId = postId,
comment = comment,
parentId = null,
isSecret = false,
token = authToken()
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isSendingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
if (response.success) {
_commentWrittenEventLiveData.value = true
_detailStateLiveData.value = current.copy(commentInput = "", isSendingComment = false)
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
},
{
isSendingComment = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
showMutationFailureToast()
_detailStateLiveData.value = current.copy(isSendingComment = false)
}
)
)
}
fun consumeCommentWrittenEvent() {
_commentWrittenEventLiveData.value = false
}
fun consumePostChangedEvent() {
_postChangedEventLiveData.value = false
}
fun modifyComment(commentId: Long, comment: String) {
val trimmedComment = comment.trim()
if (commentId <= 0 || isModifyingComment) return
if (trimmedComment.isBlank()) {
showBlankCommentToast()
return
}
modifyComment(ModifyCommentRequest(commentId = commentId, comment = trimmedComment))
}
fun deleteComment(commentId: Long) {
if (commentId <= 0 || isModifyingComment) return
modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false))
}
private fun handleDetailResponse(response: ApiResponse<CreatorChannelCommunityPostDetailResponse>) {
val data = response.data
if (!response.success || data == null) {
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Error(response.message)
return
}
val isCreatorOwner = data.creatorId == SharedPreferenceManager.userId
val comments = if (data.isCommentAvailable) {
data.comments.comments.map { it.toUiModel(isCreatorOwner) }
} else {
emptyList()
}
_detailStateLiveData.value = CreatorChannelCommunityDetailUiState.Content(
post = data.toUiModel(),
isCommentAvailable = data.isCommentAvailable,
isCommentInputVisible = data.isCommentAvailable,
comments = comments,
commentPage = data.comments.page,
hasNextComment = data.isCommentAvailable && data.comments.hasNext
)
}
private fun loadComments(page: Int, append: Boolean) {
if (postId <= 0 || isLoadingComments && append) return
val generation = if (append) {
commentRequestGeneration
} else {
++commentRequestGeneration
}
isLoadingComments = true
val beforeRequest = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
if (beforeRequest != null) {
_detailStateLiveData.value = beforeRequest.copy(isLoadingComments = true)
}
compositeDisposable.add(
repository.getCommunityPostComments(postId, page, DEFAULT_PAGE_SIZE, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response -> handleCommentsResponse(response, append, generation) },
{
if (generation != commentRequestGeneration) return@subscribe
isLoadingComments = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content
?: return@subscribe
_detailStateLiveData.value = current.copy(isLoadingComments = false)
}
)
)
}
private fun handleCommentsResponse(
response: ApiResponse<CreatorChannelCommunityCommentsResponse>,
append: Boolean,
generation: Int
) {
if (generation != commentRequestGeneration) return
isLoadingComments = false
val current = _detailStateLiveData.value as? CreatorChannelCommunityDetailUiState.Content ?: return
val data = response.data
if (!response.success || data == null) {
_detailStateLiveData.value = current.copy(isLoadingComments = false)
return
}
val isCreatorOwner = current.post.creatorId == SharedPreferenceManager.userId
val nextComments = data.comments.map { it.toUiModel(isCreatorOwner) }
_detailStateLiveData.value = current.copy(
post = current.post.copy(commentCount = data.commentCount),
comments = if (append) current.comments + nextComments else nextComments,
commentPage = data.page,
hasNextComment = data.hasNext,
isLoadingComments = false
)
}
private fun modifyComment(request: ModifyCommentRequest) {
if (postId <= 0) return
isModifyingComment = true
compositeDisposable.add(
legacyRepository.modifyComment(request, authToken())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isModifyingComment = false
if (response.success) {
_postChangedEventLiveData.value = true
loadDetail(postId)
} else {
showMutationFailureToast(response.message)
}
},
{
isModifyingComment = false
showMutationFailureToast()
}
)
)
}
private fun showMutationFailureToast(message: String? = null) {
_toastLiveData.value = CreatorChannelEvent(
if (message.isNullOrBlank()) {
ToastMessage(resId = R.string.common_error_unknown)
} else {
ToastMessage(message = message)
}
)
}
private fun showBlankCommentToast() {
_toastLiveData.value = CreatorChannelEvent(
ToastMessage(resId = R.string.screen_creator_community_write_content_hint)
)
}
private fun CreatorChannelCommunityPostDetailResponse.toUiModel() = CreatorChannelCommunityPostDetailUiModel(
postId = postId,
creatorId = creatorId,
creatorNickname = creatorNickname,
creatorProfileUrl = creatorProfileUrl,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
content = content,
imageUrl = imageUrl.takeUnless { isLocked() },
audioUrl = audioUrl.takeUnless { isLocked() },
price = price,
existOrdered = existOrdered,
likeCount = likeCount,
commentCount = commentCount,
isLiked = isLiked,
isPinned = isPinned,
isLocked = isLocked(),
showPaywall = isLocked()
)
private fun CreatorChannelCommunityPostDetailResponse.isLocked(): Boolean {
return price > 0 && !existOrdered && creatorId != SharedPreferenceManager.userId
}
private fun CreatorChannelCommunityCommentResponse.toUiModel(
isCreatorOwner: Boolean
) = CreatorChannelCommunityCommentUiModel(
commentId = commentId,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
replyCount = if (latestReply != null) 1 else 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = isCreatorOwner,
latestReply = latestReply?.toUiModel(parentCommentId = commentId, isCreatorOwner = isCreatorOwner)
)
private fun CreatorChannelCommunityReplyResponse.toUiModel(
parentCommentId: Long,
isCreatorOwner: Boolean
) = CreatorChannelCommunityReplyUiModel(
replyId = commentId,
commentId = parentCommentId,
memberId = writerId,
memberNickname = writerNickname,
memberProfileUrl = writerProfileImageUrl,
createdAtUtc = createdAtUtc,
createdAtText = relativeTimeTextFormatter.format(createdAtUtc),
comment = content,
likeCount = 0,
isLiked = false,
isMine = writerId == SharedPreferenceManager.userId,
isCreatorOwner = isCreatorOwner
)
private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}"
companion object {
const val DEFAULT_PAGE_SIZE = 20
private const val FIRST_PAGE = 0
}
}
sealed interface CreatorChannelCommunityDetailUiState {
data object Loading : CreatorChannelCommunityDetailUiState
data class Error(val message: String?) : CreatorChannelCommunityDetailUiState
data class Content(
val post: CreatorChannelCommunityPostDetailUiModel,
val isCommentAvailable: Boolean,
val isCommentInputVisible: Boolean,
val comments: List<CreatorChannelCommunityCommentUiModel>,
val commentPage: Int,
val hasNextComment: Boolean,
val isLoadingComments: Boolean = false,
val commentInput: String = "",
val isSendingComment: Boolean = false
) : CreatorChannelCommunityDetailUiState
}
data class CreatorChannelCommunityPostDetailUiModel(
val postId: Long,
val creatorId: Long,
val creatorNickname: String,
val creatorProfileUrl: String?,
val createdAtText: String,
val content: String,
val imageUrl: String?,
val audioUrl: String?,
val price: Int,
val existOrdered: Boolean,
val likeCount: Int,
val commentCount: Int,
val isLiked: Boolean,
val isPinned: Boolean,
val isLocked: Boolean,
val showPaywall: Boolean
)
data class CreatorChannelCommunityCommentUiModel(
val commentId: Long,
val memberId: Long,
val memberNickname: String,
val memberProfileUrl: String?,
val createdAtUtc: String,
val createdAtText: String,
val comment: String,
val likeCount: Int,
val replyCount: Int,
val isLiked: Boolean,
val isMine: Boolean,
val isCreatorOwner: Boolean,
val latestReply: CreatorChannelCommunityReplyUiModel?
)
data class CreatorChannelCommunityReplyUiModel(
val replyId: Long,
val commentId: Long,
val memberId: Long,
val memberNickname: String,
val memberProfileUrl: String?,
val createdAtUtc: String,
val createdAtText: String,
val comment: String,
val likeCount: Int,
val isLiked: Boolean,
val isMine: Boolean,
val isCreatorOwner: Boolean
)

View File

@@ -0,0 +1,504 @@
package kr.co.vividnext.sodalive.v2.creator.channel.community
import android.app.Application
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.common.ApiResponse
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.UtcRelativeTimeTextFormatter
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
import kr.co.vividnext.sodalive.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailUiState
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityDetailViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityCommentsResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityPostDetailResponse
import kr.co.vividnext.sodalive.v2.creator.channel.data.CreatorChannelRepository
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argThat
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [28], application = Application::class)
class CreatorChannelCommunityDetailViewModelTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var repository: CreatorChannelRepository
private lateinit var legacyRepository: CreatorCommunityRepository
private lateinit var viewModel: CreatorChannelCommunityDetailViewModel
@Before
fun setUp() {
setImmediateRxSchedulers()
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
SharedPreferenceManager.token = "test-token"
SharedPreferenceManager.userId = 10L
repository = org.mockito.kotlin.mock()
legacyRepository = org.mockito.kotlin.mock()
viewModel = CreatorChannelCommunityDetailViewModel(repository, legacyRepository, testFormatter)
}
@After
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetForTest()
}
@Test
fun `상세 로드는 본문 미디어 좋아요 댓글 상태를 매핑하고 댓글 첫 페이지를 조회한다`() {
stubDetail(
detailResponse(
isCommentAvailable = true,
isLiked = true,
likeCount = 3,
commentIds = listOf(11L, 12L),
commentsHasNext = true
)
)
viewModel.loadDetail(POST_ID)
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals(POST_ID, state.post.postId)
assertEquals("body", state.post.content)
assertEquals("image.png", state.post.imageUrl)
assertEquals("audio.mp3", state.post.audioUrl)
assertTrue(state.post.isLiked)
assertEquals(3, state.post.likeCount)
assertTrue(state.isCommentAvailable)
assertEquals(listOf(11L, 12L), state.comments.map { it.commentId })
assertEquals(0, state.commentPage)
assertTrue(state.hasNextComment)
verify(repository).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
}
@Test
fun `채널 작성자는 상세 embedded 타인 댓글의 삭제 메뉴 권한을 가진다`() {
SharedPreferenceManager.userId = 100L
stubDetail(
detailResponse(
isCommentAvailable = true,
creatorId = 100L,
commentIds = listOf(11L)
)
)
viewModel.loadDetail(POST_ID)
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertFalse(state.comments.first().isMine)
assertTrue(state.comments.first().isCreatorOwner)
}
@Test
fun `댓글 페이지네이션은 hasNext일 때 다음 페이지를 append하고 중복 loadMore를 막는다`() {
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L), commentsHasNext = true))
whenever(
repository.getCommunityPostComments(
POST_ID,
1,
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(Single.just(ApiResponse(true, commentsResponse(page = 1, ids = listOf(12L), hasNext = false), null)))
viewModel.loadDetail(POST_ID)
viewModel.loadMoreComments()
viewModel.loadMoreComments()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals(listOf(11L, 12L), state.comments.map { it.commentId })
assertEquals(1, state.commentPage)
assertFalse(state.hasNextComment)
verify(repository, times(1)).getCommunityPostComments(
POST_ID,
1,
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
}
@Test
fun `좋아요 토글은 낙관적으로 상태를 바꾸고 진행 중 중복 호출을 막는다`() {
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
val likeSubject = PublishSubject.create<ApiResponse<Any>>()
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN)).thenReturn(likeSubject.firstOrError())
viewModel.loadDetail(POST_ID)
viewModel.toggleLike()
viewModel.toggleLike()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertTrue(state.post.isLiked)
assertEquals(4, state.post.likeCount)
verify(legacyRepository, times(1)).communityPostLike(POST_ID, AUTH_TOKEN)
}
@Test
fun `좋아요 토글 실패는 이전 좋아요 상태와 개수로 롤백한다`() {
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN)).thenReturn(Single.error(RuntimeException("fail")))
viewModel.loadDetail(POST_ID)
viewModel.toggleLike()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertFalse(state.post.isLiked)
assertEquals(3, state.post.likeCount)
verify(legacyRepository).communityPostLike(POST_ID, AUTH_TOKEN)
}
@Test
fun `좋아요 토글 success false 응답은 이전 좋아요 상태와 개수로 롤백한다`() {
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(false, Any(), null)))
viewModel.loadDetail(POST_ID)
viewModel.toggleLike()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertFalse(state.post.isLiked)
assertEquals(3, state.post.likeCount)
assertFalse(viewModel.postChangedEventLiveData.requireValue() == true)
}
@Test
fun `좋아요 토글 success true 응답은 게시물 변경 이벤트를 노출한다`() {
stubDetail(detailResponse(isCommentAvailable = false, isLiked = false, likeCount = 3))
whenever(legacyRepository.communityPostLike(POST_ID, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadDetail(POST_ID)
viewModel.toggleLike()
assertTrue(viewModel.postChangedEventLiveData.requireValue() == true)
viewModel.consumePostChangedEvent()
assertFalse(viewModel.postChangedEventLiveData.requireValue() == true)
}
@Test
fun `댓글 작성은 trim 후 전송하고 성공 시 입력을 비우고 댓글을 새로고침한다`() {
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
.thenReturn(
Single.just(
ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(11L)), null)
),
Single.just(
ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(21L)), null)
)
)
whenever(legacyRepository.registerComment(POST_ID, "hello", null, false, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadDetail(POST_ID)
viewModel.updateCommentInput(" hello ")
viewModel.submitComment()
viewModel.submitComment()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals("", state.commentInput)
assertTrue(viewModel.commentWrittenEventLiveData.requireValue() == true)
assertEquals(listOf(21L), state.comments.map { it.commentId })
verify(repository, times(2)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
verify(legacyRepository, times(1)).registerComment(POST_ID, "hello", null, false, AUTH_TOKEN)
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
}
@Test
fun `댓글 작성 실패는 API 메시지 toast 이벤트를 노출하고 입력 상태를 유지한다`() {
stubDetail(detailResponse(isCommentAvailable = true))
stubComments(commentsResponse(ids = listOf(11L), hasNext = false))
whenever(legacyRepository.registerComment(POST_ID, "hello", null, false, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(false, Any(), "작성 실패")))
viewModel.loadDetail(POST_ID)
viewModel.updateCommentInput("hello")
viewModel.submitComment()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals("hello", state.commentInput)
assertFalse(state.isSendingComment)
assertEquals("작성 실패", viewModel.toastLiveData.requireValue()?.consume()?.message)
assertEquals(null, viewModel.toastLiveData.requireValue()?.consume())
}
@Test
fun `댓글 수정 실패는 알 수 없는 오류 toast 이벤트를 노출한다`() {
stubDetail(detailResponse(isCommentAvailable = true))
stubComments(commentsResponse(ids = listOf(11L), hasNext = false))
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
.thenReturn(Single.error(RuntimeException("network")))
viewModel.loadDetail(POST_ID)
viewModel.modifyComment(COMMENT_ID, "수정 댓글")
assertEquals(
kr.co.vividnext.sodalive.R.string.common_error_unknown,
viewModel.toastLiveData.requireValue()?.consume()?.resId
)
}
@Test
fun `댓글 수정 내용이 공백이면 내용 입력 toast를 노출하고 API를 호출하지 않는다`() {
stubDetail(detailResponse(isCommentAvailable = true))
viewModel.loadDetail(POST_ID)
viewModel.modifyComment(COMMENT_ID, " ")
assertEquals(
kr.co.vividnext.sodalive.R.string.screen_creator_community_write_content_hint,
viewModel.toastLiveData.requireValue()?.consume()?.resId
)
verify(legacyRepository, never()).modifyComment(any(), any())
}
@Test
fun `댓글 불가 게시물은 댓글 조회와 작성을 호출하지 않고 입력 숨김 상태를 노출한다`() {
stubDetail(detailResponse(isCommentAvailable = false))
viewModel.loadDetail(POST_ID)
viewModel.updateCommentInput("hello")
viewModel.submitComment()
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertFalse(state.isCommentAvailable)
assertFalse(state.isCommentInputVisible)
assertTrue(state.comments.isEmpty())
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
verify(legacyRepository, never()).registerComment(any(), any(), any(), any(), any())
}
@Test
fun `유료 미구매 게시물은 작성자가 아니면 이미지와 오디오 URL을 노출하지 않는다`() {
SharedPreferenceManager.userId = 10L
stubDetail(
detailResponse(
isCommentAvailable = false,
price = 300,
existOrdered = false,
creatorId = 999L
)
)
viewModel.loadDetail(POST_ID)
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertTrue(state.post.isLocked)
assertEquals(null, state.post.imageUrl)
assertEquals(null, state.post.audioUrl)
assertTrue(state.post.showPaywall)
}
@Test
fun `채널 작성자는 유료 미구매 게시물도 이미지와 오디오 URL을 볼 수 있다`() {
SharedPreferenceManager.userId = 999L
stubDetail(
detailResponse(
isCommentAvailable = false,
price = 300,
existOrdered = false,
creatorId = 999L
)
)
viewModel.loadDetail(POST_ID)
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertFalse(state.post.isLocked)
assertEquals("image.png", state.post.imageUrl)
assertEquals("audio.mp3", state.post.audioUrl)
assertFalse(state.post.showPaywall)
}
@Test
fun `댓글 UI 모델은 답글 화면 재포맷을 위해 원본 createdAtUtc를 보존한다`() {
stubDetail(detailResponse(isCommentAvailable = true, commentIds = listOf(11L)))
viewModel.loadDetail(POST_ID)
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals("2026-07-08T00:00:00Z", state.comments.first().createdAtUtc)
}
@Test
fun `상세 댓글 수정과 삭제는 legacy modifyComment를 호출하고 성공 시 댓글을 새로고침한다`() {
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
.thenReturn(
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(11L)), null)),
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(12L)), null)),
Single.just(ApiResponse(true, detailResponse(isCommentAvailable = true, commentIds = listOf(13L)), null))
)
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadDetail(POST_ID)
viewModel.modifyComment(COMMENT_ID, " 수정 댓글 ")
viewModel.deleteComment(COMMENT_ID)
verify(legacyRepository).modifyComment(
argThat<ModifyCommentRequest> { commentId == COMMENT_ID && comment == "수정 댓글" && isActive == null },
org.mockito.kotlin.eq(AUTH_TOKEN)
)
verify(legacyRepository).modifyComment(
argThat<ModifyCommentRequest> { commentId == COMMENT_ID && comment == null && isActive == false },
org.mockito.kotlin.eq(AUTH_TOKEN)
)
verify(repository, times(3)).getCommunityPostDetail(POST_ID, AUTH_TOKEN)
verify(repository, never()).getCommunityPostComments(any(), any(), any(), any())
}
@Test
fun `새 상세 로드 후 이전 댓글 페이지 응답이 도착해도 현재 댓글 목록을 덮지 않는다`() {
val loadMoreSubject = PublishSubject.create<ApiResponse<CreatorChannelCommunityCommentsResponse>>()
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
.thenReturn(
Single.just(
ApiResponse(
true,
detailResponse(isCommentAvailable = true, commentIds = listOf(11L), commentsHasNext = true),
null
)
),
Single.just(
ApiResponse(
true,
detailResponse(isCommentAvailable = true, commentIds = listOf(21L), commentsHasNext = false),
null
)
)
)
whenever(
repository.getCommunityPostComments(
POST_ID,
1,
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
).thenReturn(loadMoreSubject.firstOrError())
viewModel.loadDetail(POST_ID)
viewModel.loadMoreComments()
viewModel.loadDetail(POST_ID)
loadMoreSubject.onNext(ApiResponse(true, commentsResponse(page = 1, ids = listOf(99L), hasNext = false), null))
val state = viewModel.detailStateLiveData.requireValue() as CreatorChannelCommunityDetailUiState.Content
assertEquals(listOf(21L), state.comments.map { it.commentId })
assertEquals(0, state.commentPage)
assertFalse(state.hasNextComment)
}
private fun stubDetail(response: CreatorChannelCommunityPostDetailResponse) {
whenever(repository.getCommunityPostDetail(POST_ID, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(true, response, null)))
}
private fun stubComments(response: CreatorChannelCommunityCommentsResponse) {
whenever(
repository.getCommunityPostComments(
POST_ID,
0,
CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(Single.just(ApiResponse(true, response, null)))
}
private fun detailResponse(
isCommentAvailable: Boolean,
isLiked: Boolean = false,
likeCount: Int = 1,
price: Int = 0,
existOrdered: Boolean = true,
creatorId: Long = 100L,
commentIds: List<Long> = emptyList(),
commentsHasNext: Boolean = false
) = CreatorChannelCommunityPostDetailResponse(
postId = POST_ID,
creatorId = creatorId,
creatorNickname = "creator",
creatorProfileUrl = "profile.png",
createdAtUtc = "2026-07-08T00:00:00Z",
content = "body",
imageUrl = "image.png",
audioUrl = "audio.mp3",
price = price,
existOrdered = existOrdered,
isCommentAvailable = isCommentAvailable,
likeCount = likeCount,
commentCount = 2,
isLiked = isLiked,
isPinned = false,
comments = commentsResponse(ids = commentIds, hasNext = commentsHasNext)
)
private fun commentsResponse(
page: Int = 0,
ids: List<Long>,
hasNext: Boolean
) = CreatorChannelCommunityCommentsResponse(
commentCount = ids.size,
comments = ids.map { commentResponse(it) },
page = page,
size = CreatorChannelCommunityDetailViewModel.DEFAULT_PAGE_SIZE,
hasNext = hasNext
)
private fun commentResponse(id: Long) = CreatorChannelCommunityCommentResponse(
commentId = id,
writerId = 10L,
writerProfileImageUrl = "member.png",
writerNickname = "member $id",
content = "comment $id",
isSecret = false,
createdAtUtc = "2026-07-08T00:00:00Z",
latestReply = null
)
private fun setImmediateRxSchedulers() {
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
RxJavaPlugins.setIoSchedulerHandler(trampoline)
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
private fun <T> LiveData<T>.requireValue(): T? {
var value: T? = null
val observer = Observer<T> { value = it }
observeForever(observer)
removeObserver(observer)
return value
}
private companion object {
const val POST_ID = 200L
const val COMMENT_ID = 11L
const val AUTH_TOKEN = "Bearer test-token"
val testFormatter = UtcRelativeTimeTextFormatter { "방금 전" }
}
}