summaryrefslogtreecommitdiffstats
path: root/tests
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
downloadcpp-0.1.0.tar.gz
Initial importv0.1.0
Diffstat (limited to 'tests')
-rw-r--r--tests/.clang-tidy8
-rw-r--r--tests/framework.hpp94
-rw-r--r--tests/test_cepis.cpp496
-rw-r--r--tests/test_main.cpp43
-rw-r--r--tests/test_server.cpp207
-rw-r--r--tests/test_server.hpp74
6 files changed, 922 insertions, 0 deletions
diff --git a/tests/.clang-tidy b/tests/.clang-tidy
new file mode 100644
index 0000000..0b70d86
--- /dev/null
+++ b/tests/.clang-tidy
@@ -0,0 +1,8 @@
+---
+InheritParentConfig: true
+Checks: >
+ -bugprone-throwing-static-initialization,
+ -bugprone-unchecked-optional-access,
+ -misc-use-anonymous-namespace,
+ -performance-avoid-endl,
+ -readability-convert-member-functions-to-static
diff --git a/tests/framework.hpp b/tests/framework.hpp
new file mode 100644
index 0000000..e3751cf
--- /dev/null
+++ b/tests/framework.hpp
@@ -0,0 +1,94 @@
+#ifndef CEPIS_TESTS_FRAMEWORK_HPP
+#define CEPIS_TESTS_FRAMEWORK_HPP
+
+#include <cmath>
+#include <functional>
+#include <sstream>
+#include <stdexcept>
+#include <string>
+
+namespace cepis::testing {
+
+/// Registers a test case. `CEPIS_TEST` does this for you.
+void register_test(const char* name, std::function<void()> body);
+
+/// Runs every registered test. Returns the process exit code.
+int run_all();
+
+/// Thrown by the assertion helpers; caught by the runner.
+class AssertionFailure : public std::runtime_error {
+ public:
+ explicit AssertionFailure(const std::string& message) : std::runtime_error(message) {}
+};
+
+struct Registrar {
+ Registrar(const char* name, std::function<void()> body) { register_test(name, std::move(body)); }
+};
+
+[[noreturn]] inline void fail(const std::string& detail, const char* file, int line) {
+ std::ostringstream message;
+ message << file << ':' << line << ": " << detail;
+ throw AssertionFailure(message.str());
+}
+
+inline void check_true(bool value, const char* text, const char* file, int line) {
+ if (!value) {
+ fail(std::string("expected ") + text, file, line);
+ }
+}
+
+/// Comparison lives in a function rather than a macro so that the arguments are
+/// bound as parameters: a `CHECK_EQ` on a member of a temporary would otherwise
+/// compare against a reference that has already dangled.
+template <typename Actual, typename Expected>
+void check_equal(const Actual& actual, const Expected& expected, const char* text, const char* file,
+ int line) {
+ if (actual == expected) {
+ return;
+ }
+ std::ostringstream detail;
+ detail << "expected " << text << ", got [" << actual << "] vs [" << expected << ']';
+ fail(detail.str(), file, line);
+}
+
+inline void check_near(double actual, double expected, double tolerance, const char* text,
+ const char* file, int line) {
+ if (std::fabs(actual - expected) <= tolerance) {
+ return;
+ }
+ std::ostringstream detail;
+ detail << "expected " << text << ", got " << actual << " vs " << expected;
+ fail(detail.str(), file, line);
+}
+
+} // namespace cepis::testing
+
+#define CEPIS_TEST(test_name) \
+ static void test_name(); \
+ static const ::cepis::testing::Registrar registrar_##test_name(#test_name, &(test_name)); \
+ static void test_name()
+
+#define CHECK(condition) ::cepis::testing::check_true((condition), #condition, __FILE__, __LINE__)
+
+#define CHECK_EQ(actual, expected) \
+ ::cepis::testing::check_equal((actual), (expected), #actual " == " #expected, __FILE__, __LINE__)
+
+#define CHECK_NEAR(actual, expected, tolerance) \
+ ::cepis::testing::check_near((actual), (expected), (tolerance), #actual " ~= " #expected, \
+ __FILE__, __LINE__)
+
+#define CHECK_THROWS(expression, exception_type) \
+ do { \
+ bool cepis_threw = false; \
+ try { \
+ (void)(expression); \
+ } catch (const exception_type&) { \
+ cepis_threw = true; \
+ } \
+ if (!cepis_threw) { \
+ ::cepis::testing::fail("expected " #expression " to throw " #exception_type, __FILE__, \
+ __LINE__); \
+ } \
+ } while (false)
+
+#endif // CEPIS_TESTS_FRAMEWORK_HPP
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})"));
+}
diff --git a/tests/test_main.cpp b/tests/test_main.cpp
new file mode 100644
index 0000000..d74c473
--- /dev/null
+++ b/tests/test_main.cpp
@@ -0,0 +1,43 @@
+#include "framework.hpp"
+
+#include <exception>
+#include <iostream>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace cepis::testing {
+namespace {
+
+std::vector<std::pair<std::string, std::function<void()>>>& registry() {
+ static std::vector<std::pair<std::string, std::function<void()>>> tests;
+ return tests;
+}
+
+} // namespace
+
+void register_test(const char* name, std::function<void()> body) {
+ registry().emplace_back(name, std::move(body));
+}
+
+int run_all() {
+ int failures = 0;
+ for (const auto& [name, body] : registry()) {
+ try {
+ body();
+ std::cout << "ok " << name << '\n';
+ } catch (const std::exception& error) {
+ ++failures;
+ std::cout << "FAIL " << name << "\n " << error.what() << '\n';
+ } catch (...) {
+ ++failures;
+ std::cout << "FAIL " << name << "\n unknown exception\n";
+ }
+ }
+ std::cout << '\n' << registry().size() << " test(s), " << failures << " failure(s)" << std::endl;
+ return failures == 0 ? 0 : 1;
+}
+
+} // namespace cepis::testing
+
+int main() { return cepis::testing::run_all(); }
diff --git a/tests/test_server.cpp b/tests/test_server.cpp
new file mode 100644
index 0000000..dbb9511
--- /dev/null
+++ b/tests/test_server.cpp
@@ -0,0 +1,207 @@
+#include "test_server.hpp"
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include <cerrno>
+#include <cstring>
+#include <stdexcept>
+
+namespace cepis::testing {
+namespace {
+
+std::string reason_phrase(int status) {
+ switch (status) {
+ case 200:
+ return "OK";
+ case 400:
+ return "Bad Request";
+ case 404:
+ return "Not Found";
+ case 429:
+ return "Too Many Requests";
+ case 500:
+ return "Internal Server Error";
+ case 502:
+ return "Bad Gateway";
+ case 503:
+ return "Service Unavailable";
+ case 504:
+ return "Gateway Timeout";
+ default:
+ return "Status";
+ }
+}
+
+bool write_all(int fd, const std::string& data) {
+ std::size_t sent = 0;
+ while (sent < data.size()) {
+ const ssize_t written = ::send(fd, data.data() + sent, data.size() - sent, MSG_NOSIGNAL);
+ if (written <= 0) {
+ return false;
+ }
+ sent += static_cast<std::size_t>(written);
+ }
+ return true;
+}
+
+} // namespace
+
+Server::Server() {
+ listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
+ if (listen_fd_ < 0) {
+ throw std::runtime_error("socket: " + std::string(std::strerror(errno)));
+ }
+ const int enable = 1;
+ ::setsockopt(listen_fd_, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable));
+
+ sockaddr_in address{};
+ address.sin_family = AF_INET;
+ address.sin_addr.s_addr = ::htonl(INADDR_LOOPBACK);
+ address.sin_port = 0;
+ if (::bind(listen_fd_, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0) {
+ ::close(listen_fd_);
+ throw std::runtime_error("bind: " + std::string(std::strerror(errno)));
+ }
+ if (::listen(listen_fd_, 32) != 0) {
+ ::close(listen_fd_);
+ throw std::runtime_error("listen: " + std::string(std::strerror(errno)));
+ }
+
+ sockaddr_in bound{};
+ socklen_t length = sizeof(bound);
+ if (::getsockname(listen_fd_, reinterpret_cast<sockaddr*>(&bound), &length) != 0) {
+ ::close(listen_fd_);
+ throw std::runtime_error("getsockname: " + std::string(std::strerror(errno)));
+ }
+ port_ = ::ntohs(bound.sin_port);
+
+ thread_ = std::thread([this] { serve(); });
+}
+
+Server::~Server() {
+ stopping_.store(true);
+ if (thread_.joinable()) {
+ thread_.join();
+ }
+ if (listen_fd_ >= 0) {
+ ::close(listen_fd_);
+ }
+}
+
+std::string Server::url() const { return "http://127.0.0.1:" + std::to_string(port_); }
+
+void Server::respond(std::string path, Reply reply) {
+ respond(std::move(path), std::vector<Reply>{std::move(reply)});
+}
+
+void Server::respond(std::string path, std::vector<Reply> replies) {
+ const std::scoped_lock guard(mutex_);
+ routes_[std::move(path)] = std::move(replies);
+}
+
+void Server::handle(std::function<Reply(const std::string&)> handler) {
+ const std::scoped_lock guard(mutex_);
+ handler_ = std::move(handler);
+}
+
+int Server::count(std::string_view prefix) const {
+ const std::scoped_lock guard(mutex_);
+ int total = 0;
+ for (const std::string& call : calls_) {
+ if (call.rfind(prefix, 0) == 0) {
+ ++total;
+ }
+ }
+ return total;
+}
+
+std::vector<std::string> Server::calls() const {
+ const std::scoped_lock guard(mutex_);
+ return calls_;
+}
+
+Reply Server::next(const std::string& path) {
+ std::function<Reply(const std::string&)> handler;
+ {
+ const std::scoped_lock guard(mutex_);
+ calls_.push_back(path);
+ handler = handler_;
+ if (!handler) {
+ const auto found = routes_.find(path);
+ if (found == routes_.end()) {
+ return Reply{404, R"({"erro":"cep_nao_encontrado"})", {}};
+ }
+ std::vector<Reply>& queue = found->second;
+ if (queue.size() > 1) {
+ Reply reply = queue.front();
+ queue.erase(queue.begin());
+ return reply;
+ }
+ return queue.front();
+ }
+ }
+ return handler(path);
+}
+
+void Server::serve() {
+ while (!stopping_.load()) {
+ pollfd waiting{listen_fd_, POLLIN, 0};
+ const int ready = ::poll(&waiting, 1, 10);
+ if (ready <= 0) {
+ continue;
+ }
+
+ const int client = ::accept(listen_fd_, nullptr, nullptr);
+ if (client < 0) {
+ continue;
+ }
+
+ std::string request;
+ char buffer[4096];
+ while (request.find("\r\n\r\n") == std::string::npos) {
+ const ssize_t read_bytes = ::recv(client, buffer, sizeof(buffer), 0);
+ if (read_bytes <= 0) {
+ break;
+ }
+ request.append(buffer, static_cast<std::size_t>(read_bytes));
+ }
+
+ const std::size_t first_space = request.find(' ');
+ const std::size_t second_space =
+ first_space == std::string::npos ? std::string::npos : request.find(' ', first_space + 1);
+ if (second_space == std::string::npos) {
+ ::close(client);
+ continue;
+ }
+ const std::string path = request.substr(first_space + 1, second_space - first_space - 1);
+
+ const Reply reply = next(path);
+ std::string out = "HTTP/1.1 ";
+ out += std::to_string(reply.status);
+ out += ' ';
+ out += reason_phrase(reply.status);
+ out += "\r\n";
+ for (const auto& [name, value] : reply.headers) {
+ out += name;
+ out += ": ";
+ out += value;
+ out += "\r\n";
+ }
+ out += "Content-Length: ";
+ out += std::to_string(reply.body.size());
+ out += "\r\n";
+ out += "Connection: close\r\n\r\n";
+ out += reply.body;
+
+ write_all(client, out);
+ ::shutdown(client, SHUT_WR);
+ ::close(client);
+ }
+}
+
+} // namespace cepis::testing
diff --git a/tests/test_server.hpp b/tests/test_server.hpp
new file mode 100644
index 0000000..54c6e0c
--- /dev/null
+++ b/tests/test_server.hpp
@@ -0,0 +1,74 @@
+#ifndef CEPIS_TESTS_TEST_SERVER_HPP
+#define CEPIS_TESTS_TEST_SERVER_HPP
+
+#include <atomic>
+#include <functional>
+#include <map>
+#include <mutex>
+#include <string>
+#include <string_view>
+#include <thread>
+#include <utility>
+#include <vector>
+
+namespace cepis::testing {
+
+/// One canned HTTP response.
+struct Reply {
+ int status = 200;
+ std::string body;
+ std::vector<std::pair<std::string, std::string>> headers;
+};
+
+/// A throwaway HTTP/1.1 server on a random loopback port.
+///
+/// The tests point `Config::base_url` at it, so the code path they exercise is
+/// the one that runs in production — request construction, header reading and
+/// error-body reading included. Nothing is mocked below the socket, and it
+/// doubles as the test that `base_url` works: without it none of this runs.
+class Server {
+ public:
+ Server();
+ ~Server();
+
+ Server(const Server&) = delete;
+ Server& operator=(const Server&) = delete;
+ Server(Server&&) = delete;
+ Server& operator=(Server&&) = delete;
+
+ /// `http://127.0.0.1:<port>`, ready to hand to `Config::base_url`.
+ [[nodiscard]] std::string url() const;
+
+ /// Registers the response for a path. The last entry of a queue repeats once
+ /// the earlier ones are consumed, which is how retrying is tested.
+ void respond(std::string path, Reply reply);
+ void respond(std::string path, std::vector<Reply> replies);
+
+ /// Installs a catch-all that sees every request. Takes precedence over the
+ /// registered paths.
+ void handle(std::function<Reply(const std::string& path)> handler);
+
+ /// How many requests so far began with `prefix`.
+ [[nodiscard]] int count(std::string_view prefix) const;
+
+ /// Every path requested so far, in order.
+ [[nodiscard]] std::vector<std::string> calls() const;
+
+ private:
+ void serve();
+ Reply next(const std::string& path);
+
+ int listen_fd_ = -1;
+ int port_ = 0;
+ std::atomic<bool> stopping_{false};
+ std::thread thread_;
+
+ mutable std::mutex mutex_;
+ std::map<std::string, std::vector<Reply>> routes_;
+ std::function<Reply(const std::string&)> handler_;
+ std::vector<std::string> calls_;
+};
+
+} // namespace cepis::testing
+
+#endif // CEPIS_TESTS_TEST_SERVER_HPP