|
| 1 | +/* |
| 2 | + * Nextcloud Talk - Android Client |
| 3 | + * |
| 4 | + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 5 | + * SPDX-License-Identifier: GPL-3.0-or-later |
| 6 | + */ |
| 7 | + |
| 8 | +package com.nextcloud.talk.chat.data.network |
| 9 | + |
| 10 | +import android.os.Bundle |
| 11 | +import com.nextcloud.talk.chat.data.model.ChatMessage |
| 12 | +import com.nextcloud.talk.data.database.dao.ChatBlocksDao |
| 13 | +import com.nextcloud.talk.data.database.dao.ChatMessagesDao |
| 14 | +import com.nextcloud.talk.data.database.model.ChatBlockEntity |
| 15 | +import com.nextcloud.talk.data.network.NetworkMonitor |
| 16 | +import com.nextcloud.talk.data.user.model.User |
| 17 | +import com.nextcloud.talk.logger.Logger |
| 18 | +import com.nextcloud.talk.models.domain.ConversationModel |
| 19 | +import com.nextcloud.talk.models.json.capabilities.Capabilities |
| 20 | +import com.nextcloud.talk.models.json.capabilities.SpreedCapability |
| 21 | +import com.nextcloud.talk.models.json.chat.ChatMessageJson |
| 22 | +import com.nextcloud.talk.models.json.chat.ChatOCS |
| 23 | +import com.nextcloud.talk.models.json.chat.ChatOverall |
| 24 | +import com.nextcloud.talk.models.json.conversations.Conversation |
| 25 | +import kotlinx.coroutines.flow.MutableStateFlow |
| 26 | +import kotlinx.coroutines.flow.flowOf |
| 27 | +import kotlinx.coroutines.test.runTest |
| 28 | +import org.junit.Assert.assertEquals |
| 29 | +import org.junit.Assert.assertFalse |
| 30 | +import org.junit.Before |
| 31 | +import org.junit.Test |
| 32 | +import org.mockito.kotlin.any |
| 33 | +import org.mockito.kotlin.argumentCaptor |
| 34 | +import org.mockito.kotlin.eq |
| 35 | +import org.mockito.kotlin.mock |
| 36 | +import org.mockito.kotlin.verifyBlocking |
| 37 | +import org.mockito.kotlin.whenever |
| 38 | +import org.mockito.kotlin.wheneverBlocking |
| 39 | +import retrofit2.Response |
| 40 | + |
| 41 | +/** |
| 42 | + * Covers the unread-boundary decisions of [OfflineFirstChatRepository.loadInitialMessages]: the |
| 43 | + * cached window (the latest chat block) is only trusted when it reaches back to the conversation's |
| 44 | + * last read message, otherwise the initial window is re-fetched — anchored at the boundary when |
| 45 | + * the unread backlog calls for it. |
| 46 | + */ |
| 47 | +class OfflineFirstChatRepositoryTest { |
| 48 | + |
| 49 | + private val logger: Logger = mock() |
| 50 | + private val chatDao: ChatMessagesDao = mock() |
| 51 | + private val chatBlocksDao: ChatBlocksDao = mock() |
| 52 | + private val network: ChatNetworkDataSource = mock() |
| 53 | + private val networkMonitor: NetworkMonitor = mock() |
| 54 | + |
| 55 | + private lateinit var repository: OfflineFirstChatRepository |
| 56 | + |
| 57 | + @Before |
| 58 | + fun setUp() { |
| 59 | + whenever(networkMonitor.isOnline).thenReturn(MutableStateFlow(true)) |
| 60 | + wheneverBlocking { chatDao.deleteExpiredMessages(eq(INTERNAL_CONVERSATION_ID), any()) }.thenReturn(0) |
| 61 | + whenever(chatBlocksDao.getChatBlocksContainingMessageId(eq(INTERNAL_CONVERSATION_ID), eq(null), any())) |
| 62 | + .thenReturn(flowOf(emptyList())) |
| 63 | + |
| 64 | + repository = OfflineFirstChatRepository( |
| 65 | + logger, |
| 66 | + chatDao, |
| 67 | + chatBlocksDao, |
| 68 | + network, |
| 69 | + networkMonitor, |
| 70 | + ChatMessageSyncer(chatDao, chatBlocksDao, network, networkMonitor) |
| 71 | + ) |
| 72 | + repository.initData(user(), CREDENTIALS, CHAT_URL, ROOM_TOKEN, null) |
| 73 | + } |
| 74 | + |
| 75 | + @Test |
| 76 | + fun `loadInitialMessages closes the backlog when the latest block reaches the last read message`() = |
| 77 | + runTest { |
| 78 | + givenLatestBlock(block(oldest = 10, newest = 50)) |
| 79 | + repository.updateConversation(conversation(lastReadMessage = 40, unreadMessages = 5)) |
| 80 | + wheneverBlocking { network.pullChatMessages(any(), any(), any()) } |
| 81 | + .thenReturn(Response.success(overall(message(51)))) |
| 82 | + |
| 83 | + repository.loadInitialMessages(Bundle()) |
| 84 | + |
| 85 | + val fieldMap = singleRequestFieldMap() |
| 86 | + assertEquals(1, fieldMap["lookIntoFuture"]) |
| 87 | + assertEquals(0, fieldMap["includeLastKnown"]) |
| 88 | + assertEquals(50, fieldMap["lastKnownMessageId"]) |
| 89 | + } |
| 90 | + |
| 91 | + @Test |
| 92 | + fun `loadInitialMessages treats a block without history below as reaching the boundary`() = |
| 93 | + runTest { |
| 94 | + givenLatestBlock(block(oldest = 45, newest = 50, hasHistory = false)) |
| 95 | + repository.updateConversation(conversation(lastReadMessage = 40, unreadMessages = 5)) |
| 96 | + wheneverBlocking { network.pullChatMessages(any(), any(), any()) } |
| 97 | + .thenReturn(Response.success(overall(message(51)))) |
| 98 | + |
| 99 | + repository.loadInitialMessages(Bundle()) |
| 100 | + |
| 101 | + val fieldMap = singleRequestFieldMap() |
| 102 | + assertEquals(1, fieldMap["lookIntoFuture"]) |
| 103 | + assertEquals(50, fieldMap["lastKnownMessageId"]) |
| 104 | + } |
| 105 | + |
| 106 | + @Test |
| 107 | + fun `loadInitialMessages anchors the repair fetch when the latest block floats above the boundary`() = |
| 108 | + runTest { |
| 109 | + givenLatestBlock(block(oldest = 100, newest = 199)) |
| 110 | + repository.updateConversation(conversation(lastReadMessage = 40, unreadMessages = 160)) |
| 111 | + wheneverBlocking { network.pullChatMessages(any(), any(), any()) } |
| 112 | + .thenReturn(Response.success(overall(message(40), message(41)))) |
| 113 | + |
| 114 | + repository.loadInitialMessages(Bundle()) |
| 115 | + |
| 116 | + val fieldMap = singleRequestFieldMap() |
| 117 | + assertEquals(1, fieldMap["lookIntoFuture"]) |
| 118 | + assertEquals(1, fieldMap["includeLastKnown"]) |
| 119 | + assertEquals(40, fieldMap["lastKnownMessageId"]) |
| 120 | + // opening the chat clears notifications, so the fetch must not keep them |
| 121 | + assertFalse(fieldMap.containsKey("markNotificationsAsRead")) |
| 122 | + } |
| 123 | + |
| 124 | + @Test |
| 125 | + fun `loadInitialMessages anchors the initial fetch for an empty cache with a large backlog`() = |
| 126 | + runTest { |
| 127 | + givenLatestBlock(null) |
| 128 | + repository.updateConversation(conversation(lastReadMessage = 40, unreadMessages = 160)) |
| 129 | + wheneverBlocking { network.pullChatMessages(any(), any(), any()) } |
| 130 | + .thenReturn(Response.success(overall(message(40), message(41)))) |
| 131 | + |
| 132 | + repository.loadInitialMessages(Bundle()) |
| 133 | + |
| 134 | + val fieldMap = singleRequestFieldMap() |
| 135 | + assertEquals(1, fieldMap["lookIntoFuture"]) |
| 136 | + assertEquals(1, fieldMap["includeLastKnown"]) |
| 137 | + assertEquals(40, fieldMap["lastKnownMessageId"]) |
| 138 | + } |
| 139 | + |
| 140 | + @Test |
| 141 | + fun `loadInitialMessages fetches the newest messages for an empty cache with a small backlog`() = |
| 142 | + runTest { |
| 143 | + givenLatestBlock(null) |
| 144 | + repository.updateConversation(conversation(lastReadMessage = 40, unreadMessages = 5)) |
| 145 | + wheneverBlocking { network.pullChatMessages(any(), any(), any()) } |
| 146 | + .thenReturn(Response.success(overall(message(44), message(45)))) |
| 147 | + |
| 148 | + repository.loadInitialMessages(Bundle()) |
| 149 | + |
| 150 | + val fieldMap = singleRequestFieldMap() |
| 151 | + assertEquals(0, fieldMap["lookIntoFuture"]) |
| 152 | + assertEquals(1, fieldMap["includeLastKnown"]) |
| 153 | + assertFalse(fieldMap.containsKey("lastKnownMessageId")) |
| 154 | + } |
| 155 | + |
| 156 | + private fun givenLatestBlock(block: ChatBlockEntity?) { |
| 157 | + whenever(chatBlocksDao.getLatestChatBlock(INTERNAL_CONVERSATION_ID, null)) |
| 158 | + .thenReturn(flowOf(block)) |
| 159 | + } |
| 160 | + |
| 161 | + private fun singleRequestFieldMap(): HashMap<String, Int> { |
| 162 | + val fieldMapCaptor = argumentCaptor<HashMap<String, Int>>() |
| 163 | + verifyBlocking(network) { pullChatMessages(eq(CREDENTIALS), eq(CHAT_URL), fieldMapCaptor.capture()) } |
| 164 | + return fieldMapCaptor.firstValue |
| 165 | + } |
| 166 | + |
| 167 | + private fun user(): User = |
| 168 | + User( |
| 169 | + id = ACCOUNT_ID, |
| 170 | + userId = "me", |
| 171 | + username = "me", |
| 172 | + baseUrl = "https://server.example.com", |
| 173 | + capabilities = Capabilities().apply { |
| 174 | + spreedCapability = SpreedCapability().apply { features = listOf("chat-keep-notifications") } |
| 175 | + } |
| 176 | + ) |
| 177 | + |
| 178 | + private fun conversation(lastReadMessage: Int, unreadMessages: Int): ConversationModel = |
| 179 | + ConversationModel.mapToConversationModel( |
| 180 | + Conversation( |
| 181 | + token = ROOM_TOKEN, |
| 182 | + lastReadMessage = lastReadMessage, |
| 183 | + unreadMessages = unreadMessages |
| 184 | + ), |
| 185 | + user() |
| 186 | + ) |
| 187 | + |
| 188 | + private fun block(oldest: Long, newest: Long, hasHistory: Boolean = true): ChatBlockEntity = |
| 189 | + ChatBlockEntity( |
| 190 | + internalConversationId = INTERNAL_CONVERSATION_ID, |
| 191 | + accountId = ACCOUNT_ID, |
| 192 | + token = ROOM_TOKEN, |
| 193 | + threadId = null, |
| 194 | + oldestMessageId = oldest, |
| 195 | + newestMessageId = newest, |
| 196 | + hasHistory = hasHistory |
| 197 | + ) |
| 198 | + |
| 199 | + private fun message(id: Long): ChatMessageJson = |
| 200 | + ChatMessageJson( |
| 201 | + id = id, |
| 202 | + token = ROOM_TOKEN, |
| 203 | + actorType = "users", |
| 204 | + actorId = "other", |
| 205 | + actorDisplayName = "Other User", |
| 206 | + timestamp = id, |
| 207 | + message = "message $id", |
| 208 | + messageType = "comment", |
| 209 | + systemMessageType = ChatMessage.SystemMessageType.DUMMY |
| 210 | + ) |
| 211 | + |
| 212 | + private fun overall(vararg messages: ChatMessageJson): ChatOverall = |
| 213 | + ChatOverall(ocs = ChatOCS(meta = null, data = messages.toList())) |
| 214 | + |
| 215 | + companion object { |
| 216 | + private const val ACCOUNT_ID = 1L |
| 217 | + private const val ROOM_TOKEN = "room1" |
| 218 | + private const val INTERNAL_CONVERSATION_ID = "$ACCOUNT_ID@$ROOM_TOKEN" |
| 219 | + private const val CREDENTIALS = "credentials" |
| 220 | + private const val CHAT_URL = "https://server.example.com/ocs/v2.php/apps/spreed/api/v1/chat/$ROOM_TOKEN" |
| 221 | + } |
| 222 | +} |
0 commit comments