--- 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, "captive portal") }) 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)