Skip to content

Commit 04723d2

Browse files
test(chat): cover the initial-load unread boundary decisions
Covers loadInitialMessages' choice between trusting the cached window and re-fetching it: the backlog is closed from the newest cached message when the latest chat block reaches the last read message (or has no history below it), while a block floating above the boundary and an empty cache are repaired with a fetch anchored at lastReadMessage for large backlogs and a newest-messages fetch for small ones. Assisted-by: Claude Code:claude-fable-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 0bc1391 commit 04723d2

1 file changed

Lines changed: 222 additions & 0 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

Comments
 (0)