diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:18:32 -0300 |
| commit | 72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7 (patch) | |
| tree | 3e4d33a614b673bc8cd9de1e688245f340cd3e1e /Tests/CepisTests/CepisTests.swift | |
| download | swift-72a4b451c9d06f7a97cac2f43f1b7f52fafcd3b7.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'Tests/CepisTests/CepisTests.swift')
| -rw-r--r-- | Tests/CepisTests/CepisTests.swift | 459 |
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) + } +} |