summaryrefslogtreecommitdiffstats
path: root/test/run.lua
diff options
context:
space:
mode:
Diffstat (limited to 'test/run.lua')
-rw-r--r--test/run.lua506
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)