Skip to content

Commit 9596d4e

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 94f3641 commit 9596d4e

2 files changed

Lines changed: 147 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: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/*
2+
* Nextcloud Talk - Android Client
3+
*
4+
* SPDX-FileCopyrightText: 2026 Andy Scherzinger <info@andy-scherzinger.de>
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 kotlinx.coroutines.CancellationException
20+
import kotlinx.coroutines.runBlocking
21+
import org.junit.Assert.assertEquals
22+
import org.junit.Assert.assertThrows
23+
import org.junit.Test
24+
import org.junit.runner.RunWith
25+
import org.mockito.kotlin.any
26+
import org.mockito.kotlin.mock
27+
import org.mockito.kotlin.never
28+
import org.mockito.kotlin.times
29+
import org.mockito.kotlin.verifyBlocking
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+
wheneverBlocking { userManager.getUsers() }.thenReturn(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+
verifyBlocking(userManager, never()) { getUsers() }
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+
wheneverBlocking { userManager.getUsers() }.thenReturn(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+
wheneverBlocking { userManager.getUsers() }.thenReturn(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+
verifyBlocking(repository) { syncRooms(user(1), false) }
95+
}
96+
97+
@Test
98+
fun `a sync that keeps failing gives up instead of retrying for ever`() {
99+
val worker = worker(runAttempt = 2)
100+
wheneverBlocking { userManager.getUsers() }.thenReturn(listOf(user(1)))
101+
wheneverBlocking { repository.syncRooms(any(), any()) }.thenReturn(false)
102+
103+
val result = runBlocking { worker.sync() }
104+
105+
assertEquals(ListenableWorker.Result.failure(), result)
106+
}
107+
108+
@Test
109+
fun `an account whose lookup throws does not take the other accounts down with it`() {
110+
val worker = worker()
111+
wheneverBlocking { userManager.getUsers() }.thenThrow(IllegalStateException("database is gone"))
112+
113+
val result = runBlocking { worker.sync() }
114+
115+
assertEquals(ListenableWorker.Result.success(), result)
116+
verifyBlocking(repository, never()) { syncRooms(any(), any()) }
117+
}
118+
119+
@Test
120+
fun `a cancelled run stops instead of syncing the remaining accounts`() {
121+
val worker = worker()
122+
wheneverBlocking { userManager.getUsers() }.thenReturn(listOf(user(1), user(2), user(3)))
123+
wheneverBlocking { repository.syncRooms(any(), any()) }.thenThrow(CancellationException("run stopped"))
124+
125+
assertThrows(CancellationException::class.java) { runBlocking { worker.sync() } }
126+
127+
verifyBlocking(repository, times(1)) { syncRooms(any(), any()) }
128+
}
129+
130+
private fun worker(runAttempt: Int = 0): ConversationsSyncWorker =
131+
TestListenableWorkerBuilder<ConversationsSyncWorker>(applicationContext())
132+
.setRunAttemptCount(runAttempt)
133+
.build()
134+
.also {
135+
it.userManager = userManager
136+
it.conversationsRepository = repository
137+
}
138+
139+
private fun applicationContext(): Context = ApplicationProvider.getApplicationContext()
140+
141+
private fun user(id: Long): User = User(id = id, userId = "user$id", username = "user$id", baseUrl = BASE_URL)
142+
143+
companion object {
144+
private const val BASE_URL = "https://server.example.com"
145+
}
146+
}

0 commit comments

Comments
 (0)