feat(chat): DM 채팅방 ViewModel을 추가한다
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.orhanobut.logger.Logger
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
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.v2.main.chat.dm.data.CreateDmChatRoomResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageUiItem
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.mergeByMessageId
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.sortByCreatedAtAndMessageId
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItem
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.toUiItems
|
||||
|
||||
class DmChatRoomViewModel(
|
||||
private val repository: DmChatRepository
|
||||
) : BaseViewModel() {
|
||||
|
||||
private var currentRoomId: Long = 0L
|
||||
private var opponentNickname: String = ""
|
||||
private var opponentProfileImageUrl: String = ""
|
||||
private var currentMessages: List<DmChatMessageUiItem> = emptyList()
|
||||
private var hasMore: Boolean = false
|
||||
private var nextCursor: Long? = null
|
||||
private var isLoadingOlder: Boolean = false
|
||||
private var isSending: Boolean = false
|
||||
private var localMessageSequence: Long = 0L
|
||||
|
||||
private val _chatRoomStateLiveData = MutableLiveData<DmChatRoomUiState>()
|
||||
val chatRoomStateLiveData: LiveData<DmChatRoomUiState>
|
||||
get() = _chatRoomStateLiveData
|
||||
|
||||
private val _finishEventLiveData = MutableLiveData<Boolean>()
|
||||
val finishEventLiveData: LiveData<Boolean>
|
||||
get() = _finishEventLiveData
|
||||
|
||||
private val _prependedMessageCountLiveData = MutableLiveData(0)
|
||||
val prependedMessageCountLiveData: LiveData<Int>
|
||||
get() = _prependedMessageCountLiveData
|
||||
|
||||
fun enter(roomId: Long, creatorId: Long) {
|
||||
when {
|
||||
roomId > 0L -> openRoom(roomId)
|
||||
creatorId > 0L -> createRoomAndOpen(creatorId)
|
||||
else -> _finishEventLiveData.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun loadOlderMessages() {
|
||||
val roomId = currentRoomId
|
||||
val cursor = nextCursor
|
||||
if (roomId <= 0L || !hasMore || cursor == null || isLoadingOlder) return
|
||||
|
||||
isLoadingOlder = true
|
||||
emitContent()
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.getMessages(
|
||||
token = authToken(),
|
||||
roomId = roomId,
|
||||
cursor = cursor
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ handleOlderMessagesResult(it) },
|
||||
{
|
||||
isLoadingOlder = false
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
emitContent()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun sendText(text: String) {
|
||||
val trimmed = text.trim()
|
||||
if (trimmed.isBlank() || currentRoomId <= 0L || isSending) return
|
||||
|
||||
val localId = nextLocalId()
|
||||
val localItem = DmChatMessageUiItem(
|
||||
messageId = null,
|
||||
localId = localId,
|
||||
mine = true,
|
||||
textMessage = trimmed,
|
||||
senderNickname = "",
|
||||
senderProfileImageUrl = "",
|
||||
createdAt = System.currentTimeMillis(),
|
||||
status = DmChatMessageStatus.SENDING
|
||||
)
|
||||
currentMessages = currentMessages + localItem
|
||||
isSending = true
|
||||
emitContent()
|
||||
|
||||
sendLocalMessage(localId = localId, text = trimmed)
|
||||
}
|
||||
|
||||
fun retry(localId: String) {
|
||||
val failedItem = currentMessages.firstOrNull {
|
||||
it.localId == localId && it.status == DmChatMessageStatus.FAILED
|
||||
} ?: return
|
||||
if (isSending || currentRoomId <= 0L) return
|
||||
|
||||
currentMessages = currentMessages.map {
|
||||
if (it.localId == localId) it.copy(status = DmChatMessageStatus.SENDING) else it
|
||||
}
|
||||
isSending = true
|
||||
emitContent()
|
||||
|
||||
sendLocalMessage(localId = localId, text = failedItem.textMessage)
|
||||
}
|
||||
|
||||
fun onRealtimeMessage(message: DmChatMessageResponse) {
|
||||
val item = message.toUiItem() ?: return
|
||||
currentMessages = currentMessages.mergeByMessageId(listOf(item))
|
||||
emitContent()
|
||||
}
|
||||
|
||||
fun syncLatestMessagesAfterReconnect() {
|
||||
val roomId = currentRoomId
|
||||
if (roomId <= 0L) return
|
||||
|
||||
compositeDisposable.add(
|
||||
repository.getMessages(
|
||||
token = authToken(),
|
||||
roomId = roomId,
|
||||
cursor = null
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{
|
||||
val data = it.data
|
||||
if (it.success && data != null) {
|
||||
currentMessages = currentMessages.mergeByMessageId(data.messages.toUiItems())
|
||||
emitContent()
|
||||
}
|
||||
},
|
||||
{ it.message?.let { message -> Logger.e(message) } }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRoomAndOpen(creatorId: Long) {
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Loading
|
||||
compositeDisposable.add(
|
||||
repository.createOrGetRoom(token = authToken(), creatorId = creatorId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.flatMap { response ->
|
||||
val data = response.requireData()
|
||||
repository.openRoom(token = authToken(), roomId = data.roomId)
|
||||
}
|
||||
.subscribe(
|
||||
{ handleOpenRoomResult(it) },
|
||||
{ handleError(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun openRoom(roomId: Long) {
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Loading
|
||||
compositeDisposable.add(
|
||||
repository.openRoom(token = authToken(), roomId = roomId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ handleOpenRoomResult(it) },
|
||||
{ handleError(it) }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendLocalMessage(localId: String, text: String) {
|
||||
compositeDisposable.add(
|
||||
repository.sendTextMessage(
|
||||
token = authToken(),
|
||||
roomId = currentRoomId,
|
||||
textMessage = text
|
||||
)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ handleSendResult(localId, it) },
|
||||
{
|
||||
it.message?.let { message -> Logger.e(message) }
|
||||
markLocalMessageFailed(localId)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleOpenRoomResult(response: ApiResponse<DmChatRoomOpenResponse>) {
|
||||
val data = response.data
|
||||
if (!response.success || data == null) {
|
||||
showError(response.message)
|
||||
return
|
||||
}
|
||||
|
||||
currentRoomId = data.roomId
|
||||
opponentNickname = data.opponentNickname
|
||||
opponentProfileImageUrl = data.opponentProfileImageUrl
|
||||
currentMessages = data.messages.toUiItems().sortByCreatedAtAndMessageId()
|
||||
hasMore = data.hasMore
|
||||
nextCursor = data.nextCursor
|
||||
isLoadingOlder = false
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun handleOlderMessagesResult(response: ApiResponse<DmChatMessagesPageResponse>) {
|
||||
isLoadingOlder = false
|
||||
val data = response.data
|
||||
if (!response.success || data == null) {
|
||||
emitContent()
|
||||
return
|
||||
}
|
||||
|
||||
val beforeIds = currentMessages.mapNotNull { it.messageId }.toSet()
|
||||
currentMessages = currentMessages.mergeByMessageId(data.messages.toUiItems())
|
||||
hasMore = data.hasMore
|
||||
nextCursor = data.nextCursor
|
||||
_prependedMessageCountLiveData.value = currentMessages.count {
|
||||
it.messageId != null && it.messageId !in beforeIds
|
||||
}
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun handleSendResult(
|
||||
localId: String,
|
||||
response: ApiResponse<SendDmChatMessageResponse>
|
||||
) {
|
||||
val message = response.data?.message
|
||||
val sentItem = if (response.success && message != null) message.toUiItem() else null
|
||||
if (sentItem == null) {
|
||||
markLocalMessageFailed(localId)
|
||||
return
|
||||
}
|
||||
|
||||
isSending = false
|
||||
currentMessages = currentMessages.map {
|
||||
if (it.localId == localId) sentItem else it
|
||||
}.deduplicateSentMessage(sentItem.messageId).sortByCreatedAtAndMessageId()
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun List<DmChatMessageUiItem>.deduplicateSentMessage(messageId: Long?): List<DmChatMessageUiItem> {
|
||||
if (messageId == null) return this
|
||||
var found = false
|
||||
return filter { item ->
|
||||
if (item.messageId != messageId) return@filter true
|
||||
if (found) return@filter false
|
||||
found = true
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun markLocalMessageFailed(localId: String) {
|
||||
isSending = false
|
||||
currentMessages = currentMessages.map {
|
||||
if (it.localId == localId) it.copy(status = DmChatMessageStatus.FAILED) else it
|
||||
}
|
||||
emitContent()
|
||||
}
|
||||
|
||||
private fun handleError(throwable: Throwable) {
|
||||
throwable.message?.let { Logger.e(it) }
|
||||
showError(throwable.message)
|
||||
}
|
||||
|
||||
private fun showError(message: String?) {
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Error(message)
|
||||
}
|
||||
|
||||
private fun emitContent() {
|
||||
if (currentRoomId <= 0L) return
|
||||
_chatRoomStateLiveData.value = DmChatRoomUiState.Content(
|
||||
roomId = currentRoomId,
|
||||
opponentNickname = opponentNickname,
|
||||
opponentProfileImageUrl = opponentProfileImageUrl,
|
||||
messages = currentMessages,
|
||||
hasMore = hasMore,
|
||||
nextCursor = nextCursor,
|
||||
isLoadingOlder = isLoadingOlder
|
||||
)
|
||||
}
|
||||
|
||||
private fun nextLocalId(): String {
|
||||
localMessageSequence += 1L
|
||||
return "local-$localMessageSequence"
|
||||
}
|
||||
|
||||
private fun authToken(): String = SharedPreferenceManager.token
|
||||
|
||||
private fun ApiResponse<CreateDmChatRoomResponse>.requireData(): CreateDmChatRoomResponse {
|
||||
if (success && data != null) return data
|
||||
throw IllegalStateException(message)
|
||||
}
|
||||
}
|
||||
@@ -16,3 +16,19 @@ data class DmChatMessageUiItem(
|
||||
val createdAt: Long,
|
||||
val status: DmChatMessageStatus
|
||||
)
|
||||
|
||||
sealed class DmChatRoomUiState {
|
||||
data object Loading : DmChatRoomUiState()
|
||||
|
||||
data class Content(
|
||||
val roomId: Long,
|
||||
val opponentNickname: String,
|
||||
val opponentProfileImageUrl: String,
|
||||
val messages: List<DmChatMessageUiItem>,
|
||||
val hasMore: Boolean,
|
||||
val nextCursor: Long?,
|
||||
val isLoadingOlder: Boolean = false
|
||||
) : DmChatRoomUiState()
|
||||
|
||||
data class Error(val message: String?) : DmChatRoomUiState()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
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.SingleSubject
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.message
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.messagesPage
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.openResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.DmChatRoomViewModelTest.Companion.requireValue
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||
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.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], application = Application::class)
|
||||
class DmChatPaginationStateTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var api: FakeDmChatApi
|
||||
private lateinit var viewModel: DmChatRoomViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setImmediateRxSchedulers()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
SharedPreferenceManager.init(context)
|
||||
SharedPreferenceManager.token = "test-token"
|
||||
api = FakeDmChatApi()
|
||||
viewModel = DmChatRoomViewModel(repository = DmChatRepository(api))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
RxJavaPlugins.reset()
|
||||
RxAndroidPlugins.reset()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasMore가 false이면 과거 메시지를 요청하지 않는다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, hasMore = false, nextCursor = 1L))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
|
||||
assertTrue(api.messagesCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nextCursor가 null이면 과거 메시지를 요청하지 않는다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, hasMore = true, nextCursor = null))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
|
||||
assertTrue(api.messagesCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `과거 메시지 로딩 중 중복 요청은 무시한다`() {
|
||||
val pendingOlder = SingleSubject.create<ApiResponse<DmChatMessagesPageResponse>>()
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, hasMore = true, nextCursor = 50L))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
api.enqueueMessagesSuccessSubject(pendingOlder)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
viewModel.loadOlderMessages()
|
||||
|
||||
assertEquals(1, api.messagesCalls.size)
|
||||
val loadingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertTrue(loadingState.isLoadingOlder)
|
||||
|
||||
pendingOlder.onSuccess(ApiResponse(success = true, data = messagesPage(messages = emptyList())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `과거 메시지 요청 실패는 로딩 상태를 해제하고 기존 목록을 유지한다`() {
|
||||
api.enqueueOpenSuccess(
|
||||
openResponse(
|
||||
roomId = 10L,
|
||||
messages = listOf(message(messageId = 2L, createdAt = 200L, textMessage = "현재")),
|
||||
hasMore = true,
|
||||
nextCursor = 50L
|
||||
)
|
||||
)
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
api.enqueueMessages(Single.error(IllegalStateException("network")))
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertFalse(state.isLoadingOlder)
|
||||
assertEquals(listOf(2L), state.messages.map { it.messageId })
|
||||
assertEquals(1, api.messagesCalls.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `과거 메시지는 cursor로 요청하고 기존 목록 상단에 prepend하며 중복을 제거한다`() {
|
||||
api.enqueueOpenSuccess(
|
||||
openResponse(
|
||||
roomId = 10L,
|
||||
messages = listOf(message(messageId = 2L, createdAt = 200L, textMessage = "현재")),
|
||||
hasMore = true,
|
||||
nextCursor = 50L
|
||||
)
|
||||
)
|
||||
api.enqueueMessagesSuccess(
|
||||
messagesPage(
|
||||
messages = listOf(
|
||||
message(messageId = 1L, createdAt = 100L, textMessage = "과거"),
|
||||
message(messageId = 2L, createdAt = 200L, textMessage = "중복")
|
||||
),
|
||||
hasMore = false,
|
||||
nextCursor = null
|
||||
)
|
||||
)
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.loadOlderMessages()
|
||||
|
||||
assertEquals(listOf(MessagesCall("Bearer test-token", 10L, 50L, 20)), api.messagesCalls)
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||
assertEquals(listOf("과거", "현재"), state.messages.map { it.textMessage })
|
||||
assertFalse(state.hasMore)
|
||||
assertEquals(1, viewModel.prependedMessageCountLiveData.requireValue())
|
||||
}
|
||||
|
||||
private fun setImmediateRxSchedulers() {
|
||||
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
}
|
||||
|
||||
private fun FakeDmChatApi.enqueueMessagesSuccessSubject(
|
||||
subject: SingleSubject<ApiResponse<DmChatMessagesPageResponse>>
|
||||
) = enqueueMessages(subject)
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
package kr.co.vividnext.sodalive.v2.main.chat.dm
|
||||
|
||||
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.SingleSubject
|
||||
import kr.co.vividnext.sodalive.common.ApiResponse
|
||||
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomRequest
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.CreateDmChatRoomResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatApi
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatMessagesPageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRepository
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.DmChatRoomOpenResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmChatMessageResponse
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.data.SendDmTextMessageRequest
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatMessageStatus
|
||||
import kr.co.vividnext.sodalive.v2.main.chat.dm.model.DmChatRoomUiState
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], application = Application::class)
|
||||
class DmChatRoomViewModelTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var api: FakeDmChatApi
|
||||
private lateinit var viewModel: DmChatRoomViewModel
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
setImmediateRxSchedulers()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
SharedPreferenceManager.init(context)
|
||||
SharedPreferenceManager.token = "test-token"
|
||||
api = FakeDmChatApi()
|
||||
viewModel = DmChatRoomViewModel(repository = DmChatRepository(api))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
RxJavaPlugins.reset()
|
||||
RxAndroidPlugins.reset()
|
||||
SharedPreferenceManager.resetForTest()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `roomId 진입은 create 없이 openRoom을 호출하고 메시지를 정렬한다`() {
|
||||
api.enqueueOpenSuccess(
|
||||
openResponse(
|
||||
roomId = 10L,
|
||||
messages = listOf(
|
||||
message(messageId = 2L, createdAt = 200L, textMessage = "둘"),
|
||||
message(messageId = 1L, createdAt = 100L, textMessage = "하나")
|
||||
),
|
||||
hasMore = true,
|
||||
nextCursor = 1L
|
||||
)
|
||||
)
|
||||
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
assertTrue(api.createCalls.isEmpty())
|
||||
assertEquals(listOf(OpenCall("Bearer test-token", 10L, 20)), api.openCalls)
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(10L, state.roomId)
|
||||
assertEquals("상대", state.opponentNickname)
|
||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||
assertTrue(state.hasMore)
|
||||
assertEquals(1L, state.nextCursor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creatorId 진입은 createOrGetRoom 후 반환 roomId로 openRoom을 호출한다`() {
|
||||
api.enqueueCreateSuccess(CreateDmChatRoomResponse(roomId = 12L))
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 12L))
|
||||
|
||||
viewModel.enter(roomId = 0L, creatorId = 99L)
|
||||
|
||||
assertEquals(listOf(CreateCall("Bearer test-token", CreateDmChatRoomRequest(creatorId = 99L))), api.createCalls)
|
||||
assertEquals(listOf(OpenCall("Bearer test-token", 12L, 20)), api.openCalls)
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(12L, state.roomId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `유효하지 않은 진입 값은 종료 이벤트를 발행한다`() {
|
||||
viewModel.enter(roomId = 0L, creatorId = 0L)
|
||||
|
||||
assertTrue(viewModel.finishEventLiveData.requireValue() == true)
|
||||
assertTrue(api.createCalls.isEmpty())
|
||||
assertTrue(api.openCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank 전송은 요청하지 않고 pending 메시지를 추가하지 않는다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.sendText(" ")
|
||||
|
||||
assertTrue(api.sendCalls.isEmpty())
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertTrue(state.messages.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `전송 직후 pending을 추가하고 성공 시 서버 메시지로 교체한다`() {
|
||||
val pendingSend = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||
api.enqueueSend(pendingSend)
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.sendText(" 안녕 ")
|
||||
val sendingState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
|
||||
assertEquals(listOf(SendCall("Bearer test-token", 10L, SendDmTextMessageRequest("안녕"))), api.sendCalls)
|
||||
assertEquals(DmChatMessageStatus.SENDING, sendingState.messages.single().status)
|
||||
assertEquals("안녕", sendingState.messages.single().textMessage)
|
||||
|
||||
pendingSend.onSuccess(
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = SendDmChatMessageResponse(
|
||||
message = message(messageId = 30L, mine = true, textMessage = "안녕"),
|
||||
deliveredRealtime = true,
|
||||
pushSent = false
|
||||
)
|
||||
)
|
||||
)
|
||||
val sentState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(30L), sentState.messages.map { it.messageId })
|
||||
assertEquals(DmChatMessageStatus.SENT, sentState.messages.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `전송 중 새 전송 중복 요청은 무시한다`() {
|
||||
val pendingSend = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||
api.enqueueSend(pendingSend)
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.sendText("안녕")
|
||||
viewModel.sendText("안녕")
|
||||
|
||||
assertEquals(1, api.sendCalls.size)
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(1, state.messages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `전송 실패는 pending 메시지를 FAILED로 바꾸고 retry 성공 시 교체한다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||
api.enqueueSend(Single.error(IllegalStateException("network")))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.sendText("안녕")
|
||||
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
val failedItem = failedState.messages.single()
|
||||
assertEquals(DmChatMessageStatus.FAILED, failedItem.status)
|
||||
|
||||
api.enqueueSendSuccess(message(messageId = 40L, mine = true, textMessage = "안녕"))
|
||||
viewModel.retry(failedItem.localId!!)
|
||||
|
||||
val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(40L), retriedState.messages.map { it.messageId })
|
||||
assertEquals(DmChatMessageStatus.SENT, retriedState.messages.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry 중 SSE echo가 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() {
|
||||
val pendingRetry = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||
api.enqueueSend(Single.error(IllegalStateException("network")))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
viewModel.sendText("안녕")
|
||||
val failedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
val failedItem = failedState.messages.single()
|
||||
api.enqueueSend(pendingRetry)
|
||||
|
||||
viewModel.retry(failedItem.localId!!)
|
||||
viewModel.onRealtimeMessage(message(messageId = 45L, mine = true, textMessage = "안녕"))
|
||||
pendingRetry.onSuccess(
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = SendDmChatMessageResponse(
|
||||
message = message(messageId = 45L, mine = true, textMessage = "안녕"),
|
||||
deliveredRealtime = true,
|
||||
pushSent = false
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val retriedState = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(45L), retriedState.messages.map { it.messageId })
|
||||
assertEquals(1, retriedState.messages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE 메시지는 messageId 중복을 제거하고 최신 메시지를 추가한다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.onRealtimeMessage(message(messageId = 1L, textMessage = "중복"))
|
||||
viewModel.onRealtimeMessage(message(messageId = 2L, createdAt = 200L, textMessage = "신규"))
|
||||
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||
assertEquals(listOf("기존", "신규"), state.messages.map { it.textMessage })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE echo가 전송 성공보다 먼저 와도 성공 교체 후 messageId 중복을 남기지 않는다`() {
|
||||
val pendingSend = SingleSubject.create<ApiResponse<SendDmChatMessageResponse>>()
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L))
|
||||
api.enqueueSend(pendingSend)
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.sendText("안녕")
|
||||
viewModel.onRealtimeMessage(message(messageId = 50L, mine = true, textMessage = "안녕"))
|
||||
pendingSend.onSuccess(
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = SendDmChatMessageResponse(
|
||||
message = message(messageId = 50L, mine = true, textMessage = "안녕"),
|
||||
deliveredRealtime = true,
|
||||
pushSent = false
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(50L), state.messages.map { it.messageId })
|
||||
assertEquals(1, state.messages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `재연결 후 최신 메시지 동기화는 getMessages 결과를 병합한다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
|
||||
api.enqueueMessagesSuccess(
|
||||
messagesPage(
|
||||
messages = listOf(message(messageId = 2L, createdAt = 200L, textMessage = "동기화"))
|
||||
)
|
||||
)
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.syncLatestMessagesAfterReconnect()
|
||||
|
||||
assertEquals(listOf(MessagesCall("Bearer test-token", 10L, null, 20)), api.messagesCalls)
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(1L, 2L), state.messages.map { it.messageId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `재연결 후 최신 메시지 동기화 실패는 기존 메시지를 유지한다`() {
|
||||
api.enqueueOpenSuccess(openResponse(roomId = 10L, messages = listOf(message(messageId = 1L, textMessage = "기존"))))
|
||||
api.enqueueMessages(Single.error(IllegalStateException("network")))
|
||||
viewModel.enter(roomId = 10L, creatorId = 0L)
|
||||
|
||||
viewModel.syncLatestMessagesAfterReconnect()
|
||||
|
||||
val state = viewModel.chatRoomStateLiveData.requireValue() as DmChatRoomUiState.Content
|
||||
assertEquals(listOf(1L), state.messages.map { it.messageId })
|
||||
assertEquals(listOf("기존"), state.messages.map { it.textMessage })
|
||||
}
|
||||
|
||||
private fun setImmediateRxSchedulers() {
|
||||
val trampoline = { _: Scheduler -> Schedulers.trampoline() }
|
||||
RxJavaPlugins.setIoSchedulerHandler(trampoline)
|
||||
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun openResponse(
|
||||
roomId: Long,
|
||||
messages: List<DmChatMessageResponse> = emptyList(),
|
||||
hasMore: Boolean = false,
|
||||
nextCursor: Long? = null
|
||||
) = DmChatRoomOpenResponse(
|
||||
roomId = roomId,
|
||||
opponentNickname = "상대",
|
||||
opponentProfileImageUrl = "https://example.com/profile.png",
|
||||
messages = messages,
|
||||
hasMore = hasMore,
|
||||
nextCursor = nextCursor
|
||||
)
|
||||
|
||||
fun messagesPage(
|
||||
messages: List<DmChatMessageResponse>,
|
||||
hasMore: Boolean = false,
|
||||
nextCursor: Long? = null
|
||||
) = DmChatMessagesPageResponse(
|
||||
messages = messages,
|
||||
hasMore = hasMore,
|
||||
nextCursor = nextCursor
|
||||
)
|
||||
|
||||
fun message(
|
||||
messageId: Long,
|
||||
messageType: String = "TEXT",
|
||||
mine: Boolean = false,
|
||||
createdAt: Long = 100L,
|
||||
textMessage: String? = "메시지"
|
||||
) = DmChatMessageResponse(
|
||||
messageId = messageId,
|
||||
messageType = messageType,
|
||||
mine = mine,
|
||||
createdAt = createdAt,
|
||||
textMessage = textMessage,
|
||||
voiceMessageUrl = null,
|
||||
senderId = if (mine) 1L else 2L,
|
||||
senderNickname = if (mine) "나" else "상대",
|
||||
senderProfileImageUrl = "https://example.com/profile.png"
|
||||
)
|
||||
|
||||
fun <T> LiveData<T>.requireValue(): T? {
|
||||
var value: T? = null
|
||||
val observer = Observer<T> { value = it }
|
||||
observeForever(observer)
|
||||
removeObserver(observer)
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CreateCall(
|
||||
val authHeader: String,
|
||||
val request: CreateDmChatRoomRequest
|
||||
)
|
||||
|
||||
data class OpenCall(
|
||||
val authHeader: String,
|
||||
val roomId: Long,
|
||||
val limit: Int
|
||||
)
|
||||
|
||||
data class MessagesCall(
|
||||
val authHeader: String,
|
||||
val roomId: Long,
|
||||
val cursor: Long?,
|
||||
val limit: Int
|
||||
)
|
||||
|
||||
data class SendCall(
|
||||
val authHeader: String,
|
||||
val roomId: Long,
|
||||
val request: SendDmTextMessageRequest
|
||||
)
|
||||
|
||||
class FakeDmChatApi : DmChatApi {
|
||||
val createCalls = mutableListOf<CreateCall>()
|
||||
val openCalls = mutableListOf<OpenCall>()
|
||||
val messagesCalls = mutableListOf<MessagesCall>()
|
||||
val sendCalls = mutableListOf<SendCall>()
|
||||
|
||||
private val createResponses = ArrayDeque<Single<ApiResponse<CreateDmChatRoomResponse>>>()
|
||||
private val openResponses = ArrayDeque<Single<ApiResponse<DmChatRoomOpenResponse>>>()
|
||||
private val messagesResponses = ArrayDeque<Single<ApiResponse<DmChatMessagesPageResponse>>>()
|
||||
private val sendResponses = ArrayDeque<Single<ApiResponse<SendDmChatMessageResponse>>>()
|
||||
|
||||
fun enqueueCreateSuccess(response: CreateDmChatRoomResponse) {
|
||||
createResponses.addLast(Single.just(ApiResponse(success = true, data = response)))
|
||||
}
|
||||
|
||||
fun enqueueOpenSuccess(response: DmChatRoomOpenResponse) {
|
||||
openResponses.addLast(Single.just(ApiResponse(success = true, data = response)))
|
||||
}
|
||||
|
||||
fun enqueueMessagesSuccess(response: DmChatMessagesPageResponse) {
|
||||
messagesResponses.addLast(Single.just(ApiResponse(success = true, data = response)))
|
||||
}
|
||||
|
||||
fun enqueueMessages(response: Single<ApiResponse<DmChatMessagesPageResponse>>) {
|
||||
messagesResponses.addLast(response)
|
||||
}
|
||||
|
||||
fun enqueueSend(response: Single<ApiResponse<SendDmChatMessageResponse>>) {
|
||||
sendResponses.addLast(response)
|
||||
}
|
||||
|
||||
fun enqueueSendSuccess(message: DmChatMessageResponse) {
|
||||
sendResponses.addLast(
|
||||
Single.just(
|
||||
ApiResponse(
|
||||
success = true,
|
||||
data = SendDmChatMessageResponse(
|
||||
message = message,
|
||||
deliveredRealtime = true,
|
||||
pushSent = false
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun createDmChatRoom(
|
||||
authHeader: String,
|
||||
request: CreateDmChatRoomRequest
|
||||
): Single<ApiResponse<CreateDmChatRoomResponse>> {
|
||||
createCalls.add(CreateCall(authHeader, request))
|
||||
return createResponses.removeFirst()
|
||||
}
|
||||
|
||||
override fun openDmChatRoom(
|
||||
authHeader: String,
|
||||
roomId: Long,
|
||||
limit: Int
|
||||
): Single<ApiResponse<DmChatRoomOpenResponse>> {
|
||||
openCalls.add(OpenCall(authHeader, roomId, limit))
|
||||
return openResponses.removeFirst()
|
||||
}
|
||||
|
||||
override fun getDmChatMessages(
|
||||
authHeader: String,
|
||||
roomId: Long,
|
||||
cursor: Long?,
|
||||
limit: Int
|
||||
): Single<ApiResponse<DmChatMessagesPageResponse>> {
|
||||
messagesCalls.add(MessagesCall(authHeader, roomId, cursor, limit))
|
||||
return messagesResponses.removeFirst()
|
||||
}
|
||||
|
||||
override fun sendDmTextMessage(
|
||||
authHeader: String,
|
||||
roomId: Long,
|
||||
request: SendDmTextMessageRequest
|
||||
): Single<ApiResponse<SendDmChatMessageResponse>> {
|
||||
sendCalls.add(SendCall(authHeader, roomId, request))
|
||||
return sendResponses.removeFirst()
|
||||
}
|
||||
|
||||
override fun disconnectRealtime(
|
||||
authHeader: String,
|
||||
roomId: Long
|
||||
): Single<ApiResponse<Boolean>> = Single.just(ApiResponse(success = true, data = true))
|
||||
}
|
||||
Reference in New Issue
Block a user