diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:25:48 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:25:48 -0300 |
| commit | e2c6ae13af335b311da2429c903032124f926df2 (patch) | |
| tree | 73776fab7f3e52124a217e2768968fc9a8a76c34 /tests | |
| download | c-0.1.0.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test_cepis.c | 935 | ||||
| -rw-r--r-- | tests/test_server.c | 417 | ||||
| -rw-r--r-- | tests/test_server.h | 69 |
3 files changed, 1421 insertions, 0 deletions
diff --git a/tests/test_cepis.c b/tests/test_cepis.c new file mode 100644 index 0000000..ee23305 --- /dev/null +++ b/tests/test_cepis.c @@ -0,0 +1,935 @@ +#include "cepis.h" +#include "test_server.h" + +#include <math.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +static int tests_run; +static int tests_failed; +static const char *current_test; +static bool current_failed; + +#define CHECK(expr) \ + do { \ + if (!(expr)) { \ + printf(" FAIL %s:%d: %s\n", __FILE__, __LINE__, \ + #expr); \ + current_failed = true; \ + } \ + } while (0) + +#define CHECK_STR(actual, expected) \ + do { \ + const char *a_ = (actual); \ + const char *e_ = (expected); \ + if (a_ == NULL || strcmp(a_, e_) != 0) { \ + printf(" FAIL %s:%d: expected \"%s\", got \"%s\"\n", \ + __FILE__, __LINE__, e_, \ + a_ != NULL ? a_ : "(null)"); \ + current_failed = true; \ + } \ + } while (0) + +#define RUN(fn) \ + do { \ + current_test = #fn; \ + current_failed = false; \ + tests_run++; \ + printf("%s\n", current_test); \ + fn(); \ + if (current_failed) \ + tests_failed++; \ + } while (0) + +static const char SE_ADDRESS[] = + "{\"cep\":\"01001000\",\"logradouro\":\"Pra\xC3\xA7\x61 da S\xC3\xA9\"," + "\"complemento\":\"lado \xC3\xADmpar\",\"unidade\":\"\",\"bairro\":" + "\"S\xC3\xA9\",\"localidade\":\"S\xC3\xA3o Paulo\",\"uf\":\"SP\"," + "\"estado\":\"S\xC3\xA3o Paulo\",\"regiao\":\"Sudeste\",\"ibge\":" + "\"3550308\",\"gia\":\"1004\",\"ddd\":\"11\",\"siafi\":\"7107\"}"; + +static const char CSV_HEADER[] = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao," + "ibge,gia,ddd,siafi\n"; + +typedef struct { + test_server_t *server; + cepis_client_t *client; +} fixture_t; + +static void fixture_up(fixture_t *fixture) +{ + cepis_config_t config; + cepis_error_t err; + + fixture->server = ts_start(); + if (fixture->server == NULL) { + printf(" FAIL could not start the test server\n"); + current_failed = true; + fixture->client = NULL; + return; + } + cepis_config_init(&config); + config.base_url = ts_base_url(fixture->server); + config.timeout = 5.0; + config.backoff_base = 0.001; + config.backoff_max = 0.01; + if (cepis_client_new(&config, &fixture->client, &err) != CEPIS_OK) { + printf(" FAIL could not build a client: %s\n", err.message); + current_failed = true; + fixture->client = NULL; + } +} + +static void fixture_down(fixture_t *fixture) +{ + cepis_client_free(fixture->client); + ts_stop(fixture->server); +} + +static double elapsed_since(double start) +{ + struct timespec ts; + + clock_gettime(CLOCK_MONOTONIC, &ts); + return ((double)ts.tv_sec + (double)ts.tv_nsec / 1e9) - start; +} + +static double now_seconds(void) +{ + return elapsed_since(0.0); +} + +static void test_normalize_accepts_masked_and_unmasked(void) +{ + static const char *inputs[] = {"01001000", "01001-000", " 01001-000 ", + "01001.000"}; + char out[CEPIS_CEP_LEN + 1]; + size_t i; + + for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) { + CHECK(cepis_normalize_cep(inputs[i], out) == CEPIS_OK); + CHECK_STR(out, "01001000"); + } +} + +static void test_normalize_rejects_bad_format(void) +{ + static const char *inputs[] = {"", "123", "0100100a", + "010010000", "abcdefgh", "-1001000"}; + char out[CEPIS_CEP_LEN + 1]; + size_t i; + + for (i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) + CHECK(cepis_normalize_cep(inputs[i], out) == + CEPIS_ERR_INVALID_CEP); + CHECK(cepis_normalize_cep(NULL, out) == CEPIS_ERR_INVALID_CEP); +} + +static void test_format_cep(void) +{ + char out[CEPIS_CEP_LEN + 2]; + + CHECK(cepis_format_cep("01001000", out) == CEPIS_OK); + CHECK_STR(out, "01001-000"); + CHECK(cepis_format_cep("123", out) == CEPIS_ERR_INVALID_CEP); +} + +static void test_successful_lookup(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + char masked[CEPIS_CEP_LEN + 2]; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 200, SE_ADDRESS, NULL); + + CHECK(cepis_lookup(fixture.client, "01001-000", &address, &err) == + CEPIS_OK); + if (address != NULL) { + CHECK_STR(address->localidade, "S\xC3\xA3o Paulo"); + CHECK_STR(address->logradouro, "Pra\xC3\xA7\x61 da S\xC3\xA9"); + CHECK_STR(address->uf, "SP"); + CHECK_STR(address->cep, "01001000"); + CHECK(address->geo == NULL); + CHECK(address->raw != NULL); + CHECK(cepis_format_cep(address->cep, masked) == CEPIS_OK); + CHECK_STR(masked, "01001-000"); + } + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_escaped_and_utf8_strings_decode(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 200, + "{\"cep\":\"01001000\",\"logradouro\":" + "\"Pra\\u00e7a da S\\u00e9\",\"localidade\":" + "\"S\\u00e3o Paulo\",\"bairro\":\"a\\\"b\\\\c\\td\"," + "\"uf\":\"SP\"}", + NULL); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_OK); + if (address != NULL) { + CHECK_STR(address->logradouro, "Pra\xC3\xA7\x61 da S\xC3\xA9"); + CHECK_STR(address->localidade, "S\xC3\xA3o Paulo"); + CHECK_STR(address->bairro, "a\"b\\c\td"); + CHECK_STR(address->complemento, ""); + } + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_reads_the_coordinate_when_present(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01310100", 200, + "{\"cep\":\"01310100\",\"localidade\":\"S\xC3\xA3o Paulo\"," + "\"geo\":{\"lat\":-23.567202,\"lon\":-46.649659," + "\"raio_m\":120,\"precisao\":\"logradouro\"}}", + NULL); + + CHECK(cepis_lookup(fixture.client, "01310100", &address, &err) == + CEPIS_OK); + if (address != NULL && address->geo != NULL) { + CHECK(fabs(address->geo->lat + 23.567202) < 1e-9); + CHECK(fabs(address->geo->lon + 46.649659) < 1e-9); + CHECK(address->geo->raio_m == 120); + CHECK_STR(address->geo->precisao, "logradouro"); + CHECK(cepis_geo_is_precise(address->geo)); + } else { + CHECK(address != NULL && address->geo != NULL); + } + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_municipality_coordinate_is_not_precise(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 200, + "{\"cep\":\"01001000\",\"localidade\":\"Manaus\"," + "\"geo\":{\"lat\":-3.1,\"lon\":-60.0,\"raio_m\":22000," + "\"precisao\":\"localidade\"}}", + NULL); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_OK); + CHECK(address != NULL && address->geo != NULL); + if (address != NULL) { + CHECK(!cepis_geo_is_precise(address->geo)); + if (address->geo != NULL) + CHECK(address->geo->raio_m == 22000); + } + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_malformed_geo_does_not_lose_the_address(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 200, + "{\"cep\":\"01001000\",\"localidade\":\"S\xC3\xA3o Paulo\"," + "\"geo\":{\"lat\":\"not a number\"}}", + NULL); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_OK); + if (address != NULL) { + CHECK(address->geo == NULL); + CHECK_STR(address->localidade, "S\xC3\xA3o Paulo"); + } + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_new_api_field_does_not_break(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 200, + "{\"cep\":\"01001000\",\"localidade\":\"Santos\"," + "\"campo_do_futuro\":\"value\"}", + NULL); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_OK); + if (address != NULL) { + CHECK_STR(address->localidade, "Santos"); + CHECK(address->raw != NULL && + strstr(address->raw, "campo_do_futuro") != NULL); + } + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_not_found_is_not_retried(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/99999999", 404, + "{\"erro\":\"cep_nao_encontrado\",\"mensagem\":" + "\"CEP n\xC3\xA3o existe na base do DNE\"}", + NULL); + + CHECK(cepis_lookup(fixture.client, "99999999", &address, &err) == + CEPIS_ERR_NOT_FOUND); + CHECK(address == NULL); + CHECK(err.http_status == 404); + CHECK(strstr(err.message, "99999999") != NULL); + CHECK(ts_count(fixture.server, "/99999999") == 1); + fixture_down(&fixture); +} + +static void test_invalid_cep_never_leaves_the_machine(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + CHECK(cepis_lookup(fixture.client, "123", &address, &err) == + CEPIS_ERR_INVALID_CEP); + CHECK(address == NULL); + CHECK(ts_total(fixture.server) == 0); + fixture_down(&fixture); +} + +static void test_api_400_becomes_invalid_cep(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/00000000", 400, + "{\"erro\":\"cep_invalido\",\"mensagem\":" + "\"rejected by the API\"}", + NULL); + + CHECK(cepis_lookup(fixture.client, "00000000", &address, &err) == + CEPIS_ERR_INVALID_CEP); + CHECK(strstr(err.message, "rejected by the API") != NULL); + CHECK(ts_count(fixture.server, "/00000000") == 1); + fixture_down(&fixture); +} + +static void test_response_that_is_not_json(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 200, + "<html>captive portal</html>", NULL); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_ERR_INVALID_RESPONSE); + CHECK(address == NULL); + fixture_down(&fixture); +} + +static void test_retries_on_500_then_succeeds(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + ts_reply_t replies[2]; + + fixture_up(&fixture); + replies[0].status = 500; + replies[0].body = "{\"erro\":\"interno\"}"; + replies[0].headers = NULL; + replies[1].status = 200; + replies[1].body = SE_ADDRESS; + replies[1].headers = NULL; + ts_respond_seq(fixture.server, "/01001000", replies, 2); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_OK); + if (address != NULL) + CHECK_STR(address->localidade, "S\xC3\xA3o Paulo"); + CHECK(ts_count(fixture.server, "/01001000") == 2); + cepis_address_free(address); + fixture_down(&fixture); +} + +static void test_exhausts_attempts_then_fails(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 503, "{\"erro\":\"interno\"}", + NULL); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_ERR_SERVER); + CHECK(err.http_status == 503); + CHECK(ts_count(fixture.server, "/01001000") == 3); + fixture_down(&fixture); +} + +static void test_max_attempts_one_disables_retry(void) +{ + fixture_t fixture; + cepis_client_t *client = NULL; + cepis_address_t *address = NULL; + cepis_config_t config; + cepis_error_t err; + + fixture_up(&fixture); + cepis_config_init(&config); + config.base_url = ts_base_url(fixture.server); + config.timeout = 5.0; + config.max_attempts = 1; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK); + ts_respond(fixture.server, "/01001000", 500, "{}", NULL); + + CHECK(cepis_lookup(client, "01001000", &address, &err) == + CEPIS_ERR_SERVER); + CHECK(ts_count(fixture.server, "/01001000") == 1); + cepis_client_free(client); + fixture_down(&fixture); +} + +static void test_429_becomes_rate_limited_with_retry_after(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 429, "{}", "Retry-After: 7"); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_ERR_RATE_LIMITED); + CHECK(fabs(err.retry_after - 7.0) < 1e-9); + CHECK(err.http_status == 429); + fixture_down(&fixture); +} + +static void test_retry_after_http_date_is_understood(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + char header[128]; + time_t target = time(NULL) + 120; + struct tm parts; + + fixture_up(&fixture); + gmtime_r(&target, &parts); + strftime(header, sizeof(header), + "Retry-After: %a, %d %b %Y %H:%M:%S GMT", &parts); + ts_respond(fixture.server, "/01001000", 429, "{}", header); + + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_ERR_RATE_LIMITED); + CHECK(err.retry_after > 100.0 && err.retry_after < 130.0); + fixture_down(&fixture); +} + +static void test_retry_after_honours_the_cap(void) +{ + fixture_t fixture; + cepis_address_t *address = NULL; + cepis_error_t err; + double started; + + fixture_up(&fixture); + ts_respond(fixture.server, "/01001000", 429, "{}", "Retry-After: 3600"); + + started = now_seconds(); + CHECK(cepis_lookup(fixture.client, "01001000", &address, &err) == + CEPIS_ERR_RATE_LIMITED); + CHECK(elapsed_since(started) < 5.0); + fixture_down(&fixture); +} + +static void test_network_error_when_nothing_listens(void) +{ + cepis_client_t *client = NULL; + cepis_address_t *address = NULL; + cepis_config_t config; + cepis_error_t err; + + cepis_config_init(&config); + config.base_url = "http://127.0.0.1:1"; + config.timeout = 1.0; + config.max_attempts = 2; + config.backoff_base = 0.001; + config.backoff_max = 0.01; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK); + + CHECK(cepis_lookup(client, "01001000", &address, &err) == + CEPIS_ERR_NETWORK); + CHECK(address == NULL); + cepis_client_free(client); +} + +static void test_total_timeout_bounds_the_whole_call(void) +{ + fixture_t fixture; + cepis_client_t *client = NULL; + cepis_address_t *address = NULL; + cepis_config_t config; + cepis_error_t err; + double started; + + fixture_up(&fixture); + cepis_config_init(&config); + config.base_url = ts_base_url(fixture.server); + config.timeout = 5.0; + config.max_attempts = 50; + config.backoff_base = 0.05; + config.backoff_max = 0.05; + config.total_timeout = 0.3; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK); + ts_respond(fixture.server, "/01001000", 500, "{}", NULL); + + started = now_seconds(); + CHECK(cepis_lookup(client, "01001000", &address, &err) == + CEPIS_ERR_SERVER); + CHECK(elapsed_since(started) < 3.0); + CHECK(ts_count(fixture.server, "/01001000") < 50); + cepis_client_free(client); + fixture_down(&fixture); +} + +static char *csv_body(const char *const *rows, size_t count) +{ + size_t header_len = sizeof(CSV_HEADER) - 1; + size_t total = header_len; + size_t used; + char *body; + size_t i; + + for (i = 0; i < count; i++) + total += strlen(rows[i]) + 1; + body = malloc(total + 1); + if (body == NULL) + return NULL; + memcpy(body, CSV_HEADER, header_len); + used = header_len; + for (i = 0; i < count; i++) { + size_t len = strlen(rows[i]); + memcpy(body + used, rows[i], len); + used += len; + body[used++] = '\n'; + } + body[used] = '\0'; + return body; +} + +static void test_batch_preserves_order_and_marks_missing(void) +{ + static const char *rows[] = { + "01001000,Pra\xC3\xA7\x61 da S\xC3\xA9,lado \xC3\xADmpar,," + "S\xC3\xA9,S\xC3\xA3o Paulo,SP,S\xC3\xA3o " + "Paulo,Sudeste,3550308," + "1004,11,7107", + "99999999,,,,,,,,,,,,", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," + "Rio de Janeiro,Sudeste,3304557,,21,6001"}; + static const char *ceps[] = {"01001000", "99999999", "20040020"}; + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + char *body; + ts_reply_t reply; + + fixture_up(&fixture); + body = csv_body(rows, 3); + reply.status = 200; + reply.body = body; + reply.headers = NULL; + ts_respond_default(fixture.server, &reply, 1); + + CHECK(cepis_lookup_many(fixture.client, ceps, 3, &list, &err) == + CEPIS_OK); + CHECK(list.count == 3); + if (list.count == 3) { + CHECK(list.items[0] != NULL); + if (list.items[0] != NULL) { + CHECK_STR(list.items[0]->localidade, + "S\xC3\xA3o Paulo"); + CHECK(list.items[0]->geo == NULL); + CHECK(list.items[0]->raw == NULL); + } + CHECK(list.items[1] == NULL); + CHECK(list.items[2] != NULL); + if (list.items[2] != NULL) + CHECK_STR(list.items[2]->uf, "RJ"); + } + CHECK(strstr(ts_path(fixture.server, 0), "/csv?ceps=") == + ts_path(fixture.server, 0)); + cepis_address_list_free(&list); + free(body); + fixture_down(&fixture); +} + +static void test_batch_field_with_comma_is_parsed(void) +{ + static const char *rows[] = { + "01001000,\"Rua Um, Dois\",,,Centro,S\xC3\xA3o Paulo,SP," + "S\xC3\xA3o Paulo,Sudeste,1,,11,1"}; + static const char *ceps[] = {"01001000"}; + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + char *body; + ts_reply_t reply; + + fixture_up(&fixture); + body = csv_body(rows, 1); + reply.status = 200; + reply.body = body; + reply.headers = NULL; + ts_respond_default(fixture.server, &reply, 1); + + CHECK(cepis_lookup_many(fixture.client, ceps, 1, &list, &err) == + CEPIS_OK); + CHECK(list.count == 1 && list.items[0] != NULL); + if (list.count == 1 && list.items[0] != NULL) + CHECK_STR(list.items[0]->logradouro, "Rua Um, Dois"); + cepis_address_list_free(&list); + free(body); + fixture_down(&fixture); +} + +static void test_batch_empty_list_makes_no_request(void) +{ + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + + fixture_up(&fixture); + CHECK(cepis_lookup_many(fixture.client, NULL, 0, &list, &err) == + CEPIS_OK); + CHECK(list.count == 0 && list.items == NULL); + CHECK(ts_total(fixture.server) == 0); + cepis_address_list_free(&list); + fixture_down(&fixture); +} + +static void test_batch_chunks_above_the_api_limit(void) +{ + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + char **ceps = malloc(501 * sizeof(*ceps)); + char **rows = malloc(501 * sizeof(*rows)); + ts_reply_t replies[2]; + char *first; + char *second; + size_t i; + + fixture_up(&fixture); + for (i = 0; i < 501; i++) { + ceps[i] = malloc(16); + rows[i] = malloc(64); + snprintf(ceps[i], 16, "%08zu", i); + snprintf(rows[i], 64, + "%s,,,,,X,SP,S\xC3\xA3o Paulo,Sudeste,1,,11,1", + ceps[i]); + } + first = csv_body((const char *const *)rows, 500); + second = csv_body((const char *const *)rows + 500, 1); + + replies[0].status = 200; + replies[0].body = first; + replies[0].headers = NULL; + replies[1].status = 200; + replies[1].body = second; + replies[1].headers = NULL; + ts_respond_default(fixture.server, replies, 2); + + CHECK(cepis_lookup_many(fixture.client, (const char *const *)ceps, 501, + &list, &err) == CEPIS_OK); + CHECK(ts_total(fixture.server) == 2); + CHECK(list.count == 501); + if (list.count == 501) { + CHECK(list.items[0] != NULL); + CHECK(list.items[499] != NULL); + CHECK(list.items[500] != NULL); + } + + cepis_address_list_free(&list); + for (i = 0; i < 501; i++) { + free(ceps[i]); + free(rows[i]); + } + free(ceps); + free(rows); + free(first); + free(second); + fixture_down(&fixture); +} + +static void test_batch_truncated_response_does_not_shift(void) +{ + static const char *rows[] = { + "01001000,,,,,S\xC3\xA3o Paulo,SP,S\xC3\xA3o " + "Paulo,Sudeste,1,,11,1"}; + static const char *ceps[] = {"01001000", "20040020"}; + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + char *body; + ts_reply_t reply; + + fixture_up(&fixture); + body = csv_body(rows, 1); + reply.status = 200; + reply.body = body; + reply.headers = NULL; + ts_respond_default(fixture.server, &reply, 1); + + CHECK(cepis_lookup_many(fixture.client, ceps, 2, &list, &err) == + CEPIS_OK); + CHECK(list.count == 2); + if (list.count == 2) { + CHECK(list.items[0] != NULL); + CHECK(list.items[1] == NULL); + } + cepis_address_list_free(&list); + free(body); + fixture_down(&fixture); +} + +static void test_batch_malformed_cep_becomes_null(void) +{ + static const char *rows[] = { + "01001000,,,,,S\xC3\xA3o Paulo,SP,S\xC3\xA3o " + "Paulo,Sudeste,1,,11,1", + "123,,,,,,,,,,,,"}; + static const char *ceps[] = {"01001000", "123"}; + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + char *body; + ts_reply_t reply; + + fixture_up(&fixture); + body = csv_body(rows, 2); + reply.status = 200; + reply.body = body; + reply.headers = NULL; + ts_respond_default(fixture.server, &reply, 1); + + CHECK(cepis_lookup_many(fixture.client, ceps, 2, &list, &err) == + CEPIS_OK); + CHECK(list.count == 2); + if (list.count == 2) + CHECK(list.items[1] == NULL); + cepis_address_list_free(&list); + free(body); + fixture_down(&fixture); +} + +static void test_batch_retries_on_503(void) +{ + static const char *rows[] = { + "01001000,,,,,S\xC3\xA3o Paulo,SP,S\xC3\xA3o " + "Paulo,Sudeste,1,,11,1"}; + static const char *ceps[] = {"01001000"}; + fixture_t fixture; + cepis_address_list_t list = {NULL, 0}; + cepis_error_t err; + ts_reply_t replies[2]; + char *body; + + fixture_up(&fixture); + body = csv_body(rows, 1); + replies[0].status = 503; + replies[0].body = "{\"erro\":\"interno\"}"; + replies[0].headers = NULL; + replies[1].status = 200; + replies[1].body = body; + replies[1].headers = NULL; + ts_respond_default(fixture.server, replies, 2); + + CHECK(cepis_lookup_many(fixture.client, ceps, 1, &list, &err) == + CEPIS_OK); + CHECK(ts_total(fixture.server) == 2); + CHECK(list.count == 1 && list.items[0] != NULL); + cepis_address_list_free(&list); + free(body); + fixture_down(&fixture); +} + +static void test_health(void) +{ + fixture_t fixture; + cepis_health_t *health = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/health", 200, + "{\"status\":\"ok\",\"dne\":\"26071\",\"ceps\":1596168," + "\"geo\":894489,\"base_gerada_em\":" + "\"2026-07-24T03:48:28Z\"}", + NULL); + + CHECK(cepis_health(fixture.client, &health, &err) == CEPIS_OK); + if (health != NULL) { + CHECK(cepis_health_ok(health)); + CHECK_STR(health->dne, "26071"); + CHECK(health->ceps == 1596168); + CHECK(health->geo == 894489); + CHECK_STR(health->base_gerada_em, "2026-07-24T03:48:28Z"); + } + cepis_health_free(health); + fixture_down(&fixture); +} + +static void test_health_degraded_does_not_fail(void) +{ + fixture_t fixture; + cepis_health_t *health = NULL; + cepis_error_t err; + + fixture_up(&fixture); + ts_respond(fixture.server, "/health", 503, + "{\"status\":\"degradado\",\"dne\":\"\",\"ceps\":0," + "\"geo\":0}", + NULL); + + CHECK(cepis_health(fixture.client, &health, &err) == CEPIS_OK); + CHECK(health != NULL && !cepis_health_ok(health)); + CHECK(ts_count(fixture.server, "/health") == 1); + cepis_health_free(health); + fixture_down(&fixture); +} + +static void test_trailing_slash_is_not_duplicated(void) +{ + cepis_client_t *client = NULL; + cepis_config_t config; + cepis_error_t err; + + cepis_config_init(&config); + config.base_url = "http://example.invalid///"; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_OK); + CHECK_STR(cepis_client_base_url(client), "http://example.invalid"); + cepis_client_free(client); +} + +static void test_invalid_configuration_is_rejected(void) +{ + cepis_client_t *client = NULL; + cepis_config_t config; + cepis_error_t err; + + cepis_config_init(&config); + config.max_attempts = 0; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_ERR_CONFIG); + + cepis_config_init(&config); + config.timeout = 0.0; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_ERR_CONFIG); + + cepis_config_init(&config); + config.base_url = ""; + CHECK(cepis_client_new(&config, &client, &err) == CEPIS_ERR_CONFIG); + + CHECK(client == NULL); +} + +static void test_user_agent_identifies_the_library(void) +{ + cepis_client_t *client = NULL; + cepis_error_t err; + + CHECK(cepis_client_new(NULL, &client, &err) == CEPIS_OK); + CHECK(strstr(cepis_client_user_agent(client), "cepis-c/") == + cepis_client_user_agent(client)); + CHECK(strstr(cepis_client_user_agent(client), CEPIS_VERSION) != NULL); + CHECK_STR(cepis_client_base_url(client), CEPIS_DEFAULT_BASE_URL); + cepis_client_free(client); +} + +static void test_status_strings_exist(void) +{ + CHECK_STR(cepis_status_str(CEPIS_OK), "ok"); + CHECK_STR(cepis_status_str(CEPIS_ERR_NOT_FOUND), "CEP not found"); + CHECK_STR(cepis_status_str(CEPIS_ERR_RATE_LIMITED), "rate limited"); + CHECK(cepis_status_str(CEPIS_ERR_NOMEM) != NULL); +} + +int main(void) +{ + cepis_global_init(); + + RUN(test_normalize_accepts_masked_and_unmasked); + RUN(test_normalize_rejects_bad_format); + RUN(test_format_cep); + RUN(test_successful_lookup); + RUN(test_escaped_and_utf8_strings_decode); + RUN(test_reads_the_coordinate_when_present); + RUN(test_municipality_coordinate_is_not_precise); + RUN(test_malformed_geo_does_not_lose_the_address); + RUN(test_new_api_field_does_not_break); + RUN(test_not_found_is_not_retried); + RUN(test_invalid_cep_never_leaves_the_machine); + RUN(test_api_400_becomes_invalid_cep); + RUN(test_response_that_is_not_json); + RUN(test_retries_on_500_then_succeeds); + RUN(test_exhausts_attempts_then_fails); + RUN(test_max_attempts_one_disables_retry); + RUN(test_429_becomes_rate_limited_with_retry_after); + RUN(test_retry_after_http_date_is_understood); + RUN(test_retry_after_honours_the_cap); + RUN(test_network_error_when_nothing_listens); + RUN(test_total_timeout_bounds_the_whole_call); + RUN(test_batch_preserves_order_and_marks_missing); + RUN(test_batch_field_with_comma_is_parsed); + RUN(test_batch_empty_list_makes_no_request); + RUN(test_batch_chunks_above_the_api_limit); + RUN(test_batch_truncated_response_does_not_shift); + RUN(test_batch_malformed_cep_becomes_null); + RUN(test_batch_retries_on_503); + RUN(test_health); + RUN(test_health_degraded_does_not_fail); + RUN(test_trailing_slash_is_not_duplicated); + RUN(test_invalid_configuration_is_rejected); + RUN(test_user_agent_identifies_the_library); + RUN(test_status_strings_exist); + + cepis_global_cleanup(); + + printf("\n%d test(s), %d failed\n", tests_run, tests_failed); + return tests_failed == 0 ? 0 : 1; +} diff --git a/tests/test_server.c b/tests/test_server.c new file mode 100644 index 0000000..83cfb5a --- /dev/null +++ b/tests/test_server.c @@ -0,0 +1,417 @@ +#include "test_server.h" + +#include <arpa/inet.h> +#include <errno.h> +#include <netinet/in.h> +#include <netinet/tcp.h> +#include <poll.h> +#include <pthread.h> +#include <stdbool.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <sys/socket.h> +#include <unistd.h> + +#define TS_MAX_ROUTES 16 +#define TS_MAX_REPLIES 8 +#define TS_MAX_CALLS 2048 +#define TS_REQUEST_MAX 65536 + +typedef struct { + long status; + char *body; + char *headers; +} ts_stored_t; + +typedef struct { + char *path; + ts_stored_t replies[TS_MAX_REPLIES]; + size_t count; + size_t served; +} ts_route_t; + +struct test_server { + int listen_fd; + int port; + char base_url[64]; + pthread_t thread; + pthread_mutex_t lock; + bool stopping; + ts_route_t routes[TS_MAX_ROUTES]; + size_t route_count; + ts_route_t fallback; + char *calls[TS_MAX_CALLS]; + size_t call_count; +}; + +static char *dup_or_empty(const char *text) +{ + size_t len = (text != NULL) ? strlen(text) : 0; + char *copy = malloc(len + 1); + + if (copy == NULL) + return NULL; + if (len > 0) + memcpy(copy, text, len); + copy[len] = '\0'; + return copy; +} + +static void route_clear(ts_route_t *route) +{ + size_t i; + + for (i = 0; i < route->count; i++) { + free(route->replies[i].body); + free(route->replies[i].headers); + } + route->count = 0; + route->served = 0; +} + +static void route_fill(ts_route_t *route, const ts_reply_t *replies, + size_t count) +{ + size_t i; + + route_clear(route); + if (count > TS_MAX_REPLIES) + count = TS_MAX_REPLIES; + for (i = 0; i < count; i++) { + route->replies[i].status = replies[i].status; + route->replies[i].body = dup_or_empty(replies[i].body); + route->replies[i].headers = dup_or_empty(replies[i].headers); + } + route->count = count; +} + +static ts_route_t *route_for(test_server_t *server, const char *path, + bool create) +{ + size_t i; + + for (i = 0; i < server->route_count; i++) + if (strcmp(server->routes[i].path, path) == 0) + return &server->routes[i]; + if (!create || server->route_count >= TS_MAX_ROUTES) + return NULL; + server->routes[server->route_count].path = dup_or_empty(path); + return &server->routes[server->route_count++]; +} + +static const char *reason_phrase(long 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"; + } +} + +static bool read_request_path(int fd, char *out, size_t out_size) +{ + char buffer[TS_REQUEST_MAX]; + size_t used = 0; + const char *start; + const char *space; + size_t len; + + for (;;) { + ssize_t got; + struct pollfd pfd; + + pfd.fd = fd; + pfd.events = POLLIN; + pfd.revents = 0; + if (poll(&pfd, 1, 5000) <= 0) + return false; + got = read(fd, buffer + used, sizeof(buffer) - used - 1); + if (got <= 0) + return false; + used += (size_t)got; + buffer[used] = '\0'; + if (strstr(buffer, "\r\n\r\n") != NULL) + break; + if (used + 1 >= sizeof(buffer)) + return false; + } + + start = strchr(buffer, ' '); + if (start == NULL) + return false; + start++; + space = strchr(start, ' '); + if (space == NULL) + return false; + len = (size_t)(space - start); + if (len + 1 > out_size) + return false; + memcpy(out, start, len); + out[len] = '\0'; + return true; +} + +static void write_all(int fd, const char *data, size_t len) +{ + size_t sent = 0; + + while (sent < len) { + ssize_t wrote = write(fd, data + sent, len - sent); + if (wrote <= 0) { + if (wrote < 0 && errno == EINTR) + continue; + return; + } + sent += (size_t)wrote; + } +} + +static void serve(test_server_t *server, int fd) +{ + char path[TS_REQUEST_MAX]; + char head[1024]; + ts_stored_t reply; + ts_route_t *route; + size_t index; + int head_len; + + if (!read_request_path(fd, path, sizeof(path))) + return; + + pthread_mutex_lock(&server->lock); + if (server->call_count < TS_MAX_CALLS) + server->calls[server->call_count++] = dup_or_empty(path); + + route = route_for(server, path, false); + if (route == NULL || route->count == 0) + route = &server->fallback; + + if (route->count == 0) { + head_len = snprintf(head, sizeof(head), + "HTTP/1.1 404 Not Found\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n"); + pthread_mutex_unlock(&server->lock); + write_all(fd, head, (size_t)head_len); + return; + } + + index = route->served; + if (index >= route->count) + index = route->count - 1; + route->served++; + + reply.status = route->replies[index].status; + reply.body = dup_or_empty(route->replies[index].body); + reply.headers = dup_or_empty(route->replies[index].headers); + pthread_mutex_unlock(&server->lock); + + head_len = snprintf(head, sizeof(head), + "HTTP/1.1 %ld %s\r\n" + "Content-Type: application/octet-stream\r\n" + "Content-Length: %zu\r\n" + "Connection: close\r\n" + "%s%s" + "\r\n", + reply.status, reason_phrase(reply.status), + reply.body != NULL ? strlen(reply.body) : 0, + (reply.headers != NULL && reply.headers[0] != '\0') + ? reply.headers + : "", + (reply.headers != NULL && reply.headers[0] != '\0') + ? "\r\n" + : ""); + if (head_len > 0) + write_all(fd, head, (size_t)head_len); + if (reply.body != NULL) + write_all(fd, reply.body, strlen(reply.body)); + free(reply.body); + free(reply.headers); +} + +static void *accept_loop(void *argument) +{ + test_server_t *server = argument; + + for (;;) { + struct pollfd pfd; + int fd; + + pthread_mutex_lock(&server->lock); + if (server->stopping) { + pthread_mutex_unlock(&server->lock); + return NULL; + } + pthread_mutex_unlock(&server->lock); + + pfd.fd = server->listen_fd; + pfd.events = POLLIN; + pfd.revents = 0; + if (poll(&pfd, 1, 20) <= 0) + continue; + + fd = accept(server->listen_fd, NULL, NULL); + if (fd < 0) + continue; + serve(server, fd); + shutdown(fd, SHUT_WR); + close(fd); + } +} + +test_server_t *ts_start(void) +{ + struct sockaddr_in address; + socklen_t length = sizeof(address); + test_server_t *server = calloc(1, sizeof(*server)); + int reuse = 1; + + if (server == NULL) + return NULL; + pthread_mutex_init(&server->lock, NULL); + + server->listen_fd = socket(AF_INET, SOCK_STREAM, 0); + if (server->listen_fd < 0) { + free(server); + return NULL; + } + setsockopt(server->listen_fd, SOL_SOCKET, SO_REUSEADDR, &reuse, + sizeof(reuse)); + + memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + + if (bind(server->listen_fd, (struct sockaddr *)&address, + sizeof(address)) != 0 || + listen(server->listen_fd, 32) != 0 || + getsockname(server->listen_fd, (struct sockaddr *)&address, + &length) != 0) { + close(server->listen_fd); + free(server); + return NULL; + } + + server->port = ntohs(address.sin_port); + snprintf(server->base_url, sizeof(server->base_url), + "http://127.0.0.1:%d", server->port); + + if (pthread_create(&server->thread, NULL, accept_loop, server) != 0) { + close(server->listen_fd); + free(server); + return NULL; + } + return server; +} + +void ts_stop(test_server_t *server) +{ + size_t i; + + if (server == NULL) + return; + pthread_mutex_lock(&server->lock); + server->stopping = true; + pthread_mutex_unlock(&server->lock); + pthread_join(server->thread, NULL); + close(server->listen_fd); + + for (i = 0; i < server->route_count; i++) { + route_clear(&server->routes[i]); + free(server->routes[i].path); + } + route_clear(&server->fallback); + for (i = 0; i < server->call_count; i++) + free(server->calls[i]); + pthread_mutex_destroy(&server->lock); + free(server); +} + +const char *ts_base_url(const test_server_t *server) +{ + return server->base_url; +} + +void ts_respond(test_server_t *server, const char *path, long status, + const char *body, const char *headers) +{ + ts_reply_t reply; + + reply.status = status; + reply.body = body; + reply.headers = headers; + ts_respond_seq(server, path, &reply, 1); +} + +void ts_respond_seq(test_server_t *server, const char *path, + const ts_reply_t *replies, size_t count) +{ + ts_route_t *route; + + pthread_mutex_lock(&server->lock); + route = route_for(server, path, true); + if (route != NULL) + route_fill(route, replies, count); + pthread_mutex_unlock(&server->lock); +} + +void ts_respond_default(test_server_t *server, const ts_reply_t *replies, + size_t count) +{ + pthread_mutex_lock(&server->lock); + route_fill(&server->fallback, replies, count); + pthread_mutex_unlock(&server->lock); +} + +size_t ts_count(test_server_t *server, const char *prefix) +{ + size_t total = 0; + size_t len = strlen(prefix); + size_t i; + + pthread_mutex_lock(&server->lock); + for (i = 0; i < server->call_count; i++) + if (server->calls[i] != NULL && + strncmp(server->calls[i], prefix, len) == 0) + total++; + pthread_mutex_unlock(&server->lock); + return total; +} + +size_t ts_total(test_server_t *server) +{ + size_t total; + + pthread_mutex_lock(&server->lock); + total = server->call_count; + pthread_mutex_unlock(&server->lock); + return total; +} + +const char *ts_path(test_server_t *server, size_t index) +{ + const char *path = NULL; + + pthread_mutex_lock(&server->lock); + if (index < server->call_count) + path = server->calls[index]; + pthread_mutex_unlock(&server->lock); + return path; +} diff --git a/tests/test_server.h b/tests/test_server.h new file mode 100644 index 0000000..cbcd4e0 --- /dev/null +++ b/tests/test_server.h @@ -0,0 +1,69 @@ +/** + * @file test_server.h + * @brief A throwaway HTTP/1.1 server on a random loopback port. + * + * The suite points the client at one of these through `base_url`. Nothing is + * mocked: the code path under test is the one that runs in production, libcurl + * and response-body reading included. It doubles as the test that `base_url` + * works, since without it none of the suite would run. + */ +#ifndef CEPIS_TEST_SERVER_H +#define CEPIS_TEST_SERVER_H + +#include <stddef.h> + +typedef struct test_server test_server_t; + +/** One canned reply. */ +typedef struct { + long status; + /** Body, copied. `NULL` means empty. */ + const char *body; + /** Extra header lines without the trailing CRLF, or `NULL`. */ + const char *headers; +} ts_reply_t; + +/** @brief Start a server. @return `NULL` if it could not bind or spawn. */ +test_server_t *ts_start(void); + +/** @brief Stop and release a server. `NULL` is ignored. */ +void ts_stop(test_server_t *server); + +/** @brief `http://127.0.0.1:PORT`, owned by the server. */ +const char *ts_base_url(const test_server_t *server); + +/** @brief Register one reply for @p path, replacing any previous route. */ +void ts_respond(test_server_t *server, const char *path, long status, + const char *body, const char *headers); + +/** + * @brief Register a sequence of replies for @p path. + * + * Each request consumes the next entry; the last one repeats once the sequence + * runs out. This is how retrying is tested. + */ +void ts_respond_seq(test_server_t *server, const char *path, + const ts_reply_t *replies, size_t count); + +/** + * @brief Register the sequence used for any path with no explicit route. + * + * Used by the chunking test, where the request paths are thousands of + * characters long and matching them exactly buys nothing. + */ +void ts_respond_default(test_server_t *server, const ts_reply_t *replies, + size_t count); + +/** @brief How many requests so far had a path starting with @p prefix. */ +size_t ts_count(test_server_t *server, const char *prefix); + +/** @brief How many requests the server has served in total. */ +size_t ts_total(test_server_t *server); + +/** + * @brief The path of request @p index, or `NULL` when there is no such + * request. Owned by the server. + */ +const char *ts_path(test_server_t *server, size_t index); + +#endif |