Skip to content

Commit 2375c95

Browse files
Add unit tests for error paths, comparator edge cases, and constructor-policy deployment
1 parent 9a5af40 commit 2375c95

11 files changed

Lines changed: 751 additions & 0 deletions

File tree

stellar-sdk/src/commonTest/kotlin/com/soneso/stellar/sdk/unitTests/UtilTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.soneso.stellar.sdk.unitTests
22

33
import com.soneso.stellar.sdk.*
4+
import kotlinx.coroutines.test.runTest
45
import kotlin.coroutines.cancellation.CancellationException
56
import kotlin.test.*
67

@@ -194,4 +195,28 @@ class UtilTest {
194195
fun testIsFatal_exception_isNotFatal() {
195196
assertFalse(isFatal(Exception("boom")))
196197
}
198+
199+
@Test
200+
fun testReadErrorBodyOrFallback_readSucceeds_returnsBodyText() = runTest {
201+
assertEquals("error detail", readErrorBodyOrFallback("fallback") { "error detail" })
202+
}
203+
204+
@Test
205+
fun testReadErrorBodyOrFallback_readThrowsError_returnsFallback() = runTest {
206+
// kotlin.Error is how the Kotlin/JS HTTP engine reports connectivity failures:
207+
// non-fatal, so the read falls back instead of propagating.
208+
assertEquals("fallback", readErrorBodyOrFallback("fallback") { throw Error("Fail to fetch") })
209+
}
210+
211+
@Test
212+
fun testReadErrorBodyOrFallback_readThrowsException_returnsFallback() = runTest {
213+
assertEquals("fallback", readErrorBodyOrFallback("fallback") { throw RuntimeException("boom") })
214+
}
215+
216+
@Test
217+
fun testReadErrorBodyOrFallback_readCancelled_propagates() = runTest {
218+
assertFailsWith<CancellationException> {
219+
readErrorBodyOrFallback("fallback") { throw CancellationException("cancelled") }
220+
}
221+
}
197222
}

stellar-sdk/src/commonTest/kotlin/com/soneso/stellar/sdk/unitTests/horizon/HorizonServerMethodsTest.kt

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.soneso.stellar.sdk.unitTests.horizon
22

33
import com.soneso.stellar.sdk.horizon.HorizonServer
4+
import com.soneso.stellar.sdk.horizon.exceptions.BadRequestException
5+
import com.soneso.stellar.sdk.horizon.exceptions.BadResponseException
46
import com.soneso.stellar.sdk.horizon.exceptions.ConnectionErrorException
7+
import com.soneso.stellar.sdk.horizon.exceptions.UnknownResponseException
58
import io.ktor.client.*
69
import io.ktor.client.engine.mock.*
710
import io.ktor.client.plugins.contentnegotiation.*
@@ -112,6 +115,133 @@ class HorizonServerMethodsTest {
112115
server.close()
113116
}
114117

118+
@Test
119+
fun testSubmitTransactionAsync_engineThrowable_wrappedAsConnectionErrorException() = runTest {
120+
// Given: the async submit path with a client reporting a connectivity failure as a
121+
// non-Exception Throwable (the Kotlin/JS HTTP engine reports these as kotlin.Error)
122+
val throwingClient = createThrowingMockClient(Error("Fail to fetch"))
123+
val server = HorizonServer(
124+
"https://horizon-testnet.stellar.org",
125+
httpClient = throwingClient,
126+
submitHttpClient = throwingClient
127+
)
128+
129+
// When/Then: the failure surfaces as the documented ConnectionErrorException.
130+
// Type and message are asserted instead of instance identity because the JVM
131+
// coroutine machinery copies exceptions for stack-trace recovery.
132+
val exception = assertFailsWith<ConnectionErrorException> {
133+
server.submitTransactionAsync(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
134+
}
135+
val cause = assertIs<Error>(exception.cause)
136+
assertEquals("Fail to fetch", cause.message)
137+
138+
server.close()
139+
}
140+
141+
@Test
142+
fun testSubmitTransactionAsync_engineCancellation_propagates() = runTest {
143+
// Given: the async submit path with a client that throws a cancellation
144+
val throwingClient = createThrowingMockClient(CancellationException("cancelled"))
145+
val server = HorizonServer(
146+
"https://horizon-testnet.stellar.org",
147+
httpClient = throwingClient,
148+
submitHttpClient = throwingClient
149+
)
150+
151+
// When/Then: cancellation propagates instead of being wrapped
152+
assertFailsWith<CancellationException> {
153+
server.submitTransactionAsync(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
154+
}
155+
156+
server.close()
157+
}
158+
159+
// ========== Submit error-status handling (error body included in the exception) ==========
160+
161+
@Test
162+
fun testSubmitTransaction_forbidden403_throwsBadRequestWithBody() = runTest {
163+
val mockClient = createMockClient("forbidden detail", statusCode = HttpStatusCode.Forbidden, contentType = "text/plain", expectedPath = "/transactions")
164+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
165+
val exception = assertFailsWith<BadRequestException> {
166+
server.submitTransaction(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
167+
}
168+
assertEquals(403, exception.code)
169+
assertEquals("forbidden detail", exception.body)
170+
server.close()
171+
}
172+
173+
@Test
174+
fun testSubmitTransaction_serverError500_throwsBadResponseWithBody() = runTest {
175+
val mockClient = createMockClient("server exploded", statusCode = HttpStatusCode.InternalServerError, contentType = "text/plain", expectedPath = "/transactions")
176+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
177+
val exception = assertFailsWith<BadResponseException> {
178+
server.submitTransaction(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
179+
}
180+
assertEquals(500, exception.code)
181+
assertEquals("server exploded", exception.body)
182+
server.close()
183+
}
184+
185+
@Test
186+
fun testSubmitTransaction_unknownStatus_throwsUnknownResponseWithBody() = runTest {
187+
val mockClient = createMockClient("weird", statusCode = HttpStatusCode(600, "Weird"), contentType = "text/plain", expectedPath = "/transactions")
188+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
189+
val exception = assertFailsWith<UnknownResponseException> {
190+
server.submitTransaction(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
191+
}
192+
assertEquals(600, exception.code)
193+
assertEquals("weird", exception.body)
194+
server.close()
195+
}
196+
197+
@Test
198+
fun testSubmitTransactionAsync_malformed400_throwsBadRequestWithBody() = runTest {
199+
val mockClient = createMockClient("not a valid async response", statusCode = HttpStatusCode.BadRequest, contentType = "text/plain", expectedPath = "/transactions_async")
200+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
201+
val exception = assertFailsWith<BadRequestException> {
202+
server.submitTransactionAsync(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
203+
}
204+
assertEquals(400, exception.code)
205+
assertEquals("not a valid async response", exception.body)
206+
server.close()
207+
}
208+
209+
@Test
210+
fun testSubmitTransactionAsync_forbidden403_throwsBadRequestWithBody() = runTest {
211+
val mockClient = createMockClient("forbidden detail", statusCode = HttpStatusCode.Forbidden, contentType = "text/plain", expectedPath = "/transactions_async")
212+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
213+
val exception = assertFailsWith<BadRequestException> {
214+
server.submitTransactionAsync(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
215+
}
216+
assertEquals(403, exception.code)
217+
assertEquals("forbidden detail", exception.body)
218+
server.close()
219+
}
220+
221+
@Test
222+
fun testSubmitTransactionAsync_serverError500_throwsBadResponseWithBody() = runTest {
223+
val mockClient = createMockClient("server exploded", statusCode = HttpStatusCode.InternalServerError, contentType = "text/plain", expectedPath = "/transactions_async")
224+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
225+
val exception = assertFailsWith<BadResponseException> {
226+
server.submitTransactionAsync(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
227+
}
228+
assertEquals(500, exception.code)
229+
assertEquals("server exploded", exception.body)
230+
server.close()
231+
}
232+
233+
@Test
234+
fun testSubmitTransactionAsync_unknownStatus_throwsUnknownResponseWithBody() = runTest {
235+
val mockClient = createMockClient("weird", statusCode = HttpStatusCode(600, "Weird"), contentType = "text/plain", expectedPath = "/transactions_async")
236+
val server = HorizonServer("https://horizon-testnet.stellar.org", httpClient = mockClient, submitHttpClient = mockClient)
237+
val exception = assertFailsWith<UnknownResponseException> {
238+
server.submitTransactionAsync(VALID_ENVELOPE_XDR, skipMemoRequiredCheck = true)
239+
}
240+
assertEquals(600, exception.code)
241+
assertEquals("weird", exception.body)
242+
server.close()
243+
}
244+
115245
// ========== Individual Resource Methods ==========
116246

117247
@Test

stellar-sdk/src/commonTest/kotlin/com/soneso/stellar/sdk/unitTests/horizon/RequestBuilderExecuteTest.kt

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.soneso.stellar.sdk.unitTests.horizon
22

33
import com.soneso.stellar.sdk.horizon.HorizonServer
4+
import com.soneso.stellar.sdk.horizon.exceptions.BadRequestException
5+
import com.soneso.stellar.sdk.horizon.exceptions.BadResponseException
46
import com.soneso.stellar.sdk.horizon.exceptions.ConnectionErrorException
7+
import com.soneso.stellar.sdk.horizon.exceptions.UnknownResponseException
58
import io.ktor.client.*
69
import io.ktor.client.engine.mock.*
710
import io.ktor.client.plugins.contentnegotiation.*
@@ -519,6 +522,70 @@ class RequestBuilderExecuteTest {
519522
}
520523
}
521524

525+
private fun createStatusMockClient(content: String, status: HttpStatusCode): HttpClient {
526+
val mockEngine = MockEngine {
527+
respond(
528+
content = content,
529+
status = status,
530+
headers = headersOf(HttpHeaders.ContentType, "text/plain")
531+
)
532+
}
533+
return HttpClient(mockEngine) {
534+
install(ContentNegotiation) {
535+
json(Json {
536+
ignoreUnknownKeys = true
537+
isLenient = true
538+
})
539+
}
540+
}
541+
}
542+
543+
// ===== Error-status handling (error body included in the exception) =====
544+
545+
@Test
546+
fun testExecuteGet_unknownStatus_throwsUnknownResponseWithBody() = runTest {
547+
val server = HorizonServer(TEST_SERVER_URL, httpClient = createStatusMockClient("weird", HttpStatusCode(600, "Weird")))
548+
val exception = assertFailsWith<UnknownResponseException> {
549+
server.accounts().execute()
550+
}
551+
assertEquals(600, exception.code)
552+
assertEquals("weird", exception.body)
553+
server.close()
554+
}
555+
556+
@Test
557+
fun testHealthExecute_badRequest400_throwsBadRequestWithBody() = runTest {
558+
val server = HorizonServer(TEST_SERVER_URL, httpClient = createStatusMockClient("bad health request", HttpStatusCode.BadRequest))
559+
val exception = assertFailsWith<BadRequestException> {
560+
server.health().execute()
561+
}
562+
assertEquals(400, exception.code)
563+
assertEquals("bad health request", exception.body)
564+
server.close()
565+
}
566+
567+
@Test
568+
fun testHealthExecute_serverError500_throwsBadResponseWithBody() = runTest {
569+
val server = HorizonServer(TEST_SERVER_URL, httpClient = createStatusMockClient("health server error", HttpStatusCode.InternalServerError))
570+
val exception = assertFailsWith<BadResponseException> {
571+
server.health().execute()
572+
}
573+
assertEquals(500, exception.code)
574+
assertEquals("health server error", exception.body)
575+
server.close()
576+
}
577+
578+
@Test
579+
fun testHealthExecute_unknownStatus_throwsUnknownResponseWithBody() = runTest {
580+
val server = HorizonServer(TEST_SERVER_URL, httpClient = createStatusMockClient("weird health", HttpStatusCode(600, "Weird")))
581+
val exception = assertFailsWith<UnknownResponseException> {
582+
server.health().execute()
583+
}
584+
assertEquals(600, exception.code)
585+
assertEquals("weird health", exception.body)
586+
server.close()
587+
}
588+
522589
// ===== Connectivity-failure classification =====
523590

524591
@Test
@@ -1451,6 +1518,37 @@ class RequestBuilderExecuteTest {
14511518
server.close()
14521519
}
14531520

1521+
@Test
1522+
fun testHealthExecute_engineThrowable_wrappedAsConnectionErrorException() = runTest {
1523+
// Given: the health endpoint with a client reporting a connectivity failure as a
1524+
// non-Exception Throwable (the Kotlin/JS HTTP engine reports these as kotlin.Error)
1525+
val server = HorizonServer(TEST_SERVER_URL, httpClient = createThrowingMockClient(Error("Fail to fetch")))
1526+
1527+
// When/Then: the failure surfaces as the documented ConnectionErrorException, with
1528+
// the original Throwable preserved as the cause (asserted by type and message rather
1529+
// than instance identity, since the JVM coroutine machinery copies exceptions).
1530+
val exception = assertFailsWith<ConnectionErrorException> {
1531+
server.health().execute()
1532+
}
1533+
val cause = assertIs<Error>(exception.cause)
1534+
assertEquals("Fail to fetch", cause.message)
1535+
1536+
server.close()
1537+
}
1538+
1539+
@Test
1540+
fun testHealthExecute_engineCancellation_propagates() = runTest {
1541+
// Given: the health endpoint with a client that throws a cancellation
1542+
val server = HorizonServer(TEST_SERVER_URL, httpClient = createThrowingMockClient(CancellationException("cancelled")))
1543+
1544+
// When/Then: cancellation propagates instead of being wrapped
1545+
assertFailsWith<CancellationException> {
1546+
server.health().execute()
1547+
}
1548+
1549+
server.close()
1550+
}
1551+
14541552
// ===== RootRequestBuilder Tests =====
14551553

14561554
@Test

stellar-sdk/src/commonTest/kotlin/com/soneso/stellar/sdk/unitTests/horizon/responses/PageGetNextPageTest.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import kotlinx.coroutines.test.runTest
1212
import kotlinx.serialization.SerialName
1313
import kotlinx.serialization.Serializable
1414
import kotlinx.serialization.json.Json
15+
import kotlin.coroutines.cancellation.CancellationException
1516
import kotlin.test.*
1617

1718
class PageGetNextPageTest {
@@ -328,6 +329,36 @@ class PageGetNextPageTest {
328329
client.close()
329330
}
330331

332+
@Test
333+
fun testGetNextPageCancellationPropagates() = runTest {
334+
// A cancellation raised while fetching the next page must propagate unchanged rather
335+
// than being wrapped as a ConnectionErrorException.
336+
val mockEngine = MockEngine {
337+
throw CancellationException("cancelled")
338+
}
339+
340+
val client = HttpClient(mockEngine) {
341+
install(ContentNegotiation) {
342+
json(json)
343+
}
344+
}
345+
346+
val page = Page<TestRecord>(
347+
embedded = null,
348+
links = Page.Links(
349+
self = Link("https://horizon.stellar.org/accounts"),
350+
next = Link("https://horizon.stellar.org/accounts?cursor=cancelled"),
351+
prev = null
352+
)
353+
)
354+
355+
assertFailsWith<CancellationException> {
356+
page.getNextPage<TestRecord>(client)
357+
}
358+
359+
client.close()
360+
}
361+
331362
@Test
332363
fun testGetNextPageWithOtherBadRequestStatus() = runTest {
333364
val mockEngine = MockEngine {

stellar-sdk/src/commonTest/kotlin/com/soneso/stellar/sdk/unitTests/rpc/SorobanServerTest.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,20 @@ class SorobanServerTest {
706706
}
707707
}
708708

709+
@Test
710+
fun testPollTransaction_engineCancellation_propagates() = runTest {
711+
// Given: a poll attempt is cancelled rather than failing with a transient glitch
712+
val client = createThrowingMockClient(CancellationException("cancelled"))
713+
SorobanServer(TEST_SERVER_URL, client).use { server ->
714+
val txHash = "a4721e2a61e9a6b3c6c2e5c0d4c0a5f3e2d1c0b9a8f7e6d5c4b3a2f1e0d9c8b7"
715+
716+
// When/Then: cancellation propagates instead of being swallowed and retried
717+
assertFailsWith<CancellationException> {
718+
server.pollTransaction(hash = txHash, maxAttempts = 3, sleepStrategy = { 1L })
719+
}
720+
}
721+
}
722+
709723
@Test
710724
fun testPollTransaction_allAttemptsFail_throwsLastFailure() = runTest {
711725
// Given: Every attempt fails with a network error

0 commit comments

Comments
 (0)