#include #include #include #include #include "cepis/cepis.hpp" #include "framework.hpp" #include "test_server.hpp" namespace { using cepis::testing::Reply; using cepis::testing::Server; const char* const kSeAddress = R"({"cep":"01001000","logradouro":"Praça da Sé","complemento":"lado ímpar",)" R"("unidade":"","bairro":"Sé","localidade":"São Paulo","uf":"SP",)" R"("estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",)" R"("ddd":"11","siafi":"7107"})"; const char* const kCsvHeader = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"; /// The defaults every test starts from, with the backoff turned down. /// /// The backoff is tiny because what is under test is the retry logic, not the /// ability of the standard library to sleep. cepis::Config fast_config(std::string base_url) { cepis::Config config; config.base_url = std::move(base_url); config.timeout = std::chrono::milliseconds{5000}; config.backoff_base = std::chrono::milliseconds{1}; config.backoff_max = std::chrono::milliseconds{10}; return config; } /// A fake server plus a client pointed at it. struct Fixture { Server server; cepis::Client client; Fixture() : client(fast_config(server.url())) {} }; std::string csv(const std::vector& rows) { std::string body = std::string(kCsvHeader) + "\n"; for (const std::string& row : rows) { body += row + "\n"; } return body; } } // namespace CEPIS_TEST(normalize_accepts_masked_and_unmasked) { for (const std::string value : {"01001000", "01001-000", " 01001-000 ", "01001.000"}) { CHECK_EQ(cepis::normalize_cep(value), std::string("01001000")); } } CEPIS_TEST(normalize_rejects_bad_format) { for (const std::string value : {"", "123", "0100100a", "010010000", "abcdefgh"}) { CHECK_THROWS(cepis::normalize_cep(value), cepis::InvalidCep); } } CEPIS_TEST(every_error_shares_one_base) { CHECK_THROWS(cepis::normalize_cep("123"), cepis::Error); CHECK_THROWS(cepis::normalize_cep("123"), std::exception); } CEPIS_TEST(successful_lookup) { Fixture fixture; fixture.server.respond("/01001000", Reply{200, kSeAddress, {}}); const cepis::Address address = fixture.client.lookup("01001-000"); CHECK_EQ(address.localidade, std::string("São Paulo")); CHECK_EQ(address.logradouro, std::string("Praça da Sé")); CHECK_EQ(address.uf, std::string("SP")); CHECK_EQ(address.formatted_cep(), std::string("01001-000")); CHECK(!address.geo.has_value()); } CEPIS_TEST(reads_the_coordinate_when_present) { Fixture fixture; fixture.server.respond( "/01310100", Reply{200, R"({"cep":"01310100","localidade":"São Paulo","geo":{"lat":-23.567202,)" R"("lon":-46.649659,"raio_m":120,"precisao":"logradouro"}})", {}}); const cepis::Address address = fixture.client.lookup("01310100"); CHECK(address.geo.has_value()); CHECK_NEAR(address.geo->lat, -23.567202, 1e-9); CHECK_NEAR(address.geo->lon, -46.649659, 1e-9); CHECK_EQ(address.geo->raio_m, 120); CHECK(address.geo->is_precise()); } CEPIS_TEST(municipality_coordinate_is_not_precise) { Fixture fixture; fixture.server.respond("/01001000", Reply{200, R"({"cep":"01001000","geo":{"lat":-23.5,"lon":-46.6,"raio_m":22000,)" R"("precisao":"localidade"}})", {}}); const cepis::Address address = fixture.client.lookup("01001000"); CHECK(address.geo.has_value()); CHECK(!address.geo->is_precise()); CHECK_EQ(address.geo->raio_m, 22000); } CEPIS_TEST(malformed_geo_does_not_lose_the_address) { Fixture fixture; fixture.server.respond( "/01001000", Reply{ 200, R"({"cep":"01001000","localidade":"São Paulo","geo":{"lat":"not a number"}})", {}}); const cepis::Address address = fixture.client.lookup("01001000"); CHECK(!address.geo.has_value()); CHECK_EQ(address.localidade, std::string("São Paulo")); } CEPIS_TEST(new_api_field_does_not_break_the_client) { Fixture fixture; fixture.server.respond("/01001000", Reply{200, R"({"cep":"01001000","campo_do_futuro":"value"})", {}}); const cepis::Address address = fixture.client.lookup("01001000"); CHECK_EQ(address.cep, std::string("01001000")); const cepis::Json* grown = address.raw.find("campo_do_futuro"); CHECK(grown != nullptr); CHECK_EQ(grown->as_string(), std::string("value")); } CEPIS_TEST(not_found_throws_cep_not_found) { Fixture fixture; fixture.server.respond( "/99999999", Reply{ 404, R"({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"})", {}}); CHECK_THROWS(fixture.client.lookup("99999999"), cepis::CepNotFound); } CEPIS_TEST(not_found_names_the_municipality_when_the_api_does) { Fixture fixture; fixture.server.respond( "/25845000", Reply{404, R"({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE",)" R"("faixa":{"localidade":"Areal","uf":"RJ","estado":"Rio de Janeiro",)" R"("regiao":"Sudeste","ibge":"3300225","cep_ini":"25845001",)" R"("cep_fim":"25849899","ceps":118}})", {}}); bool threw = false; try { (void)fixture.client.lookup("25845-000"); } catch (const cepis::CepNotFound& error) { threw = true; CHECK(error.faixa().has_value()); CHECK_EQ(error.faixa()->localidade, std::string("Areal")); CHECK_EQ(error.faixa()->uf, std::string("RJ")); CHECK_EQ(error.faixa()->estado, std::string("Rio de Janeiro")); CHECK_EQ(error.faixa()->regiao, std::string("Sudeste")); CHECK_EQ(error.faixa()->ibge, std::string("3300225")); // The bounds are the CEPs that exist, not the ones the Correios publish: // the DNE's own start for Areal is 25845000 — the CEP that just failed. CHECK_EQ(error.faixa()->cep_ini, std::string("25845001")); CHECK_EQ(error.faixa()->cep_fim, std::string("25849899")); // And the range is sparse: 118 CEPs across ~4,900 numbers. CHECK_EQ(error.faixa()->ceps, std::int64_t{118}); CHECK(std::string(error.what()).find("Areal/RJ") != std::string::npos); } CHECK(threw); } CEPIS_TEST(not_found_without_a_faixa_is_still_a_plain_not_found) { Fixture fixture; fixture.server.respond( "/99999999", Reply{ 404, R"({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"})", {}}); bool threw = false; try { (void)fixture.client.lookup("99999999"); } catch (const cepis::CepNotFound& error) { threw = true; // The API omits the block for a band no municipality owns. It never sends // null, and an absent block is not an error. CHECK(!error.faixa().has_value()); CHECK(std::string(error.what()).find("99999999") != std::string::npos); } CHECK(threw); } CEPIS_TEST(malformed_faixa_does_not_become_a_parse_failure) { for (const std::string body : { R"({"erro":"cep_nao_encontrado","faixa":{}})", R"({"erro":"cep_nao_encontrado","faixa":null})", R"({"erro":"cep_nao_encontrado","faixa":"Areal"})", R"({"erro":"cep_nao_encontrado","faixa":{"localidade":"","uf":"RJ"}})", R"({"erro":"cep_nao_encontrado","faixa":{"uf":"RJ","ceps":118}})", "gateway", }) { Fixture broken; broken.server.respond("/99999999", Reply{404, body, {}}); bool threw = false; try { (void)broken.client.lookup("99999999"); } catch (const cepis::CepNotFound& error) { threw = true; CHECK(!error.faixa().has_value()); } CHECK(threw); } } CEPIS_TEST(a_faixa_never_turns_a_not_found_into_an_address) { Fixture fixture; fixture.server.respond( "/25845000", Reply{404, R"({"erro":"cep_nao_encontrado","faixa":{"localidade":"Areal",)" R"("uf":"RJ","cep_ini":"25845001","cep_fim":"25849899","ceps":118}})", {}}); CHECK_THROWS(fixture.client.lookup("25845000"), cepis::CepNotFound); CHECK_EQ(fixture.server.count("/25845000"), 1); } CEPIS_TEST(not_found_is_not_retried) { Fixture fixture; fixture.server.respond("/99999999", Reply{404, R"({"erro":"cep_nao_encontrado"})", {}}); CHECK_THROWS(fixture.client.lookup("99999999"), cepis::CepNotFound); CHECK_EQ(fixture.server.count("/99999999"), 1); } CEPIS_TEST(invalid_cep_never_leaves_the_machine) { const Fixture fixture; CHECK_THROWS(fixture.client.lookup("123"), cepis::InvalidCep); CHECK_EQ(fixture.server.calls().size(), std::size_t{0}); } CEPIS_TEST(api_400_becomes_invalid_cep_with_its_message) { Fixture fixture; fixture.server.respond( "/00000000", Reply{400, R"({"erro":"cep_invalido","mensagem":"rejected by the API"})", {}}); bool threw = false; try { (void)fixture.client.lookup("00000000"); } catch (const cepis::InvalidCep& error) { threw = true; CHECK_EQ(std::string(error.what()), std::string("rejected by the API")); } CHECK(threw); CHECK_EQ(fixture.server.count("/00000000"), 1); } CEPIS_TEST(response_that_is_not_json_is_an_invalid_response) { Fixture fixture; fixture.server.respond("/01001000", Reply{200, "captive portal", {}}); CHECK_THROWS(fixture.client.lookup("01001000"), cepis::InvalidResponse); } CEPIS_TEST(retries_on_500_then_succeeds) { Fixture fixture; fixture.server.respond("/01001000", std::vector{ Reply{500, R"({"erro":"interno"})", {}}, Reply{200, kSeAddress, {}}, }); const cepis::Address address = fixture.client.lookup("01001000"); CHECK_EQ(address.localidade, std::string("São Paulo")); CHECK_EQ(fixture.server.count("/01001000"), 2); } CEPIS_TEST(exhausts_attempts_then_throws_server_error) { Fixture fixture; fixture.server.respond("/01001000", Reply{503, R"({"erro":"interno"})", {}}); bool threw = false; try { (void)fixture.client.lookup("01001000"); } catch (const cepis::ServerError& error) { threw = true; CHECK_EQ(error.status(), 503); } CHECK(threw); CHECK_EQ(fixture.server.count("/01001000"), 3); } CEPIS_TEST(max_attempts_one_disables_retrying) { Server server; cepis::Config config; config.base_url = server.url(); config.max_attempts = 1; config.timeout = std::chrono::milliseconds{5000}; const cepis::Client client(config); server.respond("/01001000", Reply{500, "{}", {}}); CHECK_THROWS(client.lookup("01001000"), cepis::ServerError); CHECK_EQ(server.count("/01001000"), 1); } CEPIS_TEST(rate_limited_exposes_retry_after) { Fixture fixture; fixture.server.respond("/01001000", Reply{429, "{}", {{"Retry-After", "7"}}}); bool threw = false; try { (void)fixture.client.lookup("01001000"); } catch (const cepis::RateLimited& error) { threw = true; CHECK(error.retry_after().has_value()); CHECK_NEAR(*error.retry_after(), 7.0, 1e-9); } CHECK(threw); } CEPIS_TEST(retry_after_as_an_http_date_is_understood) { Fixture fixture; fixture.server.respond("/01001000", Reply{429, "{}", {{"Retry-After", "Wed, 21 Oct 2099 07:28:00 GMT"}}}); bool threw = false; try { (void)fixture.client.lookup("01001000"); } catch (const cepis::RateLimited& error) { threw = true; CHECK(error.retry_after().has_value()); CHECK(*error.retry_after() > 1.0e9); } CHECK(threw); } CEPIS_TEST(retry_after_honours_the_cap) { Fixture fixture; fixture.server.respond("/01001000", Reply{429, "{}", {{"Retry-After", "3600"}}}); const auto started = std::chrono::steady_clock::now(); CHECK_THROWS(fixture.client.lookup("01001000"), cepis::RateLimited); const auto elapsed = std::chrono::steady_clock::now() - started; CHECK(elapsed < std::chrono::seconds{5}); } CEPIS_TEST(network_error_when_nothing_listens) { cepis::Config config; config.base_url = "http://127.0.0.1:1"; config.max_attempts = 2; config.timeout = std::chrono::milliseconds{1000}; config.backoff_base = std::chrono::milliseconds{1}; const cepis::Client client(config); CHECK_THROWS(client.lookup("01001000"), cepis::NetworkError); } CEPIS_TEST(total_timeout_bounds_the_whole_call) { Server server; cepis::Config config; config.base_url = server.url(); config.max_attempts = 50; config.timeout = std::chrono::milliseconds{5000}; config.backoff_base = std::chrono::milliseconds{50}; config.backoff_max = std::chrono::milliseconds{50}; config.total_timeout = std::chrono::milliseconds{300}; const cepis::Client client(config); server.respond("/01001000", Reply{500, "{}", {}}); const auto started = std::chrono::steady_clock::now(); CHECK_THROWS(client.lookup("01001000"), cepis::ServerError); const auto elapsed = std::chrono::steady_clock::now() - started; CHECK(elapsed < std::chrono::seconds{3}); CHECK(server.count("/01001000") < 50); } CEPIS_TEST(batch_preserves_order_and_marks_missing) { Fixture fixture; fixture.server.respond( "/csv?ceps=01001000%2C99999999%2C20040020", Reply{200, 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"}), {}}); const std::vector> result = fixture.client.lookup_many({"01001000", "99999999", "20040020"}); CHECK_EQ(result.size(), std::size_t{3}); CHECK(result[0].has_value()); CHECK_EQ(result[0]->localidade, std::string("São Paulo")); CHECK(!result[1].has_value()); CHECK(result[2].has_value()); CHECK_EQ(result[2]->uf, std::string("RJ")); } CEPIS_TEST(batch_never_carries_a_coordinate) { Fixture fixture; fixture.server.respond( "/csv?ceps=01001000", Reply{200, csv({"01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1"}), {}}); const std::vector> result = fixture.client.lookup_many({"01001000"}); CHECK(result[0].has_value()); CHECK(!result[0]->geo.has_value()); } CEPIS_TEST(batch_field_with_a_comma_is_parsed) { Fixture fixture; fixture.server.respond( "/csv?ceps=01001000", Reply{200, csv({R"(01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)"}), {}}); const std::vector> result = fixture.client.lookup_many({"01001000"}); CHECK(result[0].has_value()); CHECK_EQ(result[0]->logradouro, std::string("Rua Um, Dois")); } CEPIS_TEST(batch_field_with_an_escaped_quote_is_parsed) { Fixture fixture; fixture.server.respond( "/csv?ceps=01001000", Reply{200, csv({R"(01001000,"Rua ""Aspas""",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)"}), {}}); const std::vector> result = fixture.client.lookup_many({"01001000"}); CHECK(result[0].has_value()); CHECK_EQ(result[0]->logradouro, std::string("Rua \"Aspas\"")); } CEPIS_TEST(batch_with_an_empty_list_makes_no_request) { const Fixture fixture; CHECK_EQ(fixture.client.lookup_many({}).size(), std::size_t{0}); CHECK_EQ(fixture.server.calls().size(), std::size_t{0}); } CEPIS_TEST(batch_chunks_above_the_api_limit) { std::vector ceps; ceps.reserve(501); for (int i = 0; i < 501; ++i) { std::string cep = std::to_string(i); cep.insert(cep.begin(), 8 - cep.size(), '0'); ceps.push_back(cep); } std::vector first_rows; first_rows.reserve(500); for (std::size_t i = 0; i < 500; ++i) { first_rows.push_back(ceps[i] + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"); } const std::string first = csv(first_rows); const std::string second = csv({ceps[500] + ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"}); int seen = 0; Fixture fixture; fixture.server.handle([&](const std::string&) { ++seen; return Reply{200, seen == 1 ? first : second, {}}; }); const std::vector> result = fixture.client.lookup_many(ceps); CHECK_EQ(seen, 2); CHECK_EQ(result.size(), std::size_t{501}); CHECK(result[500].has_value()); CHECK_EQ(result[499]->cep, ceps[499]); } CEPIS_TEST(batch_truncated_by_a_proxy_pads_instead_of_shifting) { Fixture fixture; fixture.server.respond( "/csv?ceps=01001000%2C20040020", Reply{200, csv({"01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"}), {}}); const std::vector> result = fixture.client.lookup_many({"01001000", "20040020"}); CHECK_EQ(result.size(), std::size_t{2}); CHECK(result[0].has_value()); CHECK(!result[1].has_value()); } CEPIS_TEST(batch_with_a_malformed_cep_yields_an_empty_slot) { Fixture fixture; fixture.server.respond( "/csv?ceps=01001000%2C123", Reply{200, csv({"01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", "123,,,,,,,,,,,,"}), {}}); const std::vector> result = fixture.client.lookup_many({"01001000", "123"}); CHECK_EQ(result.size(), std::size_t{2}); CHECK(result[0].has_value()); CHECK(!result[1].has_value()); } CEPIS_TEST(health_reports_the_database_in_use) { Fixture fixture; fixture.server.respond("/health", Reply{200, R"({"status":"ok","dne":"26071","ceps":1596168,"geo":894489,)" R"("base_gerada_em":"2026-07-24T03:48:28Z"})", {}}); const cepis::Health health = fixture.client.health(); CHECK(health.ok()); CHECK_EQ(health.dne, std::string("26071")); CHECK_EQ(health.ceps, std::int64_t{1596168}); CHECK_EQ(health.geo, std::int64_t{894489}); CHECK_EQ(health.base_gerada_em, std::string("2026-07-24T03:48:28Z")); } CEPIS_TEST(health_degraded_does_not_throw_or_retry) { Fixture fixture; fixture.server.respond("/health", Reply{503, R"({"status":"degradado","dne":"","ceps":0,"geo":0})", {}}); const cepis::Health health = fixture.client.health(); CHECK(!health.ok()); CHECK_EQ(health.status, std::string("degradado")); CHECK_EQ(fixture.server.count("/health"), 1); } CEPIS_TEST(trailing_slash_is_not_duplicated) { cepis::Config config; config.base_url = "http://x/"; CHECK_EQ(cepis::Client(config).config().base_url, std::string("http://x")); } CEPIS_TEST(invalid_configuration_is_rejected) { cepis::Config attempts; attempts.max_attempts = 0; CHECK_THROWS(cepis::Client(attempts), std::invalid_argument); cepis::Config timeout; timeout.timeout = std::chrono::milliseconds{0}; CHECK_THROWS(cepis::Client(timeout), std::invalid_argument); } CEPIS_TEST(user_agent_identifies_the_library) { CHECK(cepis::Client().config().user_agent.rfind("cepis-cpp/", 0) == 0); CHECK_EQ(cepis::Client().config().base_url, std::string("https://cep.is")); } CEPIS_TEST(json_decodes_escapes_and_utf8) { const std::optional parsed = cepis::Json::parse(R"({"a":"São Paulo","b":"café","c":"😀","d":"tab\there"})"); CHECK(parsed.has_value()); CHECK_EQ(parsed->find("a")->as_string(), std::string("São Paulo")); CHECK_EQ(parsed->find("b")->as_string(), std::string("café")); CHECK_EQ(parsed->find("c")->as_string(), std::string("\U0001F600")); CHECK_EQ(parsed->find("d")->as_string(), std::string("tab\there")); } CEPIS_TEST(json_rejects_malformed_input) { CHECK(!cepis::Json::parse("").has_value()); CHECK(!cepis::Json::parse("{").has_value()); CHECK(!cepis::Json::parse("{\"a\":1}garbage").has_value()); CHECK(!cepis::Json::parse("{'a':1}").has_value()); CHECK(!cepis::Json::parse("[1,]").has_value()); CHECK(cepis::Json::parse(" {\"a\" : [1, 2.5e3, true, null] } ").has_value()); } CEPIS_TEST(json_round_trips_utf8_and_preserves_field_order) { const std::optional parsed = cepis::Json::parse(R"({"cep":"01001000","logradouro":"Praça da Sé","ceps":1596168})"); CHECK(parsed.has_value()); CHECK_EQ(parsed->dump(), std::string(R"({"cep":"01001000","logradouro":"Praça da Sé","ceps":1596168})")); }