summaryrefslogtreecommitdiffstats
path: root/tests/test_cepis.cpp
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:28:10 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:28:10 -0300
commitb71faa6e95a0736f1ff171e006b171467b65db90 (patch)
tree7580c3ab515e8f50726dc68dbc240995ff4a1a4c /tests/test_cepis.cpp
downloadcpp-b71faa6e95a0736f1ff171e006b171467b65db90.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests/test_cepis.cpp')
-rw-r--r--tests/test_cepis.cpp496
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})"));
+}