feat(chat): DM 채팅방 ViewModel을 추가한다

This commit is contained in:
2026-06-10 18:48:27 +09:00
parent 406c377d13
commit 56f110c548
4 changed files with 931 additions and 0 deletions

View File

@@ -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)
}

View File

@@ -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))
}