diff options
Diffstat (limited to 'tests/test_cepis.cpp')
| -rw-r--r-- | tests/test_cepis.cpp | 496 |
1 files changed, 496 insertions, 0 deletions
diff --git a/tests/test_cepis.cpp b/tests/test_cepis.cpp new file mode 100644 index 0000000..6b8b9bf --- /dev/null +++ b/tests/test_cepis.cpp @@ -0,0 +1,496 @@ +#include <chrono> +#include <optional> +#include <string> +#include <vector> + +#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<std::string>& 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_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, "<html>captive portal</html>", {}}); + CHECK_THROWS(fixture.client.lookup("01001000"), cepis::InvalidResponse); +} + +CEPIS_TEST(retries_on_500_then_succeeds) { + Fixture fixture; + fixture.server.respond("/01001000", std::vector<Reply>{ + 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<std::optional<cepis::Address>> 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<std::optional<cepis::Address>> 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<std::optional<cepis::Address>> 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<std::optional<cepis::Address>> 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<std::string> 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<std::string> 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<std::optional<cepis::Address>> 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<std::optional<cepis::Address>> 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<std::optional<cepis::Address>> 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<cepis::Json> 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<cepis::Json> 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})")); +} |