summaryrefslogtreecommitdiffstats
path: root/src/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:19:13 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:19:13 -0300
commit0757164507a1f9be8b9f61292a9069759f1da034 (patch)
treee958ba14c7f6a6ae3cdabe1ed0cd5f69b530edb7 /src/test
downloadkotlin-0757164507a1f9be8b9f61292a9069759f1da034.tar.gz
Initial importv0.1.0
Diffstat (limited to 'src/test')
-rw-r--r--src/test/kotlin/org/cepis/BatchTest.kt110
-rw-r--r--src/test/kotlin/org/cepis/ConfigurationTest.kt42
-rw-r--r--src/test/kotlin/org/cepis/FakeServer.kt98
-rw-r--r--src/test/kotlin/org/cepis/HealthTest.kt36
-rw-r--r--src/test/kotlin/org/cepis/LookupTest.kt128
-rw-r--r--src/test/kotlin/org/cepis/NormalizationTest.kt26
-rw-r--r--src/test/kotlin/org/cepis/ParserTest.kt86
-rw-r--r--src/test/kotlin/org/cepis/RetryTest.kt99
-rw-r--r--src/test/kotlin/org/cepis/ServerTest.kt51
9 files changed, 676 insertions, 0 deletions
diff --git a/src/test/kotlin/org/cepis/BatchTest.kt b/src/test/kotlin/org/cepis/BatchTest.kt
new file mode 100644
index 0000000..67aab2d
--- /dev/null
+++ b/src/test/kotlin/org/cepis/BatchTest.kt
@@ -0,0 +1,110 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+internal class BatchTest : ServerTest() {
+ @Test
+ fun `preserves order and marks missing`() {
+ val body =
+ Fixtures.csv(
+ "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo," +
+ "Sudeste,3550308,1004,11,7107",
+ "99999999,,,,,,,,,,,,",
+ "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," +
+ "Rio de Janeiro,Sudeste,3304557,,21,6001",
+ )
+ server.respond("/csv?ceps=01001000%2C99999999%2C20040020", Reply(200, body))
+ val result = client.lookupMany(listOf("01001000", "99999999", "20040020"))
+
+ assertEquals(3, result.size)
+ assertEquals("São Paulo", assertNotNull(result[0]).localidade)
+ assertNull(result[1])
+ assertEquals("RJ", assertNotNull(result[2]).uf)
+ }
+
+ @Test
+ fun `batches carry no coordinate`() {
+ val body =
+ Fixtures.csv("01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,,11,1")
+ server.respond("/csv?ceps=01001000", Reply(200, body))
+
+ assertNull(assertNotNull(client.lookupMany(listOf("01001000"))[0]).geo)
+ }
+
+ @Test
+ fun `field with a comma is parsed`() {
+ val body =
+ Fixtures.csv(
+ """01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1""",
+ )
+ server.respond("/csv?ceps=01001000", Reply(200, body))
+
+ assertEquals(
+ "Rua Um, Dois",
+ assertNotNull(client.lookupMany(listOf("01001000"))[0]).logradouro,
+ )
+ }
+
+ @Test
+ fun `field with an escaped quote is parsed`() {
+ val body =
+ Fixtures.csv(
+ "01001000,\"Rua \"\"Dois\"\"\",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ )
+ server.respond("/csv?ceps=01001000", Reply(200, body))
+
+ assertEquals(
+ "Rua \"Dois\"",
+ assertNotNull(client.lookupMany(listOf("01001000"))[0]).logradouro,
+ )
+ }
+
+ @Test
+ fun `empty list makes no request`() {
+ assertEquals(emptyList<Address?>(), client.lookupMany(emptyList()))
+ assertEquals(0, server.calls.size)
+ }
+
+ @Test
+ fun `chunks above the api limit`() {
+ val ceps = (0 until 501).map { "%08d".format(it) }
+ val first = ceps.take(500).map { "$it,,,,,X,SP,São Paulo,Sudeste,1,,11,1" }
+ val second = ceps.drop(500).map { "$it,,,,,X,SP,São Paulo,Sudeste,1,,11,1" }
+ var seen = 0
+
+ server.responder = {
+ seen += 1
+ val rows = if (seen == 1) first else second
+ Reply(200, Fixtures.csv(*rows.toTypedArray()))
+ }
+ val result = client.lookupMany(ceps)
+
+ assertEquals(2, seen)
+ assertEquals(501, result.size)
+ assertEquals("X", assertNotNull(result[500]).localidade)
+ }
+
+ @Test
+ fun `truncated response does not shift`() {
+ val body = Fixtures.csv("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1")
+ server.respond("/csv?ceps=01001000%2C20040020", Reply(200, body))
+ val result = client.lookupMany(listOf("01001000", "20040020"))
+
+ assertEquals(2, result.size)
+ assertEquals("São Paulo", assertNotNull(result[0]).localidade)
+ assertNull(result[1])
+ }
+
+ @Test
+ fun `malformed cep is null rather than an error`() {
+ val body = Fixtures.csv("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "abc,,,,,,,,,,,,")
+ server.respond("/csv?ceps=01001000%2Cabc", Reply(200, body))
+ val result = client.lookupMany(listOf("01001000", "abc"))
+
+ assertEquals(2, result.size)
+ assertNull(result[1])
+ }
+}
diff --git a/src/test/kotlin/org/cepis/ConfigurationTest.kt b/src/test/kotlin/org/cepis/ConfigurationTest.kt
new file mode 100644
index 0000000..e166b6a
--- /dev/null
+++ b/src/test/kotlin/org/cepis/ConfigurationTest.kt
@@ -0,0 +1,42 @@
+package org.cepis
+
+import java.time.Duration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+internal class ConfigurationTest {
+ @Test
+ fun `trailing slash is not duplicated`() {
+ assertEquals("http://x", Client(baseUrl = "http://x/").baseUrl)
+ }
+
+ @Test
+ fun `invalid parameters are rejected`() {
+ assertFailsWith<IllegalArgumentException> { Client(maxAttempts = 0) }
+ assertFailsWith<IllegalArgumentException> { Client(timeout = Duration.ZERO) }
+ assertFailsWith<IllegalArgumentException> { Client(timeout = Duration.ofSeconds(-1)) }
+ }
+
+ @Test
+ fun `user agent identifies the library`() {
+ assertTrue(Client().userAgent.startsWith("cepis-kotlin/$VERSION"))
+ }
+
+ @Test
+ fun `user agent can be overridden`() {
+ assertEquals("mine/1.0", Client(userAgent = "mine/1.0").userAgent)
+ }
+
+ @Test
+ fun `defaults match the documented ones`() {
+ val client = Client()
+
+ assertEquals(DEFAULT_BASE_URL, client.baseUrl)
+ assertEquals(3, client.maxAttempts)
+ assertEquals(Duration.ofMillis(500), client.backoffBase)
+ assertEquals(Duration.ofSeconds(8), client.backoffMax)
+ assertEquals(null, client.totalTimeout)
+ }
+}
diff --git a/src/test/kotlin/org/cepis/FakeServer.kt b/src/test/kotlin/org/cepis/FakeServer.kt
new file mode 100644
index 0000000..04ad4e2
--- /dev/null
+++ b/src/test/kotlin/org/cepis/FakeServer.kt
@@ -0,0 +1,98 @@
+package org.cepis
+
+import com.sun.net.httpserver.HttpServer
+import java.net.InetSocketAddress
+import java.util.Collections
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.atomic.AtomicInteger
+
+/** One canned HTTP response for the [FakeServer] to hand out. */
+internal class Reply(
+ val status: Int,
+ val body: String,
+ val headers: Map<String, String> = emptyMap(),
+)
+
+/**
+ * A throwaway HTTP server on a random loopback port.
+ *
+ * Tests point a real [Client] at it through `baseUrl`, so the code path under
+ * test is the one that runs in production — request construction, retries and
+ * error-body reading included. Nothing is mocked and nothing touches the
+ * network.
+ */
+internal class FakeServer {
+ private val server: HttpServer = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
+ private val routes = HashMap<String, MutableList<Reply>>()
+ private val lock = Any()
+
+ /** Every path requested so far, in order, query string included. */
+ val calls: MutableList<String> = Collections.synchronizedList(ArrayList())
+
+ /** When set, answers every request and bypasses the registered routes. */
+ var responder: ((String) -> Reply)? = null
+
+ private val pool =
+ Executors.newCachedThreadPool(
+ object : ThreadFactory {
+ private val counter = AtomicInteger()
+
+ override fun newThread(runnable: Runnable): Thread =
+ Thread(runnable, "fake-server-${counter.incrementAndGet()}").apply {
+ isDaemon = true
+ }
+ },
+ )
+
+ init {
+ server.createContext("/") { exchange ->
+ val uri = exchange.requestURI
+ val path = if (uri.rawQuery == null) uri.rawPath else "${uri.rawPath}?${uri.rawQuery}"
+ val reply = next(path)
+ val bytes = reply.body.toByteArray(Charsets.UTF_8)
+ reply.headers.forEach { (name, value) -> exchange.responseHeaders.add(name, value) }
+ exchange.sendResponseHeaders(
+ reply.status,
+ if (bytes.isEmpty()) -1L else bytes.size.toLong(),
+ )
+ exchange.responseBody.use { it.write(bytes) }
+ }
+ server.executor = pool
+ server.start()
+ }
+
+ /** The base URL to hand to [Client]. */
+ val url: String
+ get() = "http://127.0.0.1:${server.address.port}"
+
+ /**
+ * Register the responses for [path].
+ *
+ * Each request consumes the next reply, which is how retrying is tested.
+ * The last one repeats once the list runs out.
+ */
+ fun respond(
+ path: String,
+ vararg replies: Reply,
+ ) {
+ synchronized(lock) { routes[path] = replies.toMutableList() }
+ }
+
+ /** How many requests so far began with [prefix]. */
+ fun count(prefix: String): Int = synchronized(calls) { calls.count { it.startsWith(prefix) } }
+
+ fun stop() {
+ server.stop(0)
+ pool.shutdownNow()
+ }
+
+ private fun next(path: String): Reply {
+ synchronized(lock) {
+ calls.add(path)
+ responder?.let { return it(path) }
+ val queue = routes[path] ?: return Reply(404, """{"erro":"cep_nao_encontrado"}""")
+ return if (queue.size > 1) queue.removeAt(0) else queue[0]
+ }
+ }
+}
diff --git a/src/test/kotlin/org/cepis/HealthTest.kt b/src/test/kotlin/org/cepis/HealthTest.kt
new file mode 100644
index 0000000..ae57e48
--- /dev/null
+++ b/src/test/kotlin/org/cepis/HealthTest.kt
@@ -0,0 +1,36 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+internal class HealthTest : ServerTest() {
+ @Test
+ fun `reads health`() {
+ val body =
+ """
+ {"status":"ok","dne":"26071","ceps":1596168,"geo":894489,
+ "base_gerada_em":"2026-07-24T03:48:28Z"}
+ """
+ server.respond("/health", Reply(200, body))
+ val health = client.health()
+
+ assertTrue(health.ok)
+ assertEquals("26071", health.dne)
+ assertEquals(1596168, health.ceps)
+ assertEquals(894489, health.geo)
+ assertEquals("2026-07-24T03:48:28Z", health.baseGeradaEm)
+ }
+
+ @Test
+ fun `degraded does not raise and is not retried`() {
+ val body = """{"status":"degradado","dne":"","ceps":0,"geo":0}"""
+ server.respond("/health", Reply(503, body))
+ val health = client.health()
+
+ assertFalse(health.ok)
+ assertEquals("degradado", health.status)
+ assertEquals(1, server.count("/health"))
+ }
+}
diff --git a/src/test/kotlin/org/cepis/LookupTest.kt b/src/test/kotlin/org/cepis/LookupTest.kt
new file mode 100644
index 0000000..c266025
--- /dev/null
+++ b/src/test/kotlin/org/cepis/LookupTest.kt
@@ -0,0 +1,128 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+internal class LookupTest : ServerTest() {
+ @Test
+ fun `successful lookup`() {
+ server.respond("/01001000", Reply(200, Fixtures.SE_ADDRESS_JSON))
+ val address = client.lookup("01001-000")
+
+ assertEquals("São Paulo", address.localidade)
+ assertEquals("SP", address.uf)
+ assertEquals("Praça da Sé", address.logradouro)
+ assertEquals("01001-000", address.formattedCep)
+ assertNull(address.geo)
+ }
+
+ @Test
+ fun `reads the coordinate when present`() {
+ val body =
+ Fixtures.seAddressWith(
+ """"geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,"precisao":"logradouro"}""",
+ )
+ server.respond("/01310100", Reply(200, body))
+ val geo = assertNotNull(client.lookup("01310100").geo)
+
+ assertEquals(-23.5, geo.lat, 1e-9)
+ assertEquals(-46.6, geo.lon, 1e-9)
+ assertEquals(120, geo.raioM)
+ assertTrue(geo.isPrecise)
+ }
+
+ @Test
+ fun `municipality coordinate is not precise`() {
+ val body =
+ Fixtures.seAddressWith(
+ """"geo":{"lat":-23.5,"lon":-46.6,"raio_m":22000,"precisao":"localidade"}""",
+ )
+ server.respond("/01001000", Reply(200, body))
+ val geo = assertNotNull(client.lookup("01001000").geo)
+
+ assertFalse(geo.isPrecise)
+ assertEquals(22000, geo.raioM)
+ }
+
+ @Test
+ fun `malformed geo does not lose the address`() {
+ val body = Fixtures.seAddressWith(""""geo":{"lat":"not a number"}""")
+ server.respond("/01001000", Reply(200, body))
+ val address = client.lookup("01001000")
+
+ assertNull(address.geo)
+ assertEquals("São Paulo", address.localidade)
+ }
+
+ @Test
+ fun `new api field does not break the client`() {
+ val body = Fixtures.seAddressWith(""""campo_do_futuro":"value"""")
+ server.respond("/01001000", Reply(200, body))
+
+ assertEquals("value", client.lookup("01001000").raw["campo_do_futuro"])
+ }
+
+ @Test
+ fun `not found`() {
+ server.respond(
+ "/99999999",
+ Reply(404, """{"erro":"cep_nao_encontrado","mensagem":"gone"}"""),
+ )
+ val error = assertFailsWith<CepNotFoundException> { client.lookup("99999999") }
+
+ assertTrue(error.message!!.contains("99999999"))
+ }
+
+ @Test
+ fun `not found is not retried`() {
+ server.respond("/99999999", Reply(404, """{"erro":"x"}"""))
+ assertFailsWith<CepNotFoundException> { client.lookup("99999999") }
+
+ assertEquals(1, server.count("/99999999"))
+ }
+
+ @Test
+ fun `invalid cep never leaves the machine`() {
+ assertFailsWith<InvalidCepException> { client.lookup("123") }
+
+ assertEquals(0, server.calls.size)
+ }
+
+ @Test
+ fun `api 400 becomes an invalid cep carrying the api message`() {
+ server.respond(
+ "/00000000",
+ Reply(400, """{"erro":"cep_invalido","mensagem":"rejected by the API"}"""),
+ )
+ val error = assertFailsWith<InvalidCepException> { client.lookup("00000000") }
+
+ assertTrue(error.message!!.contains("rejected by the API"))
+ }
+
+ @Test
+ fun `api 400 is not retried`() {
+ server.respond("/00000000", Reply(400, """{"erro":"cep_invalido"}"""))
+ assertFailsWith<InvalidCepException> { client.lookup("00000000") }
+
+ assertEquals(1, server.count("/00000000"))
+ }
+
+ @Test
+ fun `response that is not json`() {
+ server.respond("/01001000", Reply(200, "<html>captive portal</html>"))
+
+ assertFailsWith<InvalidResponseException> { client.lookup("01001000") }
+ }
+
+ @Test
+ fun `json that is not an object`() {
+ server.respond("/01001000", Reply(200, "[1, 2, 3]"))
+
+ assertFailsWith<InvalidResponseException> { client.lookup("01001000") }
+ }
+}
diff --git a/src/test/kotlin/org/cepis/NormalizationTest.kt b/src/test/kotlin/org/cepis/NormalizationTest.kt
new file mode 100644
index 0000000..b3069b6
--- /dev/null
+++ b/src/test/kotlin/org/cepis/NormalizationTest.kt
@@ -0,0 +1,26 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+
+internal class NormalizationTest {
+ @Test
+ fun `accepts masked and unmasked`() {
+ for (value in listOf("01001000", "01001-000", " 01001-000 ", "01001.000")) {
+ assertEquals("01001000", normalizeCep(value))
+ }
+ }
+
+ @Test
+ fun `rejects bad format`() {
+ for (value in listOf("", "123", "0100100a", "010010000", "abcdefgh")) {
+ assertFailsWith<InvalidCepException> { normalizeCep(value) }
+ }
+ }
+
+ @Test
+ fun `is a cepis error so one handler catches everything`() {
+ assertFailsWith<CepisException> { normalizeCep("123") }
+ }
+}
diff --git a/src/test/kotlin/org/cepis/ParserTest.kt b/src/test/kotlin/org/cepis/ParserTest.kt
new file mode 100644
index 0000000..0ec0cf8
--- /dev/null
+++ b/src/test/kotlin/org/cepis/ParserTest.kt
@@ -0,0 +1,86 @@
+package org.cepis
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+internal class JsonTest {
+ @Test
+ fun `reads the shapes the api uses`() {
+ val parsed = Json.parse("""{"a":"x","b":1,"c":-2.5,"d":true,"e":null,"f":[1,"two"]}""")
+ val map = parsed as Map<*, *>
+
+ assertEquals("x", map["a"])
+ assertEquals(1L, map["b"])
+ assertEquals(-2.5, map["c"])
+ assertEquals(true, map["d"])
+ assertNull(map["e"])
+ assertEquals(listOf(1L, "two"), map["f"])
+ }
+
+ @Test
+ fun `reads escapes`() {
+ val map = Json.parse(""" {"s":"a\"b\\c\/d\ne\tfé"} """) as Map<*, *>
+
+ assertEquals("a\"b\\c/d\ne\tfé", map["s"])
+ }
+
+ @Test
+ fun `preserves key order`() {
+ val map = Json.parse("""{"z":1,"a":2,"m":3}""") as Map<*, *>
+
+ assertEquals(listOf("z", "a", "m"), map.keys.toList())
+ }
+
+ @Test
+ fun `rejects malformed documents`() {
+ for (bad in listOf("", "{", """{"a"}""", """{"a":}""", "[1,]", "tru", "{} junk")) {
+ assertFailsWith<JsonSyntaxException>(bad) { Json.parse(bad) }
+ }
+ }
+
+ @Test
+ fun `accepts an empty object and an empty array`() {
+ assertEquals(emptyMap<String, Any?>(), Json.parse("{}"))
+ assertEquals(emptyList<Any?>(), Json.parse("[]"))
+ }
+}
+
+internal class CsvTest {
+ @Test
+ fun `quoted fields keep commas newlines and quotes`() {
+ val rows = Csv.parse("a,b,c\n\"x,1\",\"y\nz\",\"q\"\"q\"\n")
+
+ assertEquals(listOf("a", "b", "c"), rows[0])
+ assertEquals(listOf("x,1", "y\nz", "q\"q"), rows[1])
+ assertEquals(2, rows.size)
+ }
+
+ @Test
+ fun `handles crlf and a missing final newline`() {
+ val rows = Csv.parse("a,b\r\n1,2\r\n3,4")
+
+ assertEquals(3, rows.size)
+ assertEquals(listOf("3", "4"), rows[2])
+ }
+
+ @Test
+ fun `a fully empty row is all empty fields`() {
+ val rows = Csv.rows("cep,localidade\n99999999,\n")
+
+ assertEquals(1, rows.size)
+ assertEquals("99999999", rows[0]["cep"])
+ assertTrue(rows[0]["localidade"].isNullOrEmpty())
+ }
+
+ @Test
+ fun `short rows are padded and blank lines skipped`() {
+ val rows = Csv.rows("a,b,c\n1\n\n2,3\n")
+
+ assertEquals(2, rows.size)
+ assertEquals(mapOf("a" to "1", "b" to "", "c" to ""), rows[0])
+ assertEquals(mapOf("a" to "2", "b" to "3", "c" to ""), rows[1])
+ }
+}
diff --git a/src/test/kotlin/org/cepis/RetryTest.kt b/src/test/kotlin/org/cepis/RetryTest.kt
new file mode 100644
index 0000000..d70735a
--- /dev/null
+++ b/src/test/kotlin/org/cepis/RetryTest.kt
@@ -0,0 +1,99 @@
+package org.cepis
+
+import java.time.Duration
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+internal class RetryTest : ServerTest() {
+ @Test
+ fun `retries on 500 then succeeds`() {
+ server.respond(
+ "/01001000",
+ Reply(500, """{"erro":"interno"}"""),
+ Reply(200, Fixtures.SE_ADDRESS_JSON),
+ )
+
+ assertEquals("São Paulo", client.lookup("01001000").localidade)
+ assertEquals(2, server.count("/01001000"))
+ }
+
+ @Test
+ fun `exhausts attempts then raises`() {
+ server.respond("/01001000", Reply(503, """{"erro":"interno"}"""))
+ val error = assertFailsWith<ServerErrorException> { client.lookup("01001000") }
+
+ assertEquals(503, error.status)
+ assertEquals(3, server.count("/01001000"))
+ }
+
+ @Test
+ fun `max attempts of one disables retrying`() {
+ val single = Client(baseUrl = server.url, maxAttempts = 1, timeout = Duration.ofSeconds(5))
+ server.respond("/01001000", Reply(500, "{}"))
+
+ assertFailsWith<ServerErrorException> { single.lookup("01001000") }
+ assertEquals(1, server.count("/01001000"))
+ }
+
+ @Test
+ fun `429 becomes rate limited with retry after`() {
+ server.respond("/01001000", Reply(429, "{}", mapOf("Retry-After" to "7")))
+ val error = assertFailsWith<RateLimitedException> { client.lookup("01001000") }
+
+ assertEquals(7.0, error.retryAfter)
+ assertEquals(3, server.count("/01001000"))
+ }
+
+ @Test
+ fun `retry after honours the cap`() {
+ server.respond("/01001000", Reply(429, "{}", mapOf("Retry-After" to "3600")))
+ val started = System.nanoTime()
+
+ assertFailsWith<RateLimitedException> { client.lookup("01001000") }
+ assertTrue((System.nanoTime() - started) / 1e9 < 5.0)
+ }
+
+ @Test
+ fun `retry after accepts an http date`() {
+ val future = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC).plusSeconds(30)
+ val header = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME.format(future)
+ server.respond("/01001000", Reply(429, "{}", mapOf("Retry-After" to header)))
+ val error = assertFailsWith<RateLimitedException> { client.lookup("01001000") }
+ val seconds = error.retryAfter
+
+ assertTrue(seconds != null && seconds > 20.0 && seconds <= 30.0, "got $seconds")
+ }
+
+ @Test
+ fun `network error when nothing listens`() {
+ val unreachable =
+ Client(
+ baseUrl = "http://127.0.0.1:1",
+ maxAttempts = 2,
+ backoffBase = Duration.ofMillis(1),
+ timeout = Duration.ofSeconds(1),
+ )
+
+ assertFailsWith<NetworkException> { unreachable.lookup("01001000") }
+ }
+
+ @Test
+ fun `total timeout bounds the whole call`() {
+ val bounded =
+ Client(
+ baseUrl = server.url,
+ maxAttempts = 50,
+ backoffBase = Duration.ofMillis(50),
+ backoffMax = Duration.ofMillis(50),
+ totalTimeout = Duration.ofMillis(300),
+ )
+ server.respond("/01001000", Reply(500, "{}"))
+ val started = System.nanoTime()
+
+ assertFailsWith<ServerErrorException> { bounded.lookup("01001000") }
+ assertTrue((System.nanoTime() - started) / 1e9 < 3.0)
+ assertTrue(server.count("/01001000") < 50)
+ }
+}
diff --git a/src/test/kotlin/org/cepis/ServerTest.kt b/src/test/kotlin/org/cepis/ServerTest.kt
new file mode 100644
index 0000000..b22975a
--- /dev/null
+++ b/src/test/kotlin/org/cepis/ServerTest.kt
@@ -0,0 +1,51 @@
+package org.cepis
+
+import java.time.Duration
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+
+/**
+ * Fixture with a fake server and a client whose backoff is near zero.
+ *
+ * The backoff is tiny because what is under test is the retry logic, not
+ * `Thread.sleep`'s ability to sleep.
+ */
+internal abstract class ServerTest {
+ protected lateinit var server: FakeServer
+ protected lateinit var client: Client
+
+ @BeforeTest
+ fun startServer() {
+ server = FakeServer()
+ client =
+ Client(
+ baseUrl = server.url,
+ timeout = Duration.ofSeconds(5),
+ backoffBase = Duration.ofMillis(1),
+ backoffMax = Duration.ofMillis(10),
+ )
+ }
+
+ @AfterTest
+ fun stopServer() {
+ server.stop()
+ }
+}
+
+internal object Fixtures {
+ const val SE_ADDRESS_JSON = """
+ {"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",
+ "unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",
+ "estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",
+ "ddd":"11","siafi":"7107"}
+ """
+
+ const val CSV_HEADER =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," +
+ "regiao,ibge,gia,ddd,siafi"
+
+ /** The Sé address with an extra top-level field spliced in. */
+ fun seAddressWith(extra: String): String = SE_ADDRESS_JSON.trim().removeSuffix("}").trimEnd() + ",$extra}"
+
+ fun csv(vararg rows: String): String = CSV_HEADER + "\n" + rows.joinToString("\n") + "\n"
+}