feat(creator-channel): 커뮤니티 답글 상태 관리를 추가한다
This commit is contained in:
@@ -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