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