Skip to content

Commit f92c673

Browse files
test(conversations): cover the periodic conversation sync worker
What is worth pinning down about a background worker is when it stands down and how many accounts it touches when it does not: a guard that quietly stops working is invisible until it turns up as battery drain or request volume, long after the change that broke it. Covers battery saver standing the run down without reading a single account, every configured account being synced rather than only the current one, a failed account asking for another attempt while its neighbours still sync, the attempt cap ending the retries, and an account lookup that throws being survivable. Unit tests gain the WorkManager testing artifact, which was available to instrumented tests only. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent 9769f44 commit f92c673

2 files changed

Lines changed: 137 additions & 0 deletions

File tree

‎app/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ dependencies {
372372
androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0")
373373
androidTestImplementation("androidx.test:core-ktx:1.7.0")
374374
androidTestImplementation("org.mockito:mockito-android:5.22.0")
375+
testImplementation("androidx.work:work-testing:$workVersion")
375376
androidTestImplementation("androidx.work:work-testing:$workVersion")
376377
androidTestImplementation("androidx.test.espresso:espresso-core:$espressoVersion") {
377378
exclude(group = "com.android.support", module = "support-annotations")
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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.jobs
9+
10+
import android.app.Application
11+
import android.content.Context
12+
import android.os.PowerManager
13+
import androidx.test.core.app.ApplicationProvider
14+
import androidx.work.ListenableWorker
15+
import androidx.work.testing.TestListenableWorkerBuilder
16+
import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository
17+
import com.nextcloud.talk.data.user.model.User
18+
import com.nextcloud.talk.users.UserManager
19+
import io.reactivex.Single
20+
import kotlinx.coroutines.runBlocking
21+
import org.junit.Assert.assertEquals
22+
import org.junit.Test
23+
import org.junit.runner.RunWith
24+
import org.mockito.kotlin.any
25+
import org.mockito.kotlin.mock
26+
import org.mockito.kotlin.never
27+
import org.mockito.kotlin.verify
28+
import org.mockito.kotlin.verifyBlocking
29+
import org.mockito.kotlin.whenever
30+
import org.mockito.kotlin.wheneverBlocking
31+
import org.robolectric.RobolectricTestRunner
32+
import org.robolectric.Shadows.shadowOf
33+
import org.robolectric.annotation.Config
34+
35+
/**
36+
* Tests for [ConversationsSyncWorker]: which accounts a run syncs, when a run stands down without
37+
* syncing, and how a failed run reports itself.
38+
*/
39+
@RunWith(RobolectricTestRunner::class)
40+
@Config(application = Application::class, sdk = [33])
41+
class ConversationsSyncWorkerTest {
42+
43+
private val userManager: UserManager = mock()
44+
private val repository: OfflineConversationsRepository = mock()
45+
46+
@Test
47+
fun `every account is synced, not just the current one`() {
48+
val worker = worker()
49+
whenever(userManager.users).thenReturn(Single.just(listOf(user(1), user(2), user(3))))
50+
wheneverBlocking { repository.syncRooms(any(), any()) }.thenReturn(true)
51+
52+
val result = runBlocking { worker.sync() }
53+
54+
assertEquals(ListenableWorker.Result.success(), result)
55+
verifyBlocking(repository) { syncRooms(user(1), false) }
56+
verifyBlocking(repository) { syncRooms(user(2), false) }
57+
verifyBlocking(repository) { syncRooms(user(3), false) }
58+
}
59+
60+
@Test
61+
fun `battery saver stands the sync down entirely`() {
62+
val worker = worker()
63+
shadowOf(applicationContext().getSystemService(Context.POWER_SERVICE) as PowerManager)
64+
.setIsPowerSaveMode(true)
65+
66+
val result = runBlocking { worker.sync() }
67+
68+
assertEquals(ListenableWorker.Result.success(), result)
69+
verify(userManager, never()).users
70+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
71+
}
72+
73+
@Test
74+
fun `an account with nothing to sync is not an error`() {
75+
val worker = worker()
76+
whenever(userManager.users).thenReturn(Single.just(emptyList()))
77+
78+
val result = runBlocking { worker.sync() }
79+
80+
assertEquals(ListenableWorker.Result.success(), result)
81+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
82+
}
83+
84+
@Test
85+
fun `a failed account asks for another attempt`() {
86+
val worker = worker(runAttempt = 0)
87+
whenever(userManager.users).thenReturn(Single.just(listOf(user(1), user(2))))
88+
wheneverBlocking { repository.syncRooms(user(1), false) }.thenReturn(true)
89+
wheneverBlocking { repository.syncRooms(user(2), false) }.thenReturn(false)
90+
91+
val result = runBlocking { worker.sync() }
92+
93+
assertEquals(ListenableWorker.Result.retry(), result)
94+
// the account that did sync was still synced, rather than abandoned with its neighbour
95+
verifyBlocking(repository) { syncRooms(user(1), false) }
96+
}
97+
98+
@Test
99+
fun `a sync that keeps failing gives up instead of retrying for ever`() {
100+
val worker = worker(runAttempt = 2)
101+
whenever(userManager.users).thenReturn(Single.just(listOf(user(1))))
102+
wheneverBlocking { repository.syncRooms(any(), any()) }.thenReturn(false)
103+
104+
val result = runBlocking { worker.sync() }
105+
106+
assertEquals(ListenableWorker.Result.failure(), result)
107+
}
108+
109+
@Test
110+
fun `an account whose lookup throws does not take the other accounts down with it`() {
111+
val worker = worker()
112+
whenever(userManager.users).thenReturn(Single.error(IllegalStateException("database is gone")))
113+
114+
val result = runBlocking { worker.sync() }
115+
116+
assertEquals(ListenableWorker.Result.success(), result)
117+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
118+
}
119+
120+
private fun worker(runAttempt: Int = 0): ConversationsSyncWorker =
121+
TestListenableWorkerBuilder<ConversationsSyncWorker>(applicationContext())
122+
.setRunAttemptCount(runAttempt)
123+
.build()
124+
.also {
125+
it.userManager = userManager
126+
it.conversationsRepository = repository
127+
}
128+
129+
private fun applicationContext(): Context = ApplicationProvider.getApplicationContext()
130+
131+
private fun user(id: Long): User = User(id = id, userId = "user$id", username = "user$id", baseUrl = BASE_URL)
132+
133+
companion object {
134+
private const val BASE_URL = "https://server.example.com"
135+
}
136+
}

0 commit comments

Comments
 (0)