From b71faa6e95a0736f1ff171e006b171467b65db90 Mon Sep 17 00:00:00 2001 From: Murilo Ijanc Date: Sun, 26 Jul 2026 12:28:10 -0300 Subject: Initial import --- tests/.clang-tidy | 8 + tests/framework.hpp | 94 ++++++++++ tests/test_cepis.cpp | 496 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/test_main.cpp | 43 +++++ tests/test_server.cpp | 207 +++++++++++++++++++++ tests/test_server.hpp | 74 ++++++++ 6 files changed, 922 insertions(+) create mode 100644 tests/.clang-tidy create mode 100644 tests/framework.hpp create mode 100644 tests/test_cepis.cpp create mode 100644 tests/test_main.cpp create mode 100644 tests/test_server.cpp create mode 100644 tests/test_server.hpp (limited to 'tests') 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 +#include +#include +#include +#include + +namespace cepis::testing { + +/// Registers a test case. `CEPIS_TEST` does this for you. +void register_test(const char* name, std::function 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 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 +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 +#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_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})")); +} 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 +#include +#include +#include +#include + +namespace cepis::testing { +namespace { + +std::vector>>& registry() { + static std::vector>> tests; + return tests; +} + +} // namespace + +void register_test(const char* name, std::function 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 +#include +#include +#include +#include +#include + +#include +#include +#include + +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(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(&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(&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{std::move(reply)}); +} + +void Server::respond(std::string path, std::vector replies) { + const std::scoped_lock guard(mutex_); + routes_[std::move(path)] = std::move(replies); +} + +void Server::handle(std::function 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 Server::calls() const { + const std::scoped_lock guard(mutex_); + return calls_; +} + +Reply Server::next(const std::string& path) { + std::function 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& 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(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 +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cepis::testing { + +/// One canned HTTP response. +struct Reply { + int status = 200; + std::string body; + std::vector> 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:`, 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 replies); + + /// Installs a catch-all that sees every request. Takes precedence over the + /// registered paths. + void handle(std::function 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 calls() const; + + private: + void serve(); + Reply next(const std::string& path); + + int listen_fd_ = -1; + int port_ = 0; + std::atomic stopping_{false}; + std::thread thread_; + + mutable std::mutex mutex_; + std::map> routes_; + std::function handler_; + std::vector calls_; +}; + +} // namespace cepis::testing + +#endif // CEPIS_TESTS_TEST_SERVER_HPP -- cgit v1.3