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
17import soy.iko.opencode.data.model.PermissionResponse
18import java.io.IOException
20class OpencodeApiClientTest {
22 private fun makeClient(engine: MockEngine): HttpClient =
25 install(ContentNegotiation) { json(OpencodeJson) }
29 fun respondPermissionRetriesOnTransientFailure() = runTest {
31 val engine = MockEngine {
33 if (calls < 3) throw IOException("transient")
34 respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
36 val api = OpencodeApiClient(makeClient(engine))
37 api.respondPermission("s1", "p1", PermissionResponse.ALWAYS)
38 assertEquals("transient failures should be retried", 3, calls)
42 fun respondPermissionDoesNotRetryClientError() = runTest {
44 val engine = MockEngine {
46 respondError(HttpStatusCode.BadRequest)
48 val api = OpencodeApiClient(makeClient(engine))
49 val error = runCatching {
50 api.respondPermission("s1", "p1", PermissionResponse.REJECT)
52 assertEquals("4xx must not be retried", 1, calls)
53 assertTrue("expected ClientRequestException, got $error", error is ClientRequestException)
57 fun respondPermissionSucceedsOnFirstTry() = runTest {
59 val engine = MockEngine {
61 respond("", HttpStatusCode.OK, headersOf("Content-Length", "0"))
63 val api = OpencodeApiClient(makeClient(engine))
64 api.respondPermission("s1", "p1", PermissionResponse.ONCE)
65 assertEquals(1, calls)
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
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" })
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")
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)
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
107 val engine = MockEngine {
109 respond("\"not a MessageWithParts\"", HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json"))
111 val api = OpencodeApiClient(makeClient(engine))
112 runCatching { api.sendPrompt("s1", "hello") }
113 assertEquals("deserialization failure must not be retried", 1, calls)
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list