diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:08 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:42:08 -0300 |
| commit | 7fd555fcaf4304cb0dff3e8f5661565c3618299e (patch) | |
| tree | 61199793c3046d73b97e9822895ede2a33034d41 /test/run.lua | |
| download | lua-7fd555fcaf4304cb0dff3e8f5661565c3618299e.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'test/run.lua')
| -rw-r--r-- | test/run.lua | 506 |
1 files changed, 506 insertions, 0 deletions
diff --git a/test/run.lua b/test/run.lua new file mode 100644 index 0000000..6220615 --- /dev/null +++ b/test/run.lua @@ -0,0 +1,506 @@ +--- Test suite. +-- +-- Every test drives a real HTTP server over a real socket, pointed at through +-- `base_url`. No test touches the network and none stubs LuaSocket: the code +-- path exercised is the one that runs in production, request construction and +-- error body reading included. +-- +-- It doubles as the test that `base_url` works — if it did not, none of this +-- would run. +-- +-- The fixture field names stay Portuguese because they mirror the API's JSON. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local json = require("dkjson") +local harness = require("test.harness") +local cepis = require("cepis") +local csv = require("cepis.csv") + +local test = harness.test +local equal = harness.equal +local ok = harness.ok +local near = harness.near + +local 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", +} + +local CSV_HEADER = + "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi" + +local function address_json(extra) + local body = {} + for key, value in pairs(SE_ADDRESS) do + body[key] = value + end + for key, value in pairs(extra or {}) do + body[key] = value + end + return json.encode(body) +end + +local function csv_body(rows) + return CSV_HEADER .. "\n" .. table.concat(rows, "\n") .. "\n" +end + +local function once(status, body, headers) + return { responses = { { status = status, body = body, headers = headers } } } +end + +local server = harness.start_server() + +local function client(opts) + opts = opts or {} + opts.base_url = opts.base_url or server.url + if opts.timeout == nil then + opts.timeout = 5 + end + if opts.backoff_base == nil then + opts.backoff_base = 0.001 + end + if opts.backoff_max == nil then + opts.backoff_max = 0.01 + end + return cepis.new(opts) +end + +test("normalize_cep accepts the mask and stray separators", function() + for _, value in ipairs({ "01001000", "01001-000", " 01001-000 ", "01001.000" }) do + equal(cepis.normalize_cep(value), "01001000", value) + end +end) + +test("normalize_cep rejects anything that is not 8 digits", function() + for _, value in ipairs({ "", "123", "0100100a", "010010000", "abcdefgh" }) do + local got, err = cepis.normalize_cep(value) + harness.error_kind(got, err, cepis.INVALID_CEP) + end +end) + +test("normalize_cep rejects a non-string", function() + local got, err = cepis.normalize_cep(1001000) + harness.error_kind(got, err, cepis.INVALID_CEP) +end) + +test("a successful lookup reads the address", function() + server:program({ ["/01001000"] = once(200, address_json()) }) + local address, err = client():lookup("01001-000") + ok(address, tostring(err)) + equal(address.localidade, "São Paulo") + equal(address.uf, "SP") + equal(address.logradouro, "Praça da Sé") + equal(address.cep, "01001000") + equal(address:formatted_cep(), "01001-000") + harness.is_nil(address.geo) +end) + +test("a coordinate is read when present", function() + server:program({ + ["/01310100"] = once( + 200, + address_json({ + geo = { lat = -23.5, lon = -46.6, raio_m = 120, precisao = "logradouro" }, + }) + ), + }) + local address, err = client():lookup("01310100") + ok(address, tostring(err)) + ok(address.geo) + near(address.geo.lat, -23.5) + near(address.geo.lon, -46.6) + equal(address.geo.raio_m, 120) + equal(address.geo:is_precise(), true) +end) + +test("a municipality coordinate is not precise", function() + server:program({ + ["/01001000"] = once( + 200, + address_json({ + geo = { lat = -23.5, lon = -46.6, raio_m = 22000, precisao = "localidade" }, + }) + ), + }) + local address = client():lookup("01001000") + ok(address.geo) + equal(address.geo:is_precise(), false) + equal(address.geo.raio_m, 22000) +end) + +test("a malformed geo block does not lose the address", function() + server:program({ + ["/01001000"] = once(200, address_json({ geo = { lat = "not a number" } })), + }) + local address = client():lookup("01001000") + harness.is_nil(address.geo) + equal(address.localidade, "São Paulo") +end) + +test("a field the API grows is kept in raw", function() + server:program({ + ["/01001000"] = once(200, address_json({ campo_do_futuro = "value" })), + }) + local address = client():lookup("01001000") + equal(address.raw.campo_do_futuro, "value") + equal(address.localidade, "São Paulo") +end) + +test("a missing CEP is not_found", function() + server:program({ + ["/99999999"] = once(404, '{"erro":"cep_nao_encontrado","mensagem":"gone"}'), + }) + local address, err = client():lookup("99999999") + local failure = harness.error_kind(address, err, cepis.NOT_FOUND) + equal(failure.cep, "99999999") + equal(failure:is_retriable(), false) + ok(cepis.is_error(failure)) +end) + +test("404 is not retried", function() + server:program({ ["/99999999"] = once(404, '{"erro":"cep_nao_encontrado"}') }) + local address, err = client():lookup("99999999") + harness.error_kind(address, err, cepis.NOT_FOUND) + equal(server:count("/99999999"), 1) +end) + +test("an invalid CEP never leaves the machine", function() + server:program({}) + local address, err = client():lookup("123") + harness.error_kind(address, err, cepis.INVALID_CEP) + equal(#server:calls(), 0) +end) + +test("a 400 from the API becomes invalid_cep with its message", function() + server:program({ + ["/00000000"] = once(400, '{"erro":"cep_invalido","mensagem":"rejected by the API"}'), + }) + local address, err = client():lookup("00000000") + local failure = harness.error_kind(address, err, cepis.INVALID_CEP) + equal(failure.message, "rejected by the API") + equal(server:count("/00000000"), 1) +end) + +test("a body that is not JSON is invalid_response", function() + server:program({ ["/01001000"] = once(200, "<html>captive portal</html>") }) + local address, err = client():lookup("01001000") + local failure = harness.error_kind(address, err, cepis.INVALID_RESPONSE) + equal(failure:is_retriable(), false) +end) + +test("a JSON array is invalid_response, not a blank address", function() + server:program({ ["/01001000"] = once(200, "[1,2,3]") }) + local address, err = client():lookup("01001000") + harness.error_kind(address, err, cepis.INVALID_RESPONSE) +end) + +test("a 500 is retried and the next attempt succeeds", function() + server:program({ + ["/01001000"] = { + responses = { + { status = 500, body = '{"erro":"interno"}' }, + { status = 200, body = address_json() }, + }, + }, + }) + local address, err = client():lookup("01001000") + ok(address, tostring(err)) + equal(address.localidade, "São Paulo") + equal(server:count("/01001000"), 2) +end) + +test("attempts are exhausted and then it fails", function() + server:program({ ["/01001000"] = once(503, '{"erro":"interno"}') }) + local address, err = client():lookup("01001000") + local failure = harness.error_kind(address, err, cepis.SERVER_ERROR) + equal(failure.status, 503) + equal(failure:is_retriable(), true) + equal(server:count("/01001000"), 3) +end) + +test("max_attempts = 1 disables retrying", function() + server:program({ ["/01001000"] = once(500, "{}") }) + local address, err = client({ max_attempts = 1 }):lookup("01001000") + harness.error_kind(address, err, cepis.SERVER_ERROR) + equal(server:count("/01001000"), 1) +end) + +test("429 becomes rate_limited and exposes Retry-After", function() + server:program({ ["/01001000"] = once(429, "{}", { ["Retry-After"] = "7" }) }) + local address, err = client():lookup("01001000") + local failure = harness.error_kind(address, err, cepis.RATE_LIMITED) + near(failure.retry_after, 7) + equal(failure:is_retriable(), true) +end) + +test("an absurd Retry-After is capped by backoff_max", function() + server:program({ ["/01001000"] = once(429, "{}", { ["Retry-After"] = "3600" }) }) + local started = os.time() + local address, err = client():lookup("01001000") + harness.error_kind(address, err, cepis.RATE_LIMITED) + ok(os.difftime(os.time(), started) < 5, "the call parked for too long") +end) + +test("Retry-After is read in its HTTP-date form too", function() + local future = os.date("!%a, %d %b %Y %H:%M:%S GMT", os.time() + 120) + local seconds = require("cepis.client").retry_after_seconds(future) + ok(seconds, "an HTTP-date Retry-After was not parsed") + ok(seconds > 60 and seconds < 180, "expected roughly 120s, got " .. tostring(seconds)) + harness.is_nil(require("cepis.client").retry_after_seconds("not a date")) +end) + +test("nothing listening is a network_error", function() + local offline = cepis.new({ + base_url = "http://127.0.0.1:1", + max_attempts = 2, + backoff_base = 0.001, + backoff_max = 0.01, + timeout = 1, + }) + local address, err = offline:lookup("01001000") + local failure = harness.error_kind(address, err, cepis.NETWORK_ERROR) + equal(failure:is_retriable(), true) +end) + +test("total_timeout bounds the whole call", function() + server:program({ ["/01001000"] = once(500, "{}") }) + local bounded = client({ + max_attempts = 50, + backoff_base = 0.05, + backoff_max = 0.05, + total_timeout = 0.3, + }) + local started = os.time() + local address, err = bounded:lookup("01001000") + harness.error_kind(address, err, cepis.SERVER_ERROR) + ok(os.difftime(os.time(), started) < 5, "total_timeout did not cut the call short") + ok(server:count("/01001000") < 50, "every attempt ran despite total_timeout") +end) + +test("a batch keeps order and marks the missing entry", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "01001000,Praça da Sé,lado ímpar,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + "99999999,,,,,,,,,,,,", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001", + }) + ), + }) + local result, err = client():lookup_many({ "01001000", "99999999", "20040020" }) + ok(result, tostring(err)) + equal(#result, 3) + ok(result[1]) + equal(result[1].localidade, "São Paulo") + harness.is_false(result[2]) + ok(result[3]) + equal(result[3].uf, "RJ") +end) + +test("a missing CEP is false, not a hole that truncates the list", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "99999999,,,,,,,,,,,,", + "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + "20040020,Avenida Rio Branco,,,Centro,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,3304557,,21,6001", + }) + ), + }) + local result = client():lookup_many({ "99999999", "01001000", "20040020" }) + + equal(#result, 3, "a nil at index 1 would have truncated the sequence") + + local seen = 0 + for _ in ipairs(result) do + seen = seen + 1 + end + equal(seen, 3, "ipairs stopped early, so the list had a hole") + + harness.is_false(result[1]) + ok(result[2]) + equal(result[2].localidade, "São Paulo") + ok(result[3]) +end) + +test("a batch carries no coordinate", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107", + }) + ), + }) + local result = client():lookup_many({ "01001000" }) + ok(result[1]) + harness.is_nil(result[1].geo) +end) + +test("a quoted field containing a comma is parsed", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ '01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1' }) + ), + }) + local result = client():lookup_many({ "01001000" }) + ok(result[1]) + equal(result[1].logradouro, "Rua Um, Dois") +end) + +test("an empty batch makes no request", function() + server:program({}) + local result = client():lookup_many({}) + equal(#result, 0) + equal(#server:calls(), 0) +end) + +test("a batch above 500 is chunked", function() + server:program({ ["/csv"] = { csv_from_query = true, status = 200 } }) + + local ceps = {} + for index = 0, 500 do + ceps[#ceps + 1] = string.format("%08d", index) + end + + local result, err = client():lookup_many(ceps) + ok(result, tostring(err)) + equal(#result, 501) + equal(server:count("/csv"), 2) + ok(result[1]) + ok(result[501]) +end) + +test("a truncated batch response is padded, never shifted", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1" }) + ), + }) + local result = client():lookup_many({ "01001000", "20040020" }) + equal(#result, 2) + ok(result[1]) + harness.is_false(result[2]) +end) + +test("a batch response longer than the request is truncated", function() + server:program({ + ["/csv"] = once( + 200, + csv_body({ + "01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1", + "20040020,,,,,Rio de Janeiro,RJ,Rio de Janeiro,Sudeste,1,,21,1", + }) + ), + }) + local result = client():lookup_many({ "01001000" }) + equal(#result, 1) +end) + +test("health reads the live database identifier", function() + server:program({ + ["/health"] = once( + 200, + json.encode({ + status = "ok", + dne = "26071", + ceps = 1596168, + geo = 894489, + base_gerada_em = "2026-07-24T03:48:28Z", + }) + ), + }) + local health, err = client():health() + ok(health, tostring(err)) + equal(health:ok(), true) + equal(health.dne, "26071") + equal(health.ceps, 1596168) + equal(health.geo, 894489) + equal(health.base_gerada_em, "2026-07-24T03:48:28Z") +end) + +test("a degraded API is a diagnosis, not a failure", function() + server:program({ + ["/health"] = once( + 503, + json.encode({ status = "degradado", dne = "", ceps = 0, geo = 0 }) + ), + }) + local health, err = client():health() + ok(health, tostring(err)) + equal(health:ok(), false) + equal(health.status, "degradado") + equal(server:count("/health"), 1) +end) + +test("a trailing slash on base_url is not duplicated", function() + equal(cepis.new({ base_url = "http://x/" }).base_url, "http://x") + equal(cepis.new({ base_url = "http://x///" }).base_url, "http://x") +end) + +test("a bad option is a programmer mistake and raises", function() + harness.raises(cepis.new, { max_attempts = 0 }) + harness.raises(cepis.new, { timeout = 0 }) + harness.raises(cepis.new, { timeout = -1 }) + harness.raises(cepis.new, { base_url = "" }) + harness.raises(cepis.new, { total_timeout = 0 }) + harness.raises(function() + return cepis.new():lookup_many("not a table") + end) +end) + +test("the default User-Agent identifies the library", function() + local agent = cepis.new().user_agent + ok(agent:find("cepis-lua/", 1, true), agent) + ok(agent:find(cepis._VERSION, 1, true), agent) + equal(cepis.new({ user_agent = "myapp/1.0" }).user_agent, "myapp/1.0") +end) + +test("the defaults are the documented ones", function() + local c = cepis.new() + equal(c.base_url, "https://cep.is") + equal(c.max_attempts, 3) + equal(c.backoff_base, 0.5) + equal(c.backoff_max, 8.0) + harness.is_nil(c.total_timeout) + equal(cepis.MAX_BATCH, 500) + equal(cepis._VERSION, "0.1.0") +end) + +test("the CSV reader handles quotes, escapes and CRLF", function() + local rows = csv.parse('a,b\r\n"x,1","he said ""hi""" \r\n,,\n') + equal(#rows, 3) + equal(rows[1][1], "a") + equal(rows[1][2], "b") + equal(rows[2][1], "x,1") + equal(rows[2][2], 'he said "hi" ') + equal(#rows[3], 3) + equal(rows[3][1], "") +end) + +test("the CSV reader ignores a UTF-8 BOM and blank lines", function() + local rows = csv.parse("\239\187\191a,b\n\nc,d\n") + equal(#rows, 2) + equal(rows[1][1], "a") + equal(rows[2][1], "c") +end) + +local failures = harness.run() +server:stop() +os.exit(failures == 0 and 0 or 1) |