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

This commit is contained in:
2026-07-09 00:53:12 +09:00
parent c04d603ac0
commit c9e47214ea
2 changed files with 819 additions and 0 deletions

View File

@@ -0,0 +1,446 @@
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.plugins.RxJavaPlugins
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import kr.co.vividnext.sodalive.audio_content.comment.ModifyCommentRequest
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.explorer.profile.creator_community.CreatorCommunityRepository
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityCommentUiModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyUiState
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply.CreatorChannelCommunityReplyViewModel
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityRepliesResponse
import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.data.CreatorChannelCommunityReplyResponse
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 CreatorChannelCommunityReplyViewModelTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private lateinit var repository: CreatorChannelRepository
private lateinit var legacyRepository: CreatorCommunityRepository
private lateinit var viewModel: CreatorChannelCommunityReplyViewModel
private lateinit var formattedInputs: MutableList<String?>
@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()
formattedInputs = mutableListOf()
viewModel = CreatorChannelCommunityReplyViewModel(
repository,
legacyRepository,
UtcRelativeTimeTextFormatter { value ->
formattedInputs.add(value)
"relative:$value"
}
)
}
@After
fun tearDown() {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
SharedPreferenceManager.resetForTest()
}
@Test
fun `답글 로드는 부모 댓글을 노출하고 첫 페이지 답글을 매핑한다`() {
stubReplies(repliesResponse(ids = listOf(101L, 102L), hasNext = true))
viewModel.loadReplies(POST_ID, parentComment())
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals(COMMENT_ID, state.parentComment.commentId)
assertEquals("부모 댓글", state.parentComment.comment)
assertEquals("relative:2026-07-08T00:00:00Z", state.parentComment.createdAtText)
assertEquals(listOf(101L, 102L), state.replies.map { it.replyId })
assertTrue(formattedInputs.contains("2026-07-08T00:00:00Z"))
assertEquals(0, state.replyPage)
assertTrue(state.hasNextReply)
verify(repository).getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
}
@Test
fun `답글 페이지네이션은 hasNext일 때 다음 페이지를 append하고 중복 loadMore를 막는다`() {
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(Single.just(ApiResponse(true, repliesResponse(ids = listOf(101L), hasNext = true), null)))
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
1,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(Single.just(ApiResponse(true, repliesResponse(page = 1, ids = listOf(102L), hasNext = false), null)))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.loadMoreReplies()
viewModel.loadMoreReplies()
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals(listOf(101L, 102L), state.replies.map { it.replyId })
assertEquals(1, state.replyPage)
assertFalse(state.hasNextReply)
verify(repository, times(1)).getCommunityCommentReplies(
COMMENT_ID,
1,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
}
@Test
fun `답글 작성은 trim 후 전송하고 중복 전송을 막고 성공 시 입력과 이벤트를 정리하며 새로고침한다`() {
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(
Single.just(ApiResponse(true, repliesResponse(ids = listOf(101L), hasNext = false), null)),
Single.just(ApiResponse(true, repliesResponse(ids = listOf(201L), hasNext = false), null))
)
val sendSubject = PublishSubject.create<ApiResponse<Any>>()
whenever(legacyRepository.registerComment(POST_ID, "hello", COMMENT_ID, false, AUTH_TOKEN))
.thenReturn(sendSubject.firstOrError())
viewModel.loadReplies(POST_ID, parentComment())
viewModel.updateReplyInput(" ")
viewModel.submitReply()
viewModel.updateReplyInput(" hello ")
viewModel.submitReply()
viewModel.submitReply()
verify(legacyRepository, times(1)).registerComment(POST_ID, "hello", COMMENT_ID, false, AUTH_TOKEN)
sendSubject.onNext(ApiResponse(true, Any(), null))
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals("", state.replyInput)
assertTrue(viewModel.replyWrittenEventLiveData.requireValue() == true)
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
viewModel.consumeReplyChangedEvent()
assertFalse(viewModel.replyChangedEventLiveData.requireValue() == true)
viewModel.consumeReplyWrittenEvent()
assertFalse(viewModel.replyWrittenEventLiveData.requireValue() == true)
assertEquals(listOf(201L), state.replies.map { it.replyId })
verify(repository, times(2)).getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
}
@Test
fun `답글 작성 실패는 API 메시지 toast 이벤트를 노출하고 입력 상태를 유지한다`() {
stubReplies(repliesResponse(ids = listOf(101L), hasNext = false))
whenever(legacyRepository.registerComment(POST_ID, "hello", COMMENT_ID, false, AUTH_TOKEN))
.thenReturn(Single.just(ApiResponse(false, Any(), "답글 실패")))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.updateReplyInput("hello")
viewModel.submitReply()
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals("hello", state.replyInput)
assertFalse(state.isSendingReply)
assertEquals("답글 실패", viewModel.toastLiveData.requireValue()?.consume()?.message)
assertEquals(null, viewModel.toastLiveData.requireValue()?.consume())
}
@Test
fun `답글 삭제 실패는 알 수 없는 오류 toast 이벤트를 노출한다`() {
stubReplies(repliesResponse(ids = listOf(101L), hasNext = false))
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
.thenReturn(Single.error(RuntimeException("network")))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.deleteReply(REPLY_ID)
assertEquals(
kr.co.vividnext.sodalive.R.string.common_error_unknown,
viewModel.toastLiveData.requireValue()?.consume()?.resId
)
}
@Test
fun `답글 수정 내용이 공백이면 내용 입력 toast를 노출하고 API를 호출하지 않는다`() {
stubReplies(repliesResponse(ids = listOf(101L), hasNext = false))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.modifyReply(REPLY_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 `답글 수정과 삭제는 legacy modifyComment를 호출하고 성공 시 답글을 새로고침한다`() {
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(
Single.just(ApiResponse(true, repliesResponse(ids = listOf(101L), hasNext = false), null)),
Single.just(ApiResponse(true, repliesResponse(ids = listOf(102L), hasNext = false), null)),
Single.just(ApiResponse(true, repliesResponse(ids = listOf(103L), hasNext = false), null))
)
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.modifyReply(REPLY_ID, " 수정 댓글 ")
viewModel.deleteReply(REPLY_ID)
verify(legacyRepository).modifyComment(
argThat<ModifyCommentRequest> { commentId == REPLY_ID && comment == "수정 댓글" && isActive == null },
org.mockito.kotlin.eq(AUTH_TOKEN)
)
verify(legacyRepository).modifyComment(
argThat<ModifyCommentRequest> { commentId == REPLY_ID && comment == null && isActive == false },
org.mockito.kotlin.eq(AUTH_TOKEN)
)
verify(repository, times(3)).getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
assertTrue(viewModel.replyChangedEventLiveData.requireValue() == true)
}
@Test
fun `부모 댓글 수정은 legacy modifyComment를 호출하고 성공 시 부모 댓글 상태와 변경 이벤트를 갱신한다`() {
stubReplies(repliesResponse(ids = listOf(101L), hasNext = false))
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.modifyParentComment(" 수정 부모 댓글 ")
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals("수정 부모 댓글", state.parentComment.comment)
assertTrue(viewModel.parentCommentChangedEventLiveData.requireValue() == true)
viewModel.consumeParentCommentChangedEvent()
assertFalse(viewModel.parentCommentChangedEventLiveData.requireValue() == true)
verify(legacyRepository).modifyComment(
argThat<ModifyCommentRequest> { commentId == COMMENT_ID && comment == "수정 부모 댓글" && isActive == null },
org.mockito.kotlin.eq(AUTH_TOKEN)
)
verify(repository, times(1)).getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
}
@Test
fun `부모 댓글 수정 내용이 공백이면 내용 입력 toast를 노출하고 API를 호출하지 않는다`() {
stubReplies(repliesResponse(ids = listOf(101L), hasNext = false))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.modifyParentComment(" ")
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 `부모 댓글 삭제는 legacy modifyComment를 호출하고 성공 시 삭제 이벤트만 노출한다`() {
stubReplies(repliesResponse(ids = listOf(101L), hasNext = false))
whenever(legacyRepository.modifyComment(any(), org.mockito.kotlin.eq(AUTH_TOKEN)))
.thenReturn(Single.just(ApiResponse(true, Any(), null)))
viewModel.loadReplies(POST_ID, parentComment())
viewModel.deleteParentComment()
assertTrue(viewModel.parentCommentDeletedEventLiveData.requireValue() == true)
viewModel.consumeParentCommentDeletedEvent()
assertFalse(viewModel.parentCommentDeletedEventLiveData.requireValue() == true)
verify(legacyRepository).modifyComment(
argThat<ModifyCommentRequest> { commentId == COMMENT_ID && comment == null && isActive == false },
org.mockito.kotlin.eq(AUTH_TOKEN)
)
verify(repository, times(1)).getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
}
@Test
fun `댓글 id가 유효하지 않으면 답글 API를 호출하지 않는다`() {
viewModel.loadReplies(POST_ID, parentComment(commentId = 0L))
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertTrue(state.replies.isEmpty())
verify(repository, never()).getCommunityCommentReplies(any(), any(), any(), any())
}
@Test
fun `새 답글 로드 후 이전 답글 페이지 응답이 도착해도 현재 답글 목록을 덮지 않는다`() {
val loadMoreSubject = PublishSubject.create<ApiResponse<CreatorChannelCommunityRepliesResponse>>()
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(
Single.just(ApiResponse(true, repliesResponse(ids = listOf(101L), hasNext = true), null)),
Single.just(ApiResponse(true, repliesResponse(ids = listOf(201L), hasNext = false), null))
)
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
1,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
).thenReturn(loadMoreSubject.firstOrError())
viewModel.loadReplies(POST_ID, parentComment())
viewModel.loadMoreReplies()
viewModel.loadReplies(POST_ID, parentComment())
loadMoreSubject.onNext(ApiResponse(true, repliesResponse(page = 1, ids = listOf(999L), hasNext = false), null))
val state = viewModel.replyStateLiveData.requireValue() as CreatorChannelCommunityReplyUiState.Content
assertEquals(listOf(201L), state.replies.map { it.replyId })
assertEquals(0, state.replyPage)
assertFalse(state.hasNextReply)
}
private fun stubReplies(response: CreatorChannelCommunityRepliesResponse) {
whenever(
repository.getCommunityCommentReplies(
COMMENT_ID,
0,
CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
AUTH_TOKEN
)
)
.thenReturn(Single.just(ApiResponse(true, response, null)))
}
private fun repliesResponse(
page: Int = 0,
ids: List<Long>,
hasNext: Boolean
) = CreatorChannelCommunityRepliesResponse(
replies = ids.map { replyResponse(it) },
page = page,
size = CreatorChannelCommunityReplyViewModel.DEFAULT_PAGE_SIZE,
hasNext = hasNext
)
private fun replyResponse(id: Long) = CreatorChannelCommunityReplyResponse(
commentId = id,
writerId = 10L,
writerProfileImageUrl = "member.png",
writerNickname = "member $id",
content = "reply $id",
createdAtUtc = "2026-07-08T00:00:00Z"
)
private fun parentComment(commentId: Long = COMMENT_ID) = CreatorChannelCommunityCommentUiModel(
commentId = commentId,
memberId = 10L,
memberNickname = "member",
memberProfileUrl = "member.png",
createdAtText = "이미 포맷된 시간",
createdAtUtc = "2026-07-08T00:00:00Z",
comment = "부모 댓글",
likeCount = 1,
replyCount = 2,
isLiked = false,
isMine = true,
isCreatorOwner = false,
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 = 300L
const val REPLY_ID = 101L
const val AUTH_TOKEN = "Bearer test-token"
}
}