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"} """ /// The 404 for `25845000`: the CEP the DNE itself lists as Areal's first, and /// which nonetheless does not exist. let arealNotFoundJSON = """ {"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE", "faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro","regiao":"Sudeste", "ibge":"3300225","cep_ini":"25845001","cep_fim":"25849899","ceps":118}} """ 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(message: "x", faixa: nil).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, let faixa) { XCTAssertTrue(message.contains("99999999")) XCTAssertNil(faixa) } } 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: "captive portal") do { _ = try await client.lookup("01001000") XCTFail("expected invalidResponse") } catch CepisError.invalidResponse { } } } final class NotFoundFaixaTests: ServerBackedTestCase { /// Serves `body` as the 404 for `cep` and returns the faixa that survived. private func faixaFromNotFound(_ body: String, cep: String = "25845000") async throws -> Faixa? { routes.respond("/\(cep)", status: 404, body: body) do { _ = try await client.lookup(cep) XCTFail("expected cepNotFound") return nil } catch CepisError.cepNotFound(_, let faixa) { return faixa } } func testNamesTheMunicipalityTheCEPWouldBelongTo() async throws { let decoded = try await faixaFromNotFound(arealNotFoundJSON) let faixa = try XCTUnwrap(decoded) XCTAssertEqual(faixa.localidade, "Areal") XCTAssertEqual(faixa.uf, "RJ") XCTAssertEqual(faixa.estado, "Rio de Janeiro") XCTAssertEqual(faixa.regiao, "Sudeste") XCTAssertEqual(faixa.ibge, "3300225") XCTAssertEqual(faixa.localidadeUF, "Areal/RJ") } /// The faixa decorates the 404; it must never promote it to a success. func testStillThrowsAndReturnsNoAddress() async throws { routes.respond("/25845000", status: 404, body: arealNotFoundJSON) do { let address = try await client.lookup("25845000") XCTFail("a faixa must not turn a not-found into \(address)") } catch CepisError.cepNotFound(let message, let faixa) { XCTAssertTrue(message.contains("25845000")) XCTAssertTrue(message.contains("does not exist")) XCTAssertNotNil(faixa) } } /// `cep_ini` is the first CEP that *exists*, not the DNE's published start /// — which for Areal is `25845000`, the very CEP that just failed. func testBoundsAreTheCEPsThatExistNotTheDNEStart() async throws { let decoded = try await faixaFromNotFound(arealNotFoundJSON) let faixa = try XCTUnwrap(decoded) XCTAssertEqual(faixa.cepIni, "25845001") XCTAssertEqual(faixa.cepFim, "25849899") XCTAssertNotEqual(faixa.cepIni, "25845000") } /// The pair is not a list: 118 CEPs across ~4,900 numbers. func testRangeIsSparse() async throws { let decoded = try await faixaFromNotFound(arealNotFoundJSON) let faixa = try XCTUnwrap(decoded) XCTAssertEqual(faixa.ceps, 118) let first = try XCTUnwrap(Int(faixa.cepIni)) let last = try XCTUnwrap(Int(faixa.cepFim)) let span = last - first + 1 XCTAssertEqual(span, 4899) XCTAssertLessThan(faixa.ceps, span / 10) } func testAbsentWhenNoMunicipalityOwnsTheBand() async throws { let body = #"{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}"# let decoded = try await faixaFromNotFound(body) XCTAssertNil(decoded) } func testNullFaixaIsNil() async throws { let decoded = try await faixaFromNotFound(#"{"erro":"cep_nao_encontrado","faixa":null}"#) XCTAssertNil(decoded) } func testEmptyFaixaIsNil() async throws { let decoded = try await faixaFromNotFound(#"{"erro":"cep_nao_encontrado","faixa":{}}"#) XCTAssertNil(decoded) } func testFaixaWithoutAMunicipalityIsNil() async throws { for value in [#""""#, #"" ""#, "123", "null", "[]"] { let body = #"{"erro":"cep_nao_encontrado","faixa":{"localidade":\#(value),"uf":"RJ"}}"# let decoded = try await faixaFromNotFound(body) XCTAssertNil(decoded, "localidade \(value)") } } func testMalformedFaixaDoesNotBecomeADecodingFailure() async throws { let bodies = [ #"{"erro":"cep_nao_encontrado","faixa":"Areal"}"#, #"{"erro":"cep_nao_encontrado","faixa":[{"localidade":"Areal"}]}"#, #"{"erro":"cep_nao_encontrado","faixa":{"localidade":{"nome":"Areal"}}}"#, ] for body in bodies { let decoded = try await faixaFromNotFound(body) XCTAssertNil(decoded, body) } } /// A 404 body that is not JSON at all is still a clean not-found, never an /// `invalidResponse`. func testNonJSONBodyStillYieldsNotFound() async throws { for body in ["captive portal", ""] { let decoded = try await faixaFromNotFound(body) XCTAssertNil(decoded, body) } } func testToleratesOddFieldTypesAroundTheMunicipality() async throws { let body = """ {"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal","uf":"RJ", "ibge":3300225,"cep_ini":"25845001","ceps":118.0}} """ let decoded = try await faixaFromNotFound(body) let faixa = try XCTUnwrap(decoded) XCTAssertEqual(faixa.localidade, "Areal") XCTAssertEqual(faixa.ceps, 118) XCTAssertEqual(faixa.ibge, "") XCTAssertEqual(faixa.cepFim, "") } func testDescriptionMentionsTheMunicipality() async throws { routes.respond("/25845000", status: 404, body: arealNotFoundJSON) do { _ = try await client.lookup("25845000") XCTFail("expected cepNotFound") } catch let error as CepisError { XCTAssertTrue(error.description.contains("Areal/RJ"), error.description) XCTAssertEqual(error.errorDescription, error.description) XCTAssertEqual(error.faixa?.localidade, "Areal") // `message` stays what it always was, for callers matching on it. XCTAssertFalse(error.message.contains("Areal")) } } func testDescriptionWithoutAFaixaIsJustTheMessage() { let error = CepisError.cepNotFound(message: "CEP 99999999 does not exist", faixa: nil) XCTAssertEqual(error.description, error.message) XCTAssertEqual(error.errorDescription, error.message) XCTAssertNil(error.faixa) } func testFaixaAccessorIsNilForTheOtherCases() { XCTAssertNil(CepisError.invalidCEP("x").faixa) XCTAssertNil(CepisError.networkError(message: "x").faixa) XCTAssertNil(CepisError.serverError(message: "x", status: 500).faixa) } func testAFaixaIsStillNotRetriable() { let faixa = Faixa(localidade: "Areal", uf: "RJ") XCTAssertFalse(CepisError.cepNotFound(message: "x", faixa: faixa).isRetriable) } } 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) } }