summaryrefslogtreecommitdiffstats
path: root/Tests/CepisTests/CepisTests.swift
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:18:32 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:18:32 -0300
commit72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (patch)
tree3e4d33a614b673bc8cd9de1e688245f340cd3e1e /Tests/CepisTests/CepisTests.swift
downloadswift-72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7.tar.gz
Initial importv0.1.0
Diffstat (limited to 'Tests/CepisTests/CepisTests.swift')
-rw-r--r--Tests/CepisTests/CepisTests.swift459
1 files changed, 459 insertions, 0 deletions
diff --git a/Tests/CepisTests/CepisTests.swift b/Tests/CepisTests/CepisTests.swift
new file mode 100644
index 0000000..46699bc
--- /dev/null
+++ b/Tests/CepisTests/CepisTests.swift
@@ -0,0 +1,459 @@
+import Foundation
+import XCTest
+
+@testable import Cepis
+
+#if canImport(FoundationNetworking)
+import FoundationNetworking
+#endif
+
+let seAddressJSON = """
+ {"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"}
+ """
+
+let csvHeader =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+func csvBody(_ rows: [String]) -> String {
+ ([csvHeader] + rows).joined(separator: "\n") + "\n"
+}
+
+class ServerBackedTestCase: XCTestCase {
+ var server: TestHTTPServer!
+ var client: Client!
+
+ var routes: Router { server.router }
+
+ override func setUpWithError() throws {
+ try super.setUpWithError()
+ server = try TestHTTPServer()
+ client = Client(
+ baseURL: server.baseURL,
+ timeout: 5,
+ backoffBase: 0.001,
+ backoffMax: 0.01
+ )
+ }
+
+ override func tearDown() {
+ server?.stop()
+ server = nil
+ client = nil
+ super.tearDown()
+ }
+}
+
+final class NormalisationTests: XCTestCase {
+ func testAcceptsMaskedAndUnmasked() throws {
+ for value in ["01001000", "01001-000", " 01001-000 ", "01001.000"] {
+ XCTAssertEqual(try normalizeCEP(value), "01001000")
+ }
+ }
+
+ func testRejectsBadFormat() {
+ for value in ["", "123", "0100100a", "010010000", "abcdefgh"] {
+ XCTAssertThrowsError(try normalizeCEP(value)) { error in
+ guard case CepisError.invalidCEP = error else {
+ return XCTFail("expected invalidCEP for \"\(value)\", got \(error)")
+ }
+ }
+ }
+ }
+
+ func testInvalidCEPIsNotRetriable() {
+ XCTAssertFalse(CepisError.invalidCEP("x").isRetriable)
+ XCTAssertFalse(CepisError.cepNotFound("x").isRetriable)
+ XCTAssertTrue(CepisError.networkError(message: "x").isRetriable)
+ }
+}
+
+final class LookupTests: ServerBackedTestCase {
+ func testSuccessfulLookup() async throws {
+ routes.respond("/01001000", body: seAddressJSON)
+ let address = try await client.lookup("01001-000")
+ XCTAssertEqual(address.localidade, "São Paulo")
+ XCTAssertEqual(address.logradouro, "Praça da Sé")
+ XCTAssertEqual(address.uf, "SP")
+ XCTAssertEqual(address.formattedCEP, "01001-000")
+ XCTAssertNil(address.geo)
+ }
+
+ func testReadsTheCoordinateWhenPresent() async throws {
+ routes.respond(
+ "/01310100",
+ body: """
+ {"cep":"01310100","logradouro":"Avenida Paulista","localidade":"São Paulo",
+ "uf":"SP","geo":{"lat":-23.5,"lon":-46.6,"raio_m":120,
+ "precisao":"logradouro"}}
+ """)
+ let address = try await client.lookup("01310100")
+ let geo = try XCTUnwrap(address.geo)
+ XCTAssertEqual(geo.lat, -23.5, accuracy: 0.0001)
+ XCTAssertEqual(geo.lon, -46.6, accuracy: 0.0001)
+ XCTAssertEqual(geo.raioM, 120)
+ XCTAssertTrue(geo.isPrecise)
+ }
+
+ func testMunicipalityCoordinateIsNotPrecise() async throws {
+ routes.respond(
+ "/01001000",
+ body: """
+ {"cep":"01001000","localidade":"São Paulo","geo":{"lat":-23.5,"lon":-46.6,
+ "raio_m":22000,"precisao":"localidade"}}
+ """)
+ let address = try await client.lookup("01001000")
+ let geo = try XCTUnwrap(address.geo)
+ XCTAssertFalse(geo.isPrecise)
+ XCTAssertEqual(geo.raioM, 22000)
+ }
+
+ func testMalformedGeoDoesNotLoseTheAddress() async throws {
+ routes.respond(
+ "/01001000",
+ body: #"{"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}}"#)
+ let address = try await client.lookup("01001000")
+ XCTAssertNil(address.geo)
+ XCTAssertEqual(address.localidade, "São Paulo")
+ }
+
+ func testNullGeoDoesNotLoseTheAddress() async throws {
+ routes.respond(
+ "/01001000", body: #"{"cep":"01001000","localidade":"São Paulo","geo":null}"#)
+ let address = try await client.lookup("01001000")
+ XCTAssertNil(address.geo)
+ XCTAssertEqual(address.localidade, "São Paulo")
+ }
+
+ func testNewAPIFieldDoesNotBreak() async throws {
+ routes.respond(
+ "/01001000",
+ body: #"{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"}"#)
+ let address = try await client.lookup("01001000")
+ XCTAssertEqual(address.localidade, "São Paulo")
+ }
+
+ func testNotFound() async throws {
+ routes.respond(
+ "/99999999", status: 404,
+ body: #"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"#)
+ do {
+ _ = try await client.lookup("99999999")
+ XCTFail("expected cepNotFound")
+ } catch CepisError.cepNotFound(let message) {
+ XCTAssertTrue(message.contains("99999999"))
+ }
+ }
+
+ func testNotFoundIsNotRetried() async throws {
+ routes.respond("/99999999", status: 404, body: #"{"erro":"cep_nao_encontrado"}"#)
+ do {
+ _ = try await client.lookup("99999999")
+ XCTFail("expected cepNotFound")
+ } catch CepisError.cepNotFound {
+ }
+ XCTAssertEqual(routes.count("/99999999"), 1)
+ }
+
+ func testInvalidCEPNeverLeavesTheMachine() async throws {
+ do {
+ _ = try await client.lookup("123")
+ XCTFail("expected invalidCEP")
+ } catch CepisError.invalidCEP {
+ }
+ XCTAssertEqual(routes.calls.count, 0)
+ }
+
+ func testAPI400BecomesInvalidCEP() async throws {
+ routes.respond(
+ "/00000000", status: 400,
+ body: #"{"erro":"cep_invalido","mensagem":"rejected by the API"}"#)
+ do {
+ _ = try await client.lookup("00000000")
+ XCTFail("expected invalidCEP")
+ } catch CepisError.invalidCEP(let message) {
+ XCTAssertEqual(message, "rejected by the API")
+ }
+ }
+
+ func testResponseThatIsNotJSON() async throws {
+ routes.respond("/01001000", body: "<html>captive portal</html>")
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected invalidResponse")
+ } catch CepisError.invalidResponse {
+ }
+ }
+}
+
+final class RetryTests: ServerBackedTestCase {
+ func testRetriesOn500ThenSucceeds() async throws {
+ routes.respond(
+ "/01001000",
+ sequence: [
+ StubResponse(status: 500, body: #"{"erro":"interno"}"#),
+ StubResponse(status: 200, body: seAddressJSON),
+ ])
+ let address = try await client.lookup("01001000")
+ XCTAssertEqual(address.localidade, "São Paulo")
+ XCTAssertEqual(routes.count("/01001000"), 2)
+ }
+
+ func testExhaustsAttemptsThenThrows() async throws {
+ routes.respond("/01001000", status: 503, body: #"{"erro":"interno"}"#)
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected serverError")
+ } catch CepisError.serverError(_, let status) {
+ XCTAssertEqual(status, 503)
+ }
+ XCTAssertEqual(routes.count("/01001000"), 3)
+ }
+
+ func testMaxAttemptsOneDisablesRetry() async throws {
+ let single = Client(baseURL: server.baseURL, timeout: 5, maxAttempts: 1)
+ routes.respond("/01001000", status: 500, body: "{}")
+ do {
+ _ = try await single.lookup("01001000")
+ XCTFail("expected serverError")
+ } catch CepisError.serverError {
+ }
+ XCTAssertEqual(routes.count("/01001000"), 1)
+ }
+
+ func test429BecomesRateLimitedWithRetryAfter() async throws {
+ routes.respond("/01001000", status: 429, body: "{}", headers: ["Retry-After": "7"])
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected rateLimited")
+ } catch CepisError.rateLimited(_, let retryAfter) {
+ XCTAssertEqual(retryAfter, 7)
+ }
+ }
+
+ func testRetryAfterHonoursTheCap() async throws {
+ routes.respond("/01001000", status: 429, body: "{}", headers: ["Retry-After": "3600"])
+ let started = Date()
+ do {
+ _ = try await client.lookup("01001000")
+ XCTFail("expected rateLimited")
+ } catch CepisError.rateLimited {
+ }
+ XCTAssertLessThan(Date().timeIntervalSince(started), 5)
+ }
+
+ func testRetryAfterAcceptsAnHTTPDate() throws {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone(identifier: "GMT")
+ formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
+ let header = formatter.string(from: Date().addingTimeInterval(30))
+
+ let seconds = try XCTUnwrap(Client.retryAfterSeconds(header))
+ XCTAssertGreaterThan(seconds, 20)
+ XCTAssertLessThan(seconds, 40)
+ }
+
+ func testRetryAfterInThePastIsClampedToZero() {
+ XCTAssertEqual(Client.retryAfterSeconds("-5"), 0)
+ XCTAssertNil(Client.retryAfterSeconds(nil))
+ XCTAssertNil(Client.retryAfterSeconds("not a date"))
+ }
+
+ func testNetworkErrorWhenNothingListens() async throws {
+ let unreachable = Client(
+ baseURL: URL(string: "http://127.0.0.1:1")!,
+ timeout: 1,
+ maxAttempts: 2,
+ backoffBase: 0.001,
+ backoffMax: 0.01
+ )
+ do {
+ _ = try await unreachable.lookup("01001000")
+ XCTFail("expected networkError")
+ } catch CepisError.networkError {
+ }
+ }
+
+ func testTotalTimeoutBoundsTheWholeCall() async throws {
+ let bounded = Client(
+ baseURL: server.baseURL,
+ timeout: 5,
+ maxAttempts: 50,
+ backoffBase: 0.05,
+ backoffMax: 0.05,
+ totalTimeout: 0.3
+ )
+ routes.respond("/01001000", status: 500, body: "{}")
+ let started = Date()
+ do {
+ _ = try await bounded.lookup("01001000")
+ XCTFail("expected serverError")
+ } catch CepisError.serverError {
+ }
+ XCTAssertLessThan(Date().timeIntervalSince(started), 5)
+ XCTAssertLessThan(routes.count("/01001000"), 50)
+ }
+}
+
+final class BatchTests: ServerBackedTestCase {
+ func testPreservesOrderAndMarksMissing() async throws {
+ let body = csvBody([
+ "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",
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000", "99999999", "20040020"])
+ XCTAssertEqual(result.count, 3)
+ XCTAssertEqual(result[0]?.localidade, "São Paulo")
+ XCTAssertNil(result[1])
+ XCTAssertEqual(result[2]?.uf, "RJ")
+ XCTAssertNil(result[0]?.geo)
+ }
+
+ func testFieldWithCommaIsParsed() async throws {
+ let body = csvBody([
+ #"01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"#
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000"])
+ XCTAssertEqual(result[0]?.logradouro, "Rua Um, Dois")
+ }
+
+ func testEscapedQuotesInAField() async throws {
+ let body = csvBody([
+ #"01001000,"Rua ""Dois"" Irmãos",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1"#
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000"])
+ XCTAssertEqual(result[0]?.logradouro, #"Rua "Dois" Irmãos"#)
+ }
+
+ func testEmptyListMakesNoRequest() async throws {
+ let result = try await client.lookupMany([])
+ XCTAssertTrue(result.isEmpty)
+ XCTAssertEqual(routes.calls.count, 0)
+ }
+
+ func testChunksAboveTheAPILimit() async throws {
+ let ceps = (0..<501).map { String(format: "%08d", $0) }
+ let first = csvBody(ceps[0..<500].map { "\($0),,,,,X,SP,São Paulo,Sudeste,1,,11,1" })
+ let second = csvBody(ceps[500...].map { "\($0),,,,,X,SP,São Paulo,Sudeste,1,,11,1" })
+
+ let served = NSLock()
+ var count = 0
+ routes.setHandler { _ in
+ served.lock()
+ defer { served.unlock() }
+ count += 1
+ return StubResponse(status: 200, body: count == 1 ? first : second)
+ }
+
+ let result = try await client.lookupMany(ceps)
+ XCTAssertEqual(routes.count("/csv"), 2)
+ XCTAssertEqual(result.count, 501)
+ XCTAssertEqual(result[500]?.cep, "00000500")
+ }
+
+ func testTruncatedResponseDoesNotShift() async throws {
+ let body = csvBody(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000", "20040020"])
+ XCTAssertEqual(result.count, 2)
+ XCTAssertEqual(result[0]?.localidade, "São Paulo")
+ XCTAssertNil(result[1])
+ }
+
+ func testMalformedCEPBecomesNilRatherThanThrowing() async throws {
+ let body = csvBody([
+ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1",
+ "abc,,,,,,,,,,,,",
+ ])
+ routes.setHandler { _ in StubResponse(status: 200, body: body) }
+
+ let result = try await client.lookupMany(["01001000", "abc"])
+ XCTAssertEqual(result.count, 2)
+ XCTAssertNil(result[1])
+ }
+}
+
+final class HealthTests: ServerBackedTestCase {
+ func testReadsHealth() async throws {
+ routes.respond(
+ "/health",
+ body: """
+ {"status":"ok","dne":"26071","ceps":1596168,"geo":894489,
+ "base_gerada_em":"2026-07-24T03:48:28Z"}
+ """)
+ let health = try await client.health()
+ XCTAssertTrue(health.ok)
+ XCTAssertEqual(health.dne, "26071")
+ XCTAssertEqual(health.ceps, 1_596_168)
+ XCTAssertEqual(health.geo, 894_489)
+ XCTAssertEqual(health.baseGeradaEm, "2026-07-24T03:48:28Z")
+ }
+
+ func testDegradedDoesNotThrow() async throws {
+ routes.respond(
+ "/health", status: 503,
+ body: #"{"status":"degradado","dne":"","ceps":0,"geo":0}"#)
+ let health = try await client.health()
+ XCTAssertFalse(health.ok)
+ XCTAssertEqual(health.status, "degradado")
+ XCTAssertEqual(routes.count("/health"), 1)
+ }
+}
+
+final class ConfigurationTests: XCTestCase {
+ func testTrailingSlashIsNotDuplicated() {
+ let client = Client(baseURL: URL(string: "http://example.test/")!)
+ XCTAssertEqual(client.baseURL.absoluteString, "http://example.test")
+ }
+
+ func testUserAgentIdentifiesTheLibrary() {
+ XCTAssertTrue(Client().userAgent.hasPrefix("cepis-swift/"))
+ XCTAssertTrue(Client().userAgent.contains(Client.version))
+ }
+
+ func testDefaultBaseURL() {
+ XCTAssertEqual(Client.defaultBaseURL.absoluteString, "https://cep.is")
+ XCTAssertEqual(Client.maxBatch, 500)
+ }
+
+ func testFormattedCEPLeavesOddLengthsAlone() {
+ XCTAssertEqual(Address(cep: "01001000").formattedCEP, "01001-000")
+ XCTAssertEqual(Address(cep: "123").formattedCEP, "123")
+ }
+}
+
+final class CSVTests: XCTestCase {
+ func testParsesQuotedFieldsAndCRLF() {
+ let rows = CSV.parse("a,b\r\n\"x,1\",\"y\"\"z\"\r\n")
+ XCTAssertEqual(rows.count, 2)
+ XCTAssertEqual(rows[0], ["a", "b"])
+ XCTAssertEqual(rows[1], ["x,1", "y\"z"])
+ }
+
+ func testParsesLastLineWithoutNewline() {
+ let rows = CSV.parse("a,b\n1,2")
+ XCTAssertEqual(rows.count, 2)
+ XCTAssertEqual(rows[1], ["1", "2"])
+ }
+
+ func testTrailingNewlineDoesNotProduceAnEmptyRow() {
+ XCTAssertEqual(CSV.parse("a,b\n1,2\n").count, 2)
+ }
+
+ func testEmptyInput() {
+ XCTAssertTrue(CSV.parse("").isEmpty)
+ }
+}