Skip to content

Commit 75b1f11

Browse files
test(utils): cover the optimistic action contract directly
The helper was only exercised through the repositories that use it, so the rules every optimistic action depends on - applied before the answer, taken back on a refusal or a cancellation, kept after one hiccup - had no test of their own. Assisted-by: Claude Code:claude-opus-5 Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
1 parent faeffc3 commit 75b1f11

1 file changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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.utils
9+
10+
import kotlinx.coroutines.ExperimentalCoroutinesApi
11+
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.suspendCancellableCoroutine
13+
import kotlinx.coroutines.test.advanceUntilIdle
14+
import kotlinx.coroutines.test.runCurrent
15+
import kotlinx.coroutines.test.runTest
16+
import okhttp3.MediaType.Companion.toMediaType
17+
import okhttp3.ResponseBody.Companion.toResponseBody
18+
import org.junit.Assert.assertEquals
19+
import org.junit.Assert.assertTrue
20+
import org.junit.Test
21+
import retrofit2.HttpException
22+
import retrofit2.Response
23+
import java.io.IOException
24+
25+
/**
26+
* The contract every optimistic action in the app relies on: the change is visible before the answer
27+
* arrives, it survives a hiccup, and it is taken back whenever the request does not finally succeed -
28+
* including when the screen is left while the request is still in flight.
29+
*/
30+
@OptIn(ExperimentalCoroutinesApi::class)
31+
class OptimisticActionTest {
32+
33+
private var state = "before"
34+
35+
private val applyChange: suspend () -> (suspend () -> Unit)? = {
36+
val previous = state
37+
state = "after"
38+
{ state = previous }
39+
}
40+
41+
@Test
42+
fun `a change the server accepts stays applied`() =
43+
runTest {
44+
val result = optimisticAction(apply = applyChange, request = { "ok" })
45+
46+
assertEquals("ok", result.getOrNull())
47+
assertEquals("after", state)
48+
}
49+
50+
@Test
51+
fun `a change the server refuses is taken back`() =
52+
runTest {
53+
val result = optimisticAction<String>(apply = applyChange, request = { throw forbidden() })
54+
55+
assertTrue(result.isFailure)
56+
assertEquals("before", state)
57+
}
58+
59+
@Test
60+
fun `a connection problem is retried once before the change is taken back`() =
61+
runTest {
62+
var attempts = 0
63+
64+
val result = optimisticAction<String>(
65+
apply = applyChange,
66+
request = {
67+
attempts++
68+
throw IOException("no connection")
69+
}
70+
)
71+
72+
assertEquals(2, attempts)
73+
assertTrue(result.isFailure)
74+
assertEquals("before", state)
75+
}
76+
77+
@Test
78+
fun `a single connection problem does not cost the change`() =
79+
runTest {
80+
var attempts = 0
81+
82+
optimisticAction(
83+
apply = applyChange,
84+
request = {
85+
attempts++
86+
if (attempts == 1) throw IOException("no connection") else "ok"
87+
}
88+
)
89+
90+
assertEquals("after", state)
91+
}
92+
93+
@Test
94+
fun `a refusal the server reports in the payload is taken back as well`() =
95+
runTest {
96+
val result = optimisticAction(
97+
apply = applyChange,
98+
isConfirmed = { answer: String -> answer == "ok" },
99+
request = { "refused" }
100+
)
101+
102+
// the request itself did not fail, so the caller still sees the answer it got
103+
assertEquals("refused", result.getOrNull())
104+
assertEquals("before", state)
105+
}
106+
107+
@Test
108+
fun `nothing is taken back when nothing was applied`() =
109+
runTest {
110+
val result = optimisticAction<String>(apply = { null }, request = { throw forbidden() })
111+
112+
assertTrue(result.isFailure)
113+
assertEquals("before", state)
114+
}
115+
116+
@Test
117+
fun `leaving the screen while the request is in flight takes the change back`() =
118+
runTest {
119+
val job = launch {
120+
optimisticAction(apply = applyChange, request = { awaitForever() })
121+
}
122+
runCurrent()
123+
assertEquals("after", state)
124+
125+
job.cancel()
126+
advanceUntilIdle()
127+
128+
assertEquals("before", state)
129+
}
130+
131+
private suspend fun awaitForever(): String = suspendCancellableCoroutine { }
132+
133+
private fun forbidden(): HttpException =
134+
HttpException(Response.error<String>(HTTP_FORBIDDEN, "".toResponseBody("text/plain".toMediaType())))
135+
136+
private companion object {
137+
const val HTTP_FORBIDDEN = 403
138+
}
139+
}

0 commit comments

Comments
 (0)