git.iko.soy/opencode-android
on 3efea16 help sign in
115 lines 4.9 kB history raw
1package soy.iko.opencode.data.network
3import io.ktor.client.HttpClient
4import io.ktor.client.engine.mock.MockEngine
5import io.ktor.client.engine.mock.respond
6import io.ktor.client.engine.mock.respondError
7import io.ktor.client.plugins.ClientRequestException
8import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
9import io.ktor.http.HttpHeaders
10import io.ktor.http.HttpStatusCode
11import io.ktor.http.headersOf
12import io.ktor.serialization.kotlinx.json.json
13import kotlinx.coroutines.test.runTest
14import org.junit.Assert.assertEquals
15import org.junit.Assert.assertTrue
16import org.junit.Test
17import soy.iko.opencode.data.model.PermissionResponse
18import java.io.IOException
20class OpencodeApiClientTest {
22 private fun makeClient(engine: MockEngine): HttpClient =
23 HttpClient(engine) {
24 expectSuccess = true
25 install(ContentNegotiation) { json(OpencodeJson) }
26 }
28 @Test
29 fun respondPermissionRetriesOnTransientFailure() = runTest {
30 var calls = 0
31 val engine = MockEngine {
32 calls++
33 if (calls < 3) throw IOException("transient")
34 respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
35 }
36 val api = OpencodeApiClient(makeClient(engine))
37 api.respondPermission("s1", "p1", PermissionResponse.ALWAYS)
38 assertEquals("transient failures should be retried", 3, calls)
39 }
41 @Test
42 fun respondPermissionDoesNotRetryClientError() = runTest {
43 var calls = 0
44 val engine = MockEngine {
45 calls++
46 respondError(HttpStatusCode.BadRequest)
47 }
48 val api = OpencodeApiClient(makeClient(engine))
49 val error = runCatching {
50 api.respondPermission("s1", "p1", PermissionResponse.REJECT)
51 }.exceptionOrNull()!!
52 assertEquals("4xx must not be retried", 1, calls)
53 assertTrue("expected ClientRequestException, got $error", error is ClientRequestException)
54 }
56 @Test
57 fun respondPermissionSucceedsOnFirstTry() = runTest {
58 var calls = 0
59 val engine = MockEngine {
60 calls++
61 respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
62 }
63 val api = OpencodeApiClient(makeClient(engine))
64 api.respondPermission("s1", "p1", PermissionResponse.ONCE)
65 assertEquals(1, calls)
66 }
68 @Test
69 fun sendPromptReusesSuppliedIdempotencyKeyForEveryRetry() = runTest {
70 // Regression: the offline outbox re-sends a queued message across separate flushes
71 // with a stable key (the outbox id) so a POST that reached the server but whose
72 // response was lost is deduplicated instead of starting a duplicate run. Every retry
73 // attempt must carry that exact key.
74 val keys = mutableListOf<String?>()
75 val engine = MockEngine { request ->
76 keys += request.headers["Idempotency-Key"]
77 throw IOException("transient") // force retries; the key must not change between them
78 }
79 val api = OpencodeApiClient(makeClient(engine))
80 runCatching { api.sendPrompt("s1", "hello", idempotencyKey = "outbox-123") }
81 assertTrue("expected more than one attempt, got ${keys.size}", keys.size > 1)
82 assertTrue("every attempt must reuse the supplied key, saw $keys", keys.all { it == "outbox-123" })
83 }
85 @Test
86 fun sendPromptDefaultKeyIsStableAcrossRetries() = runTest {
87 // Even without a caller-supplied key, all retries of a single call share one generated
88 // key (so an in-call retry after a lost response is deduplicated server-side).
89 val keys = mutableListOf<String?>()
90 val engine = MockEngine { request ->
91 keys += request.headers["Idempotency-Key"]
92 throw IOException("transient")
93 }
94 val api = OpencodeApiClient(makeClient(engine))
95 runCatching { api.sendPrompt("s1", "hello") }
96 assertTrue("expected more than one attempt", keys.size > 1)
97 assertTrue("generated key must be non-null", keys.all { !it.isNullOrBlank() })
98 assertEquals("all attempts must share one key", 1, keys.toSet().size)
99 }
101 @Test
102 fun sendPromptDoesNotRetryMalformedResponseBody() = runTest {
103 // A body that can't be deserialized is deterministic — retrying re-parses the same
104 // bytes and fails identically, so it must fail fast (one attempt), not burn the whole
105 // backoff budget.
106 var calls = 0
107 val engine = MockEngine {
108 calls++
109 respond("\"not a MessageWithParts\"", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
110 }
111 val api = OpencodeApiClient(makeClient(engine))
112 runCatching { api.sendPrompt("s1", "hello") }
113 assertEquals("deserialization failure must not be retried", 1, calls)
114 }