diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/reply/CreatorChannelCommunityReplyViewModel.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/reply/CreatorChannelCommunityReplyViewModel.kt new file mode 100644 index 00000000..7093841e --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/creator/channel/community/detail/reply/CreatorChannelCommunityReplyViewModel.kt @@ -0,0 +1,373 @@ +package kr.co.vividnext.sodalive.v2.creator.channel.community.detail.reply + +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.CreatorChannelCommunityCommentUiModel +import kr.co.vividnext.sodalive.v2.creator.channel.community.detail.CreatorChannelCommunityReplyUiModel +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 + +class CreatorChannelCommunityReplyViewModel( + private val repository: CreatorChannelRepository, + private val legacyRepository: CreatorCommunityRepository, + private val relativeTimeTextFormatter: UtcRelativeTimeTextFormatter +) : BaseViewModel() { + + private val _replyStateLiveData = MutableLiveData() + val replyStateLiveData: LiveData + get() = _replyStateLiveData + + private val _replyWrittenEventLiveData = MutableLiveData() + val replyWrittenEventLiveData: LiveData + get() = _replyWrittenEventLiveData + + private val _replyChangedEventLiveData = MutableLiveData() + val replyChangedEventLiveData: LiveData + get() = _replyChangedEventLiveData + + private val _parentCommentChangedEventLiveData = MutableLiveData() + val parentCommentChangedEventLiveData: LiveData + get() = _parentCommentChangedEventLiveData + + private val _parentCommentDeletedEventLiveData = MutableLiveData() + val parentCommentDeletedEventLiveData: LiveData + get() = _parentCommentDeletedEventLiveData + + private val _toastLiveData = MutableLiveData>() + val toastLiveData: LiveData> + get() = _toastLiveData + + private var postId: Long = 0L + private var parentComment: CreatorChannelCommunityCommentUiModel? = null + private var replyRequestGeneration = 0 + private var isLoadingReplies = false + private var isSendingReply = false + private var isModifyingReply = false + + fun loadReplies(postId: Long, parentComment: CreatorChannelCommunityCommentUiModel) { + val formattedParentComment = parentComment.copy( + createdAtText = relativeTimeTextFormatter.format(parentComment.createdAtUtc) + ) + this.postId = postId + this.parentComment = formattedParentComment + replyRequestGeneration++ + isLoadingReplies = false + _replyStateLiveData.value = CreatorChannelCommunityReplyUiState.Content( + parentComment = formattedParentComment, + replies = emptyList(), + replyPage = FIRST_PAGE, + hasNextReply = false + ) + if (formattedParentComment.commentId > 0) { + loadReplyPage(page = FIRST_PAGE, append = false) + } + } + + fun loadMoreReplies() { + val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return + if (!content.hasNextReply || isLoadingReplies) return + + loadReplyPage(page = content.replyPage + 1, append = true) + } + + fun updateReplyInput(input: String) { + val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return + _replyStateLiveData.value = content.copy(replyInput = input) + } + + fun submitReply() { + val content = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return + val comment = content.replyInput.trim() + val commentId = content.parentComment.commentId + if (postId <= 0 || commentId <= 0 || comment.isBlank() || isSendingReply) return + + isSendingReply = true + _replyStateLiveData.value = content.copy(isSendingReply = true) + compositeDisposable.add( + legacyRepository.registerComment( + postId = postId, + comment = comment, + parentId = commentId, + isSecret = false, + token = authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> handleWriteResponse(response) }, + { + isSendingReply = false + val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content + ?: return@subscribe + showMutationFailureToast() + _replyStateLiveData.value = current.copy(isSendingReply = false) + } + ) + ) + } + + fun consumeReplyWrittenEvent() { + _replyWrittenEventLiveData.value = false + } + + fun consumeReplyChangedEvent() { + _replyChangedEventLiveData.value = false + } + + fun consumeParentCommentChangedEvent() { + _parentCommentChangedEventLiveData.value = false + } + + fun consumeParentCommentDeletedEvent() { + _parentCommentDeletedEventLiveData.value = false + } + + fun modifyReply(replyId: Long, comment: String) { + val trimmedComment = comment.trim() + if (replyId <= 0 || isModifyingReply) return + if (trimmedComment.isBlank()) { + showBlankCommentToast() + return + } + + modifyComment(ModifyCommentRequest(commentId = replyId, comment = trimmedComment)) + } + + fun deleteReply(replyId: Long) { + if (replyId <= 0 || isModifyingReply) return + + modifyComment(ModifyCommentRequest(commentId = replyId, isActive = false)) + } + + fun modifyParentComment(comment: String) { + val parentComment = parentComment ?: return + val trimmedComment = comment.trim() + if (parentComment.commentId <= 0 || isModifyingReply) return + if (trimmedComment.isBlank()) { + showBlankCommentToast() + return + } + + modifyParentComment(parentComment, trimmedComment) + } + + fun deleteParentComment() { + val parentComment = parentComment ?: return + if (parentComment.commentId <= 0 || isModifyingReply) return + + deleteParentComment(parentComment.commentId) + } + + private fun modifyParentComment(parentComment: CreatorChannelCommunityCommentUiModel, comment: String) { + isModifyingReply = true + compositeDisposable.add( + legacyRepository.modifyComment( + ModifyCommentRequest(commentId = parentComment.commentId, comment = comment), + authToken() + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + isModifyingReply = false + if (response.success) { + val updatedParent = parentComment.copy(comment = comment) + this.parentComment = updatedParent + val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content + ?: return@subscribe + _replyStateLiveData.value = current.copy(parentComment = updatedParent) + _parentCommentChangedEventLiveData.value = true + } else { + showMutationFailureToast(response.message) + } + }, + { + isModifyingReply = false + showMutationFailureToast() + } + ) + ) + } + + private fun deleteParentComment(commentId: Long) { + isModifyingReply = true + compositeDisposable.add( + legacyRepository.modifyComment(ModifyCommentRequest(commentId = commentId, isActive = false), authToken()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + isModifyingReply = false + if (response.success) { + _parentCommentDeletedEventLiveData.value = true + } else { + showMutationFailureToast(response.message) + } + }, + { + isModifyingReply = false + showMutationFailureToast() + } + ) + ) + } + + private fun loadReplyPage(page: Int, append: Boolean) { + val commentId = parentComment?.commentId ?: return + if (commentId <= 0 || isLoadingReplies && append) return + val generation = if (append) { + replyRequestGeneration + } else { + ++replyRequestGeneration + } + + isLoadingReplies = true + val beforeRequest = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content + if (beforeRequest != null) { + _replyStateLiveData.value = beforeRequest.copy(isLoadingReplies = true) + } + compositeDisposable.add( + repository.getCommunityCommentReplies(commentId, page, DEFAULT_PAGE_SIZE, authToken()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> handleRepliesResponse(response, append, generation) }, + { + if (generation != replyRequestGeneration) return@subscribe + + isLoadingReplies = false + val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content + ?: return@subscribe + _replyStateLiveData.value = current.copy(isLoadingReplies = false) + } + ) + ) + } + + private fun handleRepliesResponse( + response: ApiResponse, + append: Boolean, + generation: Int + ) { + if (generation != replyRequestGeneration) return + + isLoadingReplies = false + val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return + val data = response.data + if (!response.success || data == null) { + _replyStateLiveData.value = current.copy(isLoadingReplies = false) + return + } + + val nextReplies = data.replies.map { it.toUiModel() } + _replyStateLiveData.value = current.copy( + replies = if (append) current.replies + nextReplies else nextReplies, + replyPage = data.page, + hasNextReply = data.hasNext, + isLoadingReplies = false + ) + } + + private fun handleWriteResponse(response: ApiResponse) { + isSendingReply = false + val current = _replyStateLiveData.value as? CreatorChannelCommunityReplyUiState.Content ?: return + if (response.success) { + _replyWrittenEventLiveData.value = true + _replyChangedEventLiveData.value = true + _replyStateLiveData.value = current.copy(replyInput = "", isSendingReply = false) + loadReplyPage(page = FIRST_PAGE, append = false) + } else { + showMutationFailureToast(response.message) + _replyStateLiveData.value = current.copy(isSendingReply = false) + } + } + + private fun modifyComment(request: ModifyCommentRequest) { + val commentId = parentComment?.commentId ?: return + if (commentId <= 0) return + + isModifyingReply = true + compositeDisposable.add( + legacyRepository.modifyComment(request, authToken()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { response -> + isModifyingReply = false + if (response.success) { + _replyChangedEventLiveData.value = true + loadReplyPage(page = FIRST_PAGE, append = false) + } else { + showMutationFailureToast(response.message) + } + }, + { + isModifyingReply = 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 CreatorChannelCommunityReplyResponse.toUiModel() = CreatorChannelCommunityReplyUiModel( + replyId = commentId, + commentId = parentComment?.commentId ?: 0L, + memberId = writerId, + memberNickname = writerNickname, + memberProfileUrl = writerProfileImageUrl, + createdAtUtc = createdAtUtc, + createdAtText = relativeTimeTextFormatter.format(createdAtUtc), + comment = content, + likeCount = 0, + isLiked = false, + isMine = writerId == SharedPreferenceManager.userId, + isCreatorOwner = parentComment?.isCreatorOwner == true + ) + + private fun authToken(): String = "Bearer ${SharedPreferenceManager.token}" + + companion object { + const val DEFAULT_PAGE_SIZE = 20 + private const val FIRST_PAGE = 0 + } +} + +sealed interface CreatorChannelCommunityReplyUiState { + data class Content( + val parentComment: CreatorChannelCommunityCommentUiModel, + val replies: List, + val replyPage: Int, + val hasNextReply: Boolean, + val isLoadingReplies: Boolean = false, + val replyInput: String = "", + val isSendingReply: Boolean = false + ) : CreatorChannelCommunityReplyUiState +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityReplyViewModelTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityReplyViewModelTest.kt new file mode 100644 index 00000000..eea36f46 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/creator/channel/community/CreatorChannelCommunityReplyViewModelTest.kt @@ -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 + + @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>() + 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 { commentId == REPLY_ID && comment == "수정 댓글" && isActive == null }, + org.mockito.kotlin.eq(AUTH_TOKEN) + ) + verify(legacyRepository).modifyComment( + argThat { 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 { 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 { 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>() + 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, + 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 LiveData.requireValue(): T? { + var value: T? = null + val observer = Observer { 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" + } +}