summaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_cepis.c935
-rw-r--r--tests/test_server.c417
-rw-r--r--tests/test_server.h69
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