diff options
Diffstat (limited to 'test')
| -rw-r--r-- | test/cepis_test.d | 637 | ||||
| -rw-r--r-- | test/runner.d | 12 |
2 files changed, 649 insertions, 0 deletions
diff --git a/test/cepis_test.d b/test/cepis_test.d new file mode 100644 index 0000000..3ced878 --- /dev/null +++ b/test/cepis_test.d @@ -0,0 +1,637 @@ +/** + * Integration tests. + * + * These bring up a real HTTP server on loopback and point the client at it via + * $(D ClientConfig.baseUrl). No test touches the network and no test fakes the + * HTTP layer: the code path exercised is the one that runs in production, + * request construction and error body reading included. + * + * It doubles as the test that $(D baseUrl) works — if it did not, none of this + * would run. + * + * The fixture field names stay Portuguese because they mirror the API's JSON. + */ +module cepis_test; + +import core.sync.mutex : Mutex; +import core.thread : Thread; +import core.time : Duration, MonoTime, dur; +import std.algorithm : filter, map, startsWith; +import std.array : array, join; +import std.conv : to; +import std.exception : assertThrown; +import std.format : format; +import std.socket : InternetAddress, Socket, SocketOption, SocketOptionLevel, + SocketShutdown, TcpSocket; +import std.string : indexOf, split, toLower; +import std.typecons : Nullable, nullable; + +import cepis; + +private enum string SE_ADDRESS = `{ + "cep": "01001000", "logradouro": "Praça da Sé", "complemento": "lado ímpar", + "unidade": "", "bairro": "Sé", "localidade": "São Paulo", "uf": "SP", + "estado": "São Paulo", "regiao": "Sudeste", "ibge": "3550308", "gia": "1004", + "ddd": "11", "siafi": "7107" +}`; + +private enum string CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf," + ~ "estado,regiao,ibge,gia,ddd,siafi"; + +private enum string SE_ROW = "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"; + +private enum string RJ_ROW = "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ," + ~ "Rio de Janeiro,Sudeste,3304557,,21,6001"; + +private enum string MISSING_ROW = "99999999,,,,,,,,,,,,"; + +/// One canned response: status, body and extra headers. +private struct Reply +{ + ushort status; + string content; + string[2][] headers; +} + +/** + * A throwaway HTTP server on a random loopback port. + * + * Routes are matched by longest prefix, so `/csv` catches `/csv?ceps=...`. A + * route may hold several replies: each request consumes the next one, which is + * how retrying is tested, and the last one repeats once the list runs out. + * + * The accept loop runs on a daemon thread that ends with the test process; + * nothing here outlives `dub test`. + */ +private final class TestServer +{ + private Socket listener; + private Mutex lock; + private Reply[][string] routes; + private string[] calls; + private string[][] headers; + private ushort port; + + this() + { + lock = new Mutex(); + listener = new TcpSocket(); + listener.setOption(SocketOptionLevel.SOCKET, SocketOption.REUSEADDR, true); + listener.bind(new InternetAddress("127.0.0.1", 0)); + listener.listen(32); + port = listener.localAddress.toPortString.to!ushort; + + auto worker = new Thread(&run); + worker.isDaemon = true; + worker.start(); + } + + string url() const + { + return "http://127.0.0.1:" ~ port.to!string; + } + + Client client(uint maxAttempts = 3) + { + ClientConfig config; + config.baseUrl = url; + config.timeout = dur!"seconds"(5); + config.backoffBase = dur!"msecs"(1); + config.backoffMax = dur!"msecs"(10); + config.maxAttempts = maxAttempts; + return Client(config); + } + + void respond(string path, Reply[] replies...) + { + synchronized (lock) + routes[path] = replies.dup; + } + + size_t calledTimes() + { + synchronized (lock) + return calls.length; + } + + size_t calledTimes(string prefix) + { + synchronized (lock) + return calls.filter!(c => c.startsWith(prefix)).array.length; + } + + string[] lastHeaders() + { + synchronized (lock) + return headers.length ? headers[$ - 1].dup : null; + } + + private void run() + { + while (true) + { + Socket peer; + try + peer = listener.accept(); + catch (Exception) + return; + + try + serve(peer); + catch (Exception) + { + } + + try + { + peer.shutdown(SocketShutdown.BOTH); + peer.close(); + } + catch (Exception) + { + } + } + } + + private void serve(Socket peer) + { + char[] request; + ubyte[4096] buffer; + while (request.indexOf("\r\n\r\n") < 0) + { + const received = peer.receive(buffer[]); + if (received <= 0) + break; + request ~= cast(char[]) buffer[0 .. received]; + } + + auto lines = request.idup.split("\r\n"); + if (lines.length == 0) + return; + + auto fields = lines[0].split(" "); + const path = fields.length > 1 ? fields[1] : "/"; + + Reply reply; + synchronized (lock) + { + calls ~= path; + headers ~= lines.length > 1 ? lines[1 .. $].dup : null; + + string best; + bool matched; + foreach (route; routes.byKey) + if (path.startsWith(route) && (!matched || route.length > best.length)) + { + best = route; + matched = true; + } + + if (!matched) + reply = Reply(404, `{"erro":"cep_nao_encontrado"}`); + else + { + auto replies = best in routes; + reply = (*replies)[0]; + if (replies.length > 1) + *replies = (*replies)[1 .. $]; + } + } + + auto head = format!"HTTP/1.1 %d Status\r\n"(reply.status); + foreach (header; reply.headers) + head ~= header[0] ~ ": " ~ header[1] ~ "\r\n"; + head ~= format!"Content-Length: %d\r\nConnection: close\r\n\r\n"(reply.content.length); + + sendAll(peer, head); + sendAll(peer, reply.content); + } + + private static void sendAll(Socket peer, const(void)[] data) + { + while (data.length > 0) + { + const sent = peer.send(data); + if (sent <= 0) + return; + data = data[sent .. $]; + } + } +} + +private string csvBody(string[] rows...) +{ + return CSV_HEADER ~ "\n" ~ rows.join("\n") ~ "\n"; +} + +private string withGeo(string precisao, long raio_m) +{ + enum string template_ = `{"cep":"01310100","logradouro":"Avenida Paulista","complemento":"",` + ~ `"unidade":"","bairro":"Bela Vista","localidade":"São Paulo","uf":"SP",` + ~ `"estado":"São Paulo","regiao":"Sudeste","ibge":"3550308","gia":"1004",` + ~ `"ddd":"11","siafi":"7107","geo":{"lat":-23.5,"lon":-46.6,"raio_m":%d,` + ~ `"precisao":"%s"}}`; + + return format!template_(raio_m, precisao); +} + +@("a successful lookup reads every field") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(200, SE_ADDRESS)); + + const address = server.client.lookup("01001-000"); + + assert(address.localidade == "São Paulo"); + assert(address.uf == "SP"); + assert(address.logradouro == "Praça da Sé"); + assert(address.complemento == "lado ímpar"); + assert(address.ibge == "3550308"); + assert(address.formattedCep == "01001-000"); + assert(address.geo.isNull); +} + +@("the request carries the user agent and an accept header") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(200, SE_ADDRESS)); + server.client.lookup("01001000"); + + const sent = server.lastHeaders.map!(h => h.toLower).array; + + assert(sent.filter!(h => h.startsWith("user-agent: cepis-d/")).array.length == 1); + assert(sent.filter!(h => h.startsWith("accept: application/json")).array.length == 1); +} + +@("the coordinate is read when present") +unittest +{ + auto server = new TestServer(); + server.respond("/01310100", Reply(200, withGeo("logradouro", 120))); + + const geo = server.client.lookup("01310100").geo.get; + + assert(geo.lat > -23.6 && geo.lat < -23.4); + assert(geo.lon > -46.7 && geo.lon < -46.5); + assert(geo.raio_m == 120); + assert(geo.isPrecise); +} + +@("a municipality coordinate is flagged as imprecise") +unittest +{ + auto server = new TestServer(); + server.respond("/01310100", Reply(200, withGeo("localidade", 22_000))); + + const geo = server.client.lookup("01310100").geo.get; + + assert(!geo.isPrecise); + assert(geo.raio_m == 22_000); +} + +@("a malformed geo block does not lose the address") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(200, + `{"cep":"01001000","localidade":"São Paulo","uf":"SP","geo":{"lat":"nowhere"}}`)); + + const address = server.client.lookup("01001000"); + + assert(address.geo.isNull); + assert(address.localidade == "São Paulo"); +} + +@("a new api field does not break the client") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(200, + `{"cep":"01001000","localidade":"São Paulo","campo_do_futuro":"value"}`)); + + const address = server.client.lookup("01001000"); + + assert(address.raw["campo_do_futuro"].str == "value"); + assert(address.localidade == "São Paulo"); +} + +@("a missing cep is not found, and is not retried") +unittest +{ + auto server = new TestServer(); + server.respond("/99999999", Reply(404, + `{"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}`)); + + auto client = server.client; + bool caught; + try + client.lookup("99999999"); + catch (CepNotFoundException error) + { + caught = true; + assert(error.cep == "99999999"); + assert(!error.retriable); + } + + assert(caught); + assert(server.calledTimes("/99999999") == 1); +} + +@("an invalid cep never leaves the machine") +unittest +{ + auto server = new TestServer(); + + assertThrown!InvalidCepException(server.client.lookup("123")); + assert(server.calledTimes == 0); +} + +@("a 400 from the api becomes an invalid cep carrying its message") +unittest +{ + auto server = new TestServer(); + server.respond("/00000000", Reply(400, + `{"erro":"cep_invalido","mensagem":"rejected by the API"}`)); + + auto client = server.client; + bool caught; + try + client.lookup("00000000"); + catch (InvalidCepException error) + { + caught = true; + assert(error.msg == "rejected by the API"); + } + + assert(caught); + assert(server.calledTimes("/00000000") == 1); +} + +@("a response that is not json is diagnosed") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(200, "<html>captive portal</html>")); + + assertThrown!InvalidResponseException(server.client.lookup("01001000")); +} + +@("a 500 is retried and then succeeds") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(500, `{"erro":"interno"}`), Reply(200, SE_ADDRESS)); + + const address = server.client.lookup("01001000"); + + assert(address.localidade == "São Paulo"); + assert(server.calledTimes("/01001000") == 2); +} + +@("the attempts run out and the server error surfaces") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(503, `{"erro":"interno"}`)); + + auto client = server.client; + bool caught; + try + client.lookup("01001000"); + catch (ServerException error) + { + caught = true; + assert(error.status == 503); + assert(error.retriable); + } + + assert(caught); + assert(server.calledTimes("/01001000") == 3); +} + +@("one attempt disables retrying") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(500, "{}")); + + assertThrown!ServerException(server.client(1).lookup("01001000")); + assert(server.calledTimes("/01001000") == 1); +} + +@("rate limiting exposes retry-after") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(429, "{}", [["Retry-After", "7"]])); + + auto client = server.client; + bool caught; + try + client.lookup("01001000"); + catch (RateLimitedException error) + { + caught = true; + assert(error.retryAfter.get == dur!"seconds"(7)); + assert(error.retriable); + } + + assert(caught); +} + +@("a wild retry-after is capped") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(429, "{}", [["Retry-After", "3600"]])); + + const started = MonoTime.currTime; + assertThrown!RateLimitedException(server.client.lookup("01001000")); + + assert(MonoTime.currTime - started < dur!"seconds"(5)); +} + +@("retry-after as an http date is understood and still capped") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(429, "{}", [ + ["Retry-After", "Wed, 21 Oct 2099 07:28:00 GMT"] + ])); + + const started = MonoTime.currTime; + auto client = server.client; + bool caught; + try + client.lookup("01001000"); + catch (RateLimitedException error) + { + caught = true; + assert(error.retryAfter.get > dur!"days"(1)); + } + + assert(caught); + assert(MonoTime.currTime - started < dur!"seconds"(5)); +} + +@("nothing listening is a network error") +unittest +{ + ClientConfig config; + config.baseUrl = "http://127.0.0.1:1"; + config.maxAttempts = 2; + config.timeout = dur!"seconds"(1); + config.backoffBase = dur!"msecs"(1); + config.backoffMax = dur!"msecs"(10); + + auto client = Client(config); + bool caught; + try + client.lookup("01001000"); + catch (NetworkException error) + { + caught = true; + assert(error.retriable); + } + + assert(caught); +} + +@("the total timeout bounds the whole call") +unittest +{ + auto server = new TestServer(); + server.respond("/01001000", Reply(500, "{}")); + + ClientConfig config; + config.baseUrl = server.url; + config.timeout = dur!"seconds"(5); + config.maxAttempts = 50; + config.backoffBase = dur!"msecs"(50); + config.backoffMax = dur!"msecs"(50); + config.totalTimeout = dur!"msecs"(300); + + const started = MonoTime.currTime; + assertThrown!CepisException(Client(config).lookup("01001000")); + + assert(MonoTime.currTime - started < dur!"seconds"(3)); + assert(server.calledTimes("/01001000") < 50); +} + +@("a batch keeps order and marks the missing entry") +unittest +{ + auto server = new TestServer(); + server.respond("/csv", Reply(200, csvBody(SE_ROW, MISSING_ROW, RJ_ROW))); + + auto result = server.client.lookupMany(["01001000", "99999999", "20040020"]); + + assert(result.length == 3); + assert(result[0].get.localidade == "São Paulo"); + assert(result[1].isNull); + assert(result[2].get.uf == "RJ"); + assert(result[2].get.logradouro == "Avenida Rio Branco"); +} + +@("a batch carries no coordinate") +unittest +{ + auto server = new TestServer(); + server.respond("/csv", Reply(200, csvBody( + "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"))); + + auto result = server.client.lookupMany(["01001000"]); + + assert(result[0].get.geo.isNull); +} + +@("a quoted field containing a comma is parsed") +unittest +{ + auto server = new TestServer(); + server.respond("/csv", Reply(200, + csvBody(`01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1`))); + + auto result = server.client.lookupMany(["01001000"]); + + assert(result[0].get.logradouro == "Rua Um, Dois"); +} + +@("an empty batch makes no request") +unittest +{ + auto server = new TestServer(); + + assert(server.client.lookupMany([]).length == 0); + assert(server.calledTimes == 0); +} + +@("a batch above the api limit is chunked") +unittest +{ + auto server = new TestServer(); + auto ceps = new string[501]; + foreach (index; 0 .. ceps.length) + ceps[index] = format!"%08d"(index); + + string row(string cep) + { + return cep ~ ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"; + } + + server.respond("/csv", Reply(200, csvBody(ceps[0 .. 500].map!row.array)), + Reply(200, csvBody(ceps[500 .. $].map!row.array))); + + auto result = server.client.lookupMany(ceps); + + assert(result.length == 501); + assert(server.calledTimes("/csv") == 2); + foreach (address; result) + assert(!address.isNull); +} + +@("a truncated batch pads instead of shifting") +unittest +{ + auto server = new TestServer(); + server.respond("/csv", Reply(200, + csvBody("01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"))); + + auto result = server.client.lookupMany(["01001000", "20040020"]); + + assert(result.length == 2); + assert(!result[0].isNull); + assert(result[1].isNull); +} + +@("health reports the live database") +unittest +{ + auto server = new TestServer(); + server.respond("/health", Reply(200, + `{"status":"ok","dne":"26071","ceps":1596168,"geo":894489,` + ~ `"base_gerada_em":"2026-07-24T03:48:28Z"}`)); + + const health = server.client.health(); + + assert(health.ok); + assert(health.dne == "26071"); + assert(health.ceps == 1_596_168); + assert(health.geo == 894_489); + assert(health.base_gerada_em == "2026-07-24T03:48:28Z"); +} + +@("a degraded api is not an error") +unittest +{ + auto server = new TestServer(); + server.respond("/health", Reply(503, `{"status":"degradado","dne":"","ceps":0,"geo":0}`)); + + const health = server.client.health(); + + assert(!health.ok); + assert(health.status == "degradado"); + assert(server.calledTimes("/health") == 1); +} diff --git a/test/runner.d b/test/runner.d new file mode 100644 index 0000000..b3c7a55 --- /dev/null +++ b/test/runner.d @@ -0,0 +1,12 @@ +/** + * Entry point for the `unittest` build configuration. + * + * The runtime runs every `unittest` block before deciding whether to call this, + * and reports the outcome itself, so there is nothing to do here — the module + * exists only because an executable has to have a `main`. + */ +module runner; + +void main() +{ +} |