Skip to content

Commit abc0d72

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. The worker's decision is split out from doWork so a test can drive it with its own collaborators - the Dagger injection there reaches for the application singleton, whose setter a unit test cannot reach. 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 2e29d5a commit abc0d72

3 files changed

Lines changed: 148 additions & 2 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")

‎app/src/main/java/com/nextcloud/talk/jobs/ConversationsSyncWorker.kt‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package com.nextcloud.talk.jobs
99
import android.content.Context
1010
import android.os.PowerManager
1111
import android.util.Log
12+
import androidx.annotation.VisibleForTesting
1213
import androidx.lifecycle.Lifecycle
1314
import androidx.lifecycle.ProcessLifecycleOwner
1415
import androidx.work.Constraints
@@ -58,8 +59,16 @@ class ConversationsSyncWorker(context: Context, workerParams: WorkerParameters)
5859

5960
override suspend fun doWork(): Result {
6061
sharedApplication!!.componentApplication.inject(this)
62+
return sync()
63+
}
6164

62-
return when {
65+
/**
66+
* Separate from [doWork] so a test can drive it with its own collaborators: the Dagger
67+
* injection above reaches for the application singleton, which a unit test has no way to set.
68+
*/
69+
@VisibleForTesting
70+
internal suspend fun sync(): Result =
71+
when {
6372
isPowerSaveMode() -> {
6473
Log.d(TAG, "Battery saver is active, skipping the background conversation sync")
6574
Result.success()
@@ -73,7 +82,6 @@ class ConversationsSyncWorker(context: Context, workerParams: WorkerParameters)
7382

7483
else -> syncAccounts()
7584
}
76-
}
7785

7886
private suspend fun syncAccounts(): Result {
7987
// ponytail: every account in one wake-up, sequentially. N accounts cost N room list
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
* The worker is what turns one wake-up into requests, so what is worth pinning down is when it
37+
* stands down and how many accounts it touches when it does not: a guard that stops working is
38+
* invisible until it shows up as battery or request volume.
39+
*/
40+
@RunWith(RobolectricTestRunner::class)
41+
@Config(application = Application::class, sdk = [33])
42+
class ConversationsSyncWorkerTest {
43+
44+
private val userManager: UserManager = mock()
45+
private val repository: OfflineConversationsRepository = mock()
46+
47+
@Test
48+
fun `every account is synced, not just the current one`() {
49+
val worker = worker()
50+
whenever(userManager.users).thenReturn(Single.just(listOf(user(1), user(2), user(3))))
51+
wheneverBlocking { repository.syncRooms(any(), any()) }.thenReturn(true)
52+
53+
val result = runBlocking { worker.sync() }
54+
55+
assertEquals(ListenableWorker.Result.success(), result)
56+
verifyBlocking(repository) { syncRooms(user(1), false) }
57+
verifyBlocking(repository) { syncRooms(user(2), false) }
58+
verifyBlocking(repository) { syncRooms(user(3), false) }
59+
}
60+
61+
@Test
62+
fun `battery saver stands the sync down entirely`() {
63+
val worker = worker()
64+
shadowOf(applicationContext().getSystemService(Context.POWER_SERVICE) as PowerManager)
65+
.setIsPowerSaveMode(true)
66+
67+
val result = runBlocking { worker.sync() }
68+
69+
assertEquals(ListenableWorker.Result.success(), result)
70+
verify(userManager, never()).users
71+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
72+
}
73+
74+
@Test
75+
fun `an account with nothing to sync is not an error`() {
76+
val worker = worker()
77+
whenever(userManager.users).thenReturn(Single.just(emptyList()))
78+
79+
val result = runBlocking { worker.sync() }
80+
81+
assertEquals(ListenableWorker.Result.success(), result)
82+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
83+
}
84+
85+
@Test
86+
fun `a failed account asks for another attempt`() {
87+
val worker = worker(runAttempt = 0)
88+
whenever(userManager.users).thenReturn(Single.just(listOf(user(1), user(2))))
89+
wheneverBlocking { repository.syncRooms(user(1), false) }.thenReturn(true)
90+
wheneverBlocking { repository.syncRooms(user(2), false) }.thenReturn(false)
91+
92+
val result = runBlocking { worker.sync() }
93+
94+
assertEquals(ListenableWorker.Result.retry(), result)
95+
// the account that did sync was still synced, rather than abandoned with its neighbour
96+
verifyBlocking(repository) { syncRooms(user(1), false) }
97+
}
98+
99+
@Test
100+
fun `a sync that keeps failing gives up instead of retrying for ever`() {
101+
val worker = worker(runAttempt = 2)
102+
whenever(userManager.users).thenReturn(Single.just(listOf(user(1))))
103+
wheneverBlocking { repository.syncRooms(any(), any()) }.thenReturn(false)
104+
105+
val result = runBlocking { worker.sync() }
106+
107+
assertEquals(ListenableWorker.Result.failure(), result)
108+
}
109+
110+
@Test
111+
fun `an account whose lookup throws does not take the other accounts down with it`() {
112+
val worker = worker()
113+
whenever(userManager.users).thenReturn(Single.error(IllegalStateException("database is gone")))
114+
115+
val result = runBlocking { worker.sync() }
116+
117+
assertEquals(ListenableWorker.Result.success(), result)
118+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
119+
}
120+
121+
private fun worker(runAttempt: Int = 0): ConversationsSyncWorker =
122+
TestListenableWorkerBuilder<ConversationsSyncWorker>(applicationContext())
123+
.setRunAttemptCount(runAttempt)
124+
.build()
125+
.also {
126+
it.userManager = userManager
127+
it.conversationsRepository = repository
128+
}
129+
130+
private fun applicationContext(): Context = ApplicationProvider.getApplicationContext()
131+
132+
private fun user(id: Long): User = User(id = id, userId = "user$id", username = "user$id", baseUrl = BASE_URL)
133+
134+
companion object {
135+
private const val BASE_URL = "https://server.example.com"
136+
}
137+
}

0 commit comments

Comments
 (0)