feat(creator-channel): 커뮤니티 답글 상태 관리를 추가한다
This commit is contained in:
@@ -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<CreatorChannelCommunityReplyUiState>()
|
||||
val replyStateLiveData: LiveData<CreatorChannelCommunityReplyUiState>
|
||||
get() = _replyStateLiveData
|
||||
|
||||
private val _replyWrittenEventLiveData = MutableLiveData<Boolean>()
|
||||
val replyWrittenEventLiveData: LiveData<Boolean>
|
||||
get() = _replyWrittenEventLiveData
|
||||
|
||||
private val _replyChangedEventLiveData = MutableLiveData<Boolean>()
|
||||
val replyChangedEventLiveData: LiveData<Boolean>
|
||||
get() = _replyChangedEventLiveData
|
||||
|
||||
private val _parentCommentChangedEventLiveData = MutableLiveData<Boolean>()
|
||||
val parentCommentChangedEventLiveData: LiveData<Boolean>
|
||||
get() = _parentCommentChangedEventLiveData
|
||||
|
||||
private val _parentCommentDeletedEventLiveData = MutableLiveData<Boolean>()
|
||||
val parentCommentDeletedEventLiveData: LiveData<Boolean>
|
||||
get() = _parentCommentDeletedEventLiveData
|
||||
|
||||
private val _toastLiveData = MutableLiveData<CreatorChannelEvent<ToastMessage>>()
|
||||
val toastLiveData: LiveData<CreatorChannelEvent<ToastMessage>>
|
||||
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<CreatorChannelCommunityRepliesResponse>,
|
||||
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<Any>) {
|
||||
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<CreatorChannelCommunityReplyUiModel>,
|
||||
val replyPage: Int,
|
||||
val hasNextReply: Boolean,
|
||||
val isLoadingReplies: Boolean = false,
|
||||
val replyInput: String = "",
|
||||
val isSendingReply: Boolean = false
|
||||
) : CreatorChannelCommunityReplyUiState
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user