git.iko.soy/opencode-android
on master help sign in
Add tests for 429 retry and switchServer failure/restore paths, fix FakeAppContainer.connect to mirror real behavior
commit325e3590531a875afafbe7f22a13fd0fd2502e59 authoriko <mail@iko.soy> dateTue 30 Jun 2026, 17:31 (3 months ago) parent2f3f60c
3 files changed+104−6
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+17 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+73 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+14−6
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+17
@@ -75,6 +75,23 @@ class WithRetryTest {
7575 assertTrue(error is ClientRequestException)
7676 }
7777
78+ @Test
79+ fun tooManyRequestsIsRetried() = runTest {
80+ // 429 (Too Many Requests) is the only 4xx that should retry — all other 4xx
81+ // surface immediately. Verify the special case isn't silently lost.
82+ val rateLimited = httpError(HttpStatusCode.TooManyRequests)
83+ assertTrue("expected ClientRequestException, got $rateLimited", rateLimited is ClientRequestException)
84+ assertEquals(429, (rateLimited as ClientRequestException).response.status.value)
85+ var calls = 0
86+ runCatching {
87+ withRetryInternal(maxAttempts = 3, initialDelayMs = 0) {
88+ calls++
89+ throw rateLimited
90+ }
91+ }
92+ assertEquals("429 must be retried like a transient failure", 3, calls)
93+ }
94+
7895 @Test
7996 fun serverErrorIsRetried() = runTest {
8097 val serverError = httpError(HttpStatusCode.InternalServerError)
app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt+73
@@ -225,6 +225,79 @@ class SessionListViewModelTest {
225225 assertNull(vm.switchingId.value)
226226 }
227227
228+ @Test
229+ fun switchServer_failure_restoresPreviousAndShowsTransientError() = testScope.runTest {
230+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
231+ repo.sessions = listOf(Session(id = "s1-chat", title = "Original"))
232+ val container = makeContainer(repo = repo)
233+ val vm = makeVm(container)
234+
235+ // connect() succeeds but the new server's ping fails.
236+ val newApi = FakeOpencodeApiClient().apply { pingThrows = IOException("switch failed") }
237+ val newRepo = FakeSessionRepository(newApi, FakeEventStreamClient())
238+ newRepo.sessions = listOf(Session(id = "restored", title = "Restored"))
239+ container.connectResult = FakeOpencodeConnection(
240+ newApi, FakeEventStreamClient(), newRepo,
241+ ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"),
242+ )
243+
244+ vm.switchServer(ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
245+ testScheduler.advanceUntilIdle()
246+
247+ // connect attempted for the new server, then again to restore the previous one.
248+ assertEquals(2, container.connectCalls.size)
249+ assertEquals("s2", container.connectCalls[0].id)
250+ assertEquals("s1", container.connectCalls[1].id)
251+ // The switch failure is surfaced as a transient error, not a list-replacing error.
252+ assertNotNull(vm.transientError.value)
253+ assertNull(vm.state.value.error)
254+ assertEquals(1, container.disconnectCalls)
255+ assertNull(vm.switchingId.value)
256+ }
257+
258+ @Test
259+ fun switchServer_failure_whenNoPreviousShowsErrorState() = testScope.runTest {
260+ // No active connection — there's no previous profile to restore to.
261+ val container = FakeAppContainer()
262+ val vm = makeVm(container)
263+
264+ val newApi = FakeOpencodeApiClient().apply { pingThrows = IOException("nope") }
265+ container.connectResult = FakeOpencodeConnection(
266+ newApi, FakeEventStreamClient(),
267+ FakeSessionRepository(newApi, FakeEventStreamClient()),
268+ ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"),
269+ )
270+
271+ vm.switchServer(ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
272+ testScheduler.advanceUntilIdle()
273+
274+ // No previous profile to restore — the error replaces the list state.
275+ assertNotNull(vm.state.value.error)
276+ assertFalse(vm.state.value.loading)
277+ assertNull(vm.switchingId.value)
278+ }
279+
280+ @Test
281+ fun switchServer_failure_restoreAlsoFailsShowsCombinedError() = testScope.runTest {
282+ val repo = FakeSessionRepository(FakeOpencodeApiClient(), FakeEventStreamClient())
283+ repo.sessions = listOf(Session(id = "s1-chat", title = "Original"))
284+ val container = makeContainer(repo = repo)
285+ val vm = makeVm(container)
286+
287+ // Both the new connect and the restore connect fail.
288+ container.connectException = IOException("unreachable")
289+
290+ vm.switchServer(ServerProfile(id = "s2", label = "Server 2", baseUrl = "http://other"))
291+ testScheduler.advanceUntilIdle()
292+
293+ // connect attempted for the new server, then again to restore the previous one.
294+ assertEquals(2, container.connectCalls.size)
295+ // Both failed — the combined error replaces the list state.
296+ assertNotNull(vm.state.value.error)
297+ assertFalse(vm.state.value.loading)
298+ assertNull(vm.switchingId.value)
299+ }
300+
228301 // --- setQuery / filtering ---
229302
230303 @Test
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+14−6
@@ -294,6 +294,8 @@ class FakeAppContainer : AppContainer() {
294294 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
295295
296296 var connectResult: OpencodeConnection? = null
297+ /** When non-null, [connect] throws this instead of returning a connection. */
298+ var connectException: Throwable? = null
297299 var connectCalls: List<ServerProfile> = emptyList()
298300 private set
299301 var disconnectCalls = 0
@@ -313,12 +315,18 @@ class FakeAppContainer : AppContainer() {
313315
314316 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
315317 connectCalls = connectCalls + profile
316− connectResult?.let { return it }
317− // Create a fake connection on the fly if none pre-configured
318− val api = FakeOpencodeApiClient()
319− val events = FakeEventStreamClient()
320− val repo = FakeSessionRepository(api, events)
321− return FakeOpencodeConnection(api, events, repo, profile)
318+ connectException?.let { throw it }
319+ // Mirror the real AppContainer: connect() publishes the new connection so
320+ // collectors (SSE observer, refresh) see it immediately. This lets the
321+ // switchServer failure/restore paths be exercised end-to-end.
322+ val conn = connectResult ?: run {
323+ val api = FakeOpencodeApiClient()
324+ val events = FakeEventStreamClient()
325+ val repo = FakeSessionRepository(api, events)
326+ FakeOpencodeConnection(api, events, repo, profile)
327+ }
328+ fakeActiveConnection.value = conn
329+ return conn
322330 }
323331
324332 override suspend fun disconnect() {