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,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 { "방금 전" }
}
}