Skip to content

Commit c0b96dd

Browse files
test(conversations): cover the modified-since delta sync
The case worth a test here is the one that destroys data rather than the one that annoys: a delta response lists only what changed, so every conversation it leaves out looks like one the user left, and reconciling those away takes their cached messages and chat blocks with them through the foreign key cascade. The first test seeds ten conversations with cached chat, runs a delta sync that mentions one of them, and asserts the other nine still have theirs. It was checked against the mistake it guards: with the delta branch removed from the sync, it fails. The rest cover what decides the mode - the stored timestamp is sent and includeStatus is not, the internal signaling backend never gets a delta, a full response still reconciles a conversation away, and a failed sync drops the timestamp so the next one asks for everything. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent a995372 commit c0b96dd

1 file changed

Lines changed: 296 additions & 0 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Andy Scherzinger <andy.scherzinger@nextcloud.com>
5+
* SPDX-License-Identifier: GPL-3.0-or-later
6+
*/
7+
8+
package com.nextcloud.talk.conversationlist.data.network
9+
10+
import android.app.Application
11+
import android.content.Context
12+
import androidx.room.Room
13+
import androidx.test.core.app.ApplicationProvider
14+
import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager
15+
import com.nextcloud.talk.chat.data.model.ChatMessage
16+
import com.nextcloud.talk.chat.data.network.ChatMessageSyncer
17+
import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource
18+
import com.nextcloud.talk.data.database.model.ChatBlockEntity
19+
import com.nextcloud.talk.data.database.model.ChatMessageEntity
20+
import com.nextcloud.talk.data.network.NetworkMonitor
21+
import com.nextcloud.talk.data.source.local.TalkDatabase
22+
import com.nextcloud.talk.data.storage.ArbitraryStoragesRepositoryImpl
23+
import com.nextcloud.talk.data.user.model.User
24+
import com.nextcloud.talk.data.user.model.UserEntity
25+
import com.nextcloud.talk.models.ExternalSignalingServer
26+
import com.nextcloud.talk.models.json.capabilities.Capabilities
27+
import com.nextcloud.talk.models.json.capabilities.SpreedCapability
28+
import com.nextcloud.talk.models.json.capabilities.UserStatusCapability
29+
import com.nextcloud.talk.models.json.chat.ChatOCS
30+
import com.nextcloud.talk.models.json.chat.ChatOverall
31+
import com.nextcloud.talk.models.json.conversations.Conversation
32+
import io.reactivex.Observable
33+
import io.reactivex.android.plugins.RxAndroidPlugins
34+
import io.reactivex.schedulers.Schedulers
35+
import kotlinx.coroutines.flow.MutableStateFlow
36+
import kotlinx.coroutines.flow.first
37+
import kotlinx.coroutines.runBlocking
38+
import org.junit.After
39+
import org.junit.Assert.assertEquals
40+
import org.junit.Assert.assertNull
41+
import org.junit.Assert.assertTrue
42+
import org.junit.Before
43+
import org.junit.Test
44+
import org.junit.runner.RunWith
45+
import org.mockito.kotlin.any
46+
import org.mockito.kotlin.anyOrNull
47+
import org.mockito.kotlin.argumentCaptor
48+
import org.mockito.kotlin.mock
49+
import org.mockito.kotlin.times
50+
import org.mockito.kotlin.verify
51+
import org.mockito.kotlin.whenever
52+
import org.mockito.kotlin.wheneverBlocking
53+
import org.robolectric.RobolectricTestRunner
54+
import org.robolectric.annotation.Config
55+
import retrofit2.Response
56+
57+
/**
58+
* Integration tests for the `modifiedSince` delta sync against an in-memory database.
59+
*
60+
* Covers that a delta response leaves the conversations it omits, and their cached messages and
61+
* chat blocks, in place; that a full response still removes them; what a delta request sends; that
62+
* an internal-signaling account never gets one; and that a failed sync drops the stored timestamp.
63+
*/
64+
private const val ACCOUNT_ID = 1L
65+
private const val BASE_URL = "https://server.example.com"
66+
67+
@RunWith(RobolectricTestRunner::class)
68+
@Config(application = Application::class, sdk = [33])
69+
class ConversationListDeltaSyncIntegrationTest {
70+
71+
private lateinit var db: TalkDatabase
72+
private lateinit var arbitraryStorageManager: ArbitraryStorageManager
73+
private lateinit var repository: OfflineFirstConversationsRepository
74+
75+
private val conversationsNetwork: ConversationsNetworkDataSource = mock()
76+
private val chatNetwork: ChatNetworkDataSource = mock()
77+
private val networkMonitor: NetworkMonitor = mock()
78+
79+
@Before
80+
fun setUp() {
81+
RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() }
82+
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
83+
84+
val context = ApplicationProvider.getApplicationContext<Context>()
85+
db = Room.inMemoryDatabaseBuilder(context, TalkDatabase::class.java)
86+
.allowMainThreadQueries()
87+
.build()
88+
db.usersDao().saveUser(UserEntity(id = ACCOUNT_ID, userId = "me", username = "me", baseUrl = BASE_URL))
89+
90+
whenever(networkMonitor.isOnline).thenReturn(MutableStateFlow(true))
91+
// the room list sync launches a message catch-up for every room whose activity advanced
92+
wheneverBlocking { chatNetwork.pullChatMessages(any(), any(), any()) }
93+
.thenReturn(Response.success(ChatOverall(ocs = ChatOCS(meta = null, data = emptyList()))))
94+
95+
val conversationListUpdater =
96+
ConversationListUpdater(db.chatMessagesDao(), db.chatBlocksDao(), db.conversationsDao())
97+
val syncer = ChatMessageSyncer(
98+
db.chatMessagesDao(),
99+
db.chatBlocksDao(),
100+
chatNetwork,
101+
networkMonitor,
102+
conversationListUpdater
103+
)
104+
arbitraryStorageManager = ArbitraryStorageManager(ArbitraryStoragesRepositoryImpl(db.arbitraryStoragesDao()))
105+
repository = OfflineFirstConversationsRepository(
106+
db.conversationsDao(),
107+
conversationsNetwork,
108+
chatNetwork,
109+
networkMonitor,
110+
syncer,
111+
conversationListUpdater,
112+
arbitraryStorageManager,
113+
context
114+
)
115+
}
116+
117+
@After
118+
fun tearDown() {
119+
db.close()
120+
RxAndroidPlugins.reset()
121+
}
122+
123+
@Test
124+
fun `a delta sync keeps the conversations it does not mention, with their messages and blocks`() {
125+
val allRooms = (1..ROOM_COUNT).map { conversation("room$it", lastActivity = 10) }
126+
whenever(conversationsNetwork.getRooms(any(), any(), any(), anyOrNull())).thenReturn(
127+
fullResponse(allRooms, modifiedBefore = 1000),
128+
deltaResponse(listOf(conversation("room1", lastActivity = 20)), modifiedBefore = 2000)
129+
)
130+
131+
runBlocking {
132+
repository.getRooms(user()).join()
133+
assertEquals(ROOM_COUNT, storedConversationCount())
134+
allRooms.forEach { seedCachedChat(it.token!!) }
135+
136+
repository.getRooms(user()).join()
137+
138+
assertEquals(
139+
"a delta response must not reconcile away the conversations it left out",
140+
ROOM_COUNT,
141+
storedConversationCount()
142+
)
143+
assertTrue("cached messages must survive a delta sync", cachedMessageIds("room7").isNotEmpty())
144+
assertTrue("chat blocks must survive a delta sync", cachedBlocks("room7").isNotEmpty())
145+
}
146+
}
147+
148+
@Test
149+
fun `a full sync reconciles away a conversation the response left out`() {
150+
whenever(conversationsNetwork.getRooms(any(), any(), any(), anyOrNull())).thenReturn(
151+
fullResponse(listOf(conversation("room1"), conversation("room2")), modifiedBefore = 1000),
152+
fullResponse(listOf(conversation("room1")), modifiedBefore = 2000)
153+
)
154+
155+
runBlocking {
156+
repository.getRooms(user()).join()
157+
assertEquals(2, storedConversationCount())
158+
159+
repository.getRooms(user(), forceFullSync = true).join()
160+
161+
assertEquals("a full response is the one that can say a conversation is gone", 1, storedConversationCount())
162+
}
163+
}
164+
165+
@Test
166+
fun `a delta sync sends the stored timestamp and asks without includeStatus`() {
167+
whenever(conversationsNetwork.getRooms(any(), any(), any(), anyOrNull())).thenReturn(
168+
fullResponse(listOf(conversation("room1")), modifiedBefore = 1000),
169+
deltaResponse(listOf(conversation("room1")), modifiedBefore = 2000)
170+
)
171+
172+
runBlocking {
173+
repository.getRooms(user()).join()
174+
repository.getRooms(user()).join()
175+
}
176+
177+
val includeStatus = argumentCaptor<Boolean>()
178+
val modifiedSince = argumentCaptor<Long>()
179+
verify(conversationsNetwork, times(2))
180+
.getRooms(any(), any(), includeStatus.capture(), modifiedSince.capture())
181+
182+
assertEquals("the first sync has nothing to anchor a delta on", null, modifiedSince.firstValue)
183+
assertTrue("a full sync keeps user status fresh", includeStatus.firstValue)
184+
assertEquals("the second sync echoes the header of the first", 1000L, modifiedSince.secondValue)
185+
assertEquals("includeStatus would return every one-to-one room anyway", false, includeStatus.secondValue)
186+
}
187+
188+
@Test
189+
fun `the internal signaling backend never gets a delta sync`() {
190+
whenever(conversationsNetwork.getRooms(any(), any(), any(), anyOrNull()))
191+
.thenReturn(fullResponse(listOf(conversation("room1")), modifiedBefore = 1000))
192+
193+
runBlocking {
194+
repository.getRooms(userWithInternalSignaling()).join()
195+
repository.getRooms(userWithInternalSignaling()).join()
196+
}
197+
198+
val modifiedSince = argumentCaptor<Long>()
199+
verify(conversationsNetwork, times(2))
200+
.getRooms(any(), any(), any(), modifiedSince.capture())
201+
assertNull("no signaling server exists to announce a removal out of band", modifiedSince.secondValue)
202+
}
203+
204+
@Test
205+
fun `a failed sync drops the stored timestamp so the next one asks for everything`() {
206+
whenever(conversationsNetwork.getRooms(any(), any(), any(), anyOrNull())).thenReturn(
207+
fullResponse(listOf(conversation("room1")), modifiedBefore = 1000),
208+
Observable.error(IllegalStateException("server said no"))
209+
)
210+
211+
runBlocking {
212+
repository.getRooms(user()).join()
213+
assertEquals("1000", storedValue(KEY_MODIFIED_SINCE))
214+
215+
repository.getRooms(user()).join()
216+
217+
assertNull(
218+
"a delta anchored on a sync that did not land would skip what it carried",
219+
storedValue(KEY_MODIFIED_SINCE)
220+
)
221+
}
222+
}
223+
224+
private fun storedValue(key: String): String? =
225+
arbitraryStorageManager.getStorageSetting(ACCOUNT_ID, key, "").blockingGet()?.value
226+
227+
private suspend fun storedConversationCount(): Int =
228+
db.conversationsDao().getConversationsForUser(ACCOUNT_ID).first().size
229+
230+
private suspend fun seedCachedChat(roomToken: String) {
231+
val internalConversationId = "$ACCOUNT_ID@$roomToken"
232+
db.chatMessagesDao().upsertChatMessages(
233+
listOf(
234+
ChatMessageEntity(
235+
internalId = "$internalConversationId@1",
236+
accountId = ACCOUNT_ID,
237+
token = roomToken,
238+
id = 1,
239+
internalConversationId = internalConversationId,
240+
actorDisplayName = "Other User",
241+
message = "cached message",
242+
actorId = "other",
243+
actorType = "users",
244+
messageType = "comment",
245+
systemMessageType = ChatMessage.SystemMessageType.DUMMY
246+
)
247+
)
248+
)
249+
db.chatBlocksDao().upsertChatBlock(
250+
ChatBlockEntity(
251+
internalConversationId = internalConversationId,
252+
accountId = ACCOUNT_ID,
253+
token = roomToken,
254+
oldestMessageId = 1,
255+
newestMessageId = 1,
256+
hasHistory = false
257+
)
258+
)
259+
}
260+
261+
private suspend fun cachedMessageIds(roomToken: String): List<Long> =
262+
db.chatMessagesDao().getMessagesForConversation("$ACCOUNT_ID@$roomToken", null).first().map { it.id }
263+
264+
private suspend fun cachedBlocks(roomToken: String): List<ChatBlockEntity> =
265+
db.chatBlocksDao().getChatBlocksForConversation("$ACCOUNT_ID@$roomToken")
266+
267+
companion object {
268+
private const val ROOM_COUNT = 10
269+
private const val KEY_MODIFIED_SINCE = "conversation_list_modified_since"
270+
}
271+
}
272+
273+
private fun conversation(roomToken: String, lastActivity: Long = 10): Conversation =
274+
Conversation(token = roomToken, lastActivity = lastActivity, unreadMessages = 0)
275+
276+
private fun fullResponse(rooms: List<Conversation>, modifiedBefore: Long): Observable<RoomListResult> =
277+
Observable.just(RoomListResult(rooms, modifiedBefore = modifiedBefore, wasDelta = false))
278+
279+
private fun deltaResponse(rooms: List<Conversation>, modifiedBefore: Long): Observable<RoomListResult> =
280+
Observable.just(RoomListResult(rooms, modifiedBefore = modifiedBefore, wasDelta = true))
281+
282+
private fun user(): User =
283+
User(
284+
id = ACCOUNT_ID,
285+
userId = "me",
286+
username = "me",
287+
baseUrl = BASE_URL,
288+
token = "app-password",
289+
externalSignalingServer = ExternalSignalingServer(externalSignalingServer = "https://hpb.example.com"),
290+
capabilities = Capabilities().apply {
291+
spreedCapability = SpreedCapability().apply { features = listOf("chat-keep-notifications") }
292+
userStatusCapability = UserStatusCapability(enabled = true, restore = false, supportsEmoji = true)
293+
}
294+
)
295+
296+
private fun userWithInternalSignaling(): User = user().copy(externalSignalingServer = null)

0 commit comments

Comments
 (0)