summaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:42:08 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:42:08 -0300
commit7fd555fcaf4304cb0dff3e8f5661565c3618299e (patch)
tree61199793c3046d73b97e9822895ede2a33034d41 /test
downloadlua-71232179245c1f52db6822ef5a77cc913ba6a2a4.tar.gz
Initial importv0.1.0
Diffstat (limited to 'test')
-rw-r--r--test/harness.lua205
-rw-r--r--test/run.lua506
-rw-r--r--test/server.lua182
3 files changed, 893 insertions, 0 deletions
diff --git a/test/harness.lua b/test/harness.lua
new file mode 100644
index 0000000..b6b3a65
--- /dev/null
+++ b/test/harness.lua
@@ -0,0 +1,205 @@
+--- Assertions, the test registry and control of the fake server.
+--
+-- No rock beyond the ones the library already needs: registering a test is
+-- `test(name, fn)`, a failure is an `error()` out of an assertion, and the
+-- runner exits non-zero if any of them fires.
+
+local http = require("socket.http")
+local ltn12 = require("ltn12")
+local socket = require("socket")
+local json = require("dkjson")
+
+local M = {}
+
+M.tests = {}
+
+--- Register a test. Order of registration is order of execution.
+function M.test(name, fn)
+ M.tests[#M.tests + 1] = { name = name, fn = fn }
+end
+
+local function fail(message, level)
+ error(message, (level or 2) + 1)
+end
+
+local function show(value)
+ if type(value) == "string" then
+ return string.format("%q", value)
+ end
+ return tostring(value)
+end
+
+function M.ok(value, message)
+ if not value then
+ fail(message or ("expected a truthy value, got " .. show(value)))
+ end
+end
+
+function M.falsy(value, message)
+ if value then
+ fail(message or ("expected a falsy value, got " .. show(value)))
+ end
+end
+
+function M.equal(got, want, message)
+ if got ~= want then
+ fail(
+ (message and (message .. ": ") or "")
+ .. "expected "
+ .. show(want)
+ .. ", got "
+ .. show(got)
+ )
+ end
+end
+
+function M.near(got, want, message)
+ if type(got) ~= "number" or math.abs(got - want) > 1e-6 then
+ fail((message and (message .. ": ") or "") .. "expected ~" .. tostring(want) .. ", got " .. show(got))
+ end
+end
+
+function M.is_nil(value, message)
+ if value ~= nil then
+ fail(message or ("expected nil, got " .. show(value)))
+ end
+end
+
+function M.is_false(value, message)
+ if value ~= false then
+ fail(message or ("expected false, got " .. show(value)))
+ end
+end
+
+--- Assert a call failed with a given error kind, and return the error.
+function M.error_kind(value, err, kind)
+ if value ~= nil then
+ fail("expected a failure, got " .. show(value))
+ end
+ if type(err) ~= "table" or err.kind == nil then
+ fail("expected a cepis error, got " .. show(err))
+ end
+ if err.kind ~= kind then
+ fail("expected kind " .. show(kind) .. ", got " .. show(err.kind) .. " (" .. tostring(err.message) .. ")")
+ end
+ return err
+end
+
+--- Assert a call raised through `error()`, for programmer mistakes.
+function M.raises(fn, ...)
+ local ok = pcall(fn, ...)
+ if ok then
+ fail("expected error() to be raised")
+ end
+end
+
+local function request(url, body)
+ local chunks = {}
+ local headers = nil
+ local source = nil
+ if body then
+ source = ltn12.source.string(body)
+ headers = {
+ ["content-length"] = tostring(#body),
+ ["content-type"] = "application/json",
+ }
+ end
+ local ok, code = http.request({
+ url = url,
+ method = body and "POST" or "GET",
+ source = source,
+ headers = headers,
+ sink = ltn12.sink.table(chunks),
+ })
+ if not ok then
+ error("control request to " .. url .. " failed: " .. tostring(code))
+ end
+ return table.concat(chunks)
+end
+
+local Server = {}
+Server.__index = Server
+
+--- Replace the routes and clear the call log.
+function Server:program(routes)
+ request(self.url .. "/__program", json.encode(routes))
+end
+
+--- Every request target the server has seen since the last `program`.
+function Server:calls()
+ return json.decode(request(self.url .. "/__calls")) or {}
+end
+
+--- How many of them began with `prefix`.
+function Server:count(prefix)
+ local total = 0
+ for _, target in ipairs(self:calls()) do
+ if target:sub(1, #prefix) == prefix then
+ total = total + 1
+ end
+ end
+ return total
+end
+
+function Server:stop()
+ pcall(request, self.url .. "/__stop")
+end
+
+--- Spawn the fake server and wait for it to report its port.
+function M.start_server()
+ local directory = (arg[0] or "test/run.lua"):match("^(.*)[/\\]") or "."
+ local portfile = os.tmpname()
+ os.remove(portfile)
+
+ local interpreter = arg[-1] or "lua"
+ os.execute(
+ string.format(
+ "%s %s/server.lua %s >/dev/null 2>&1 &",
+ interpreter,
+ directory,
+ portfile
+ )
+ )
+
+ local port
+ local deadline = socket.gettime() + 10
+ while socket.gettime() < deadline do
+ local handle = io.open(portfile, "r")
+ if handle then
+ local content = handle:read("*a")
+ handle:close()
+ port = tonumber(content)
+ if port then
+ break
+ end
+ end
+ socket.sleep(0.02)
+ end
+
+ os.remove(portfile)
+ if not port then
+ error("the fake server did not start")
+ end
+
+ return setmetatable({ url = "http://127.0.0.1:" .. port, port = port }, Server)
+end
+
+--- Run every registered test, printing one line each.
+function M.run()
+ local failures = 0
+ for _, entry in ipairs(M.tests) do
+ local ok, err = pcall(entry.fn)
+ if ok then
+ print("ok - " .. entry.name)
+ else
+ failures = failures + 1
+ print("FAIL - " .. entry.name)
+ print(" " .. tostring(err))
+ end
+ end
+
+ print(string.format("\n%d test(s), %d failure(s)", #M.tests, failures))
+ return failures
+end
+
+return M
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)
diff --git a/test/server.lua b/test/server.lua
new file mode 100644
index 0000000..a51b8bf
--- /dev/null
+++ b/test/server.lua
@@ -0,0 +1,182 @@
+--- Programmable HTTP server used by the suite, run as a separate process.
+--
+-- Lua has no threads, and the client blocks on its socket, so the server
+-- cannot share this interpreter with the code under test. It is spawned once
+-- per run, binds a free loopback port and writes it to the file named in
+-- `arg[1]`, then answers until it is told to stop or nothing has connected for
+-- a while.
+--
+-- Three control routes drive it from the test process:
+--
+-- POST /__program replace the routes and clear the call log
+-- GET /__calls the request targets seen so far, as a JSON array
+-- GET /__stop shut down
+--
+-- A route maps a path (query string excluded) to `{ responses = { ... } }`.
+-- Each request consumes the next response; the last one repeats once the list
+-- runs out, which is how retrying is tested. A route may instead set
+-- `csv_from_query`, and the server then generates one CSV row per CEP in the
+-- query — that keeps the 501-CEP chunking test from shipping a body through
+-- the control channel.
+
+local socket = require("socket")
+local json = require("dkjson")
+
+local IDLE_TIMEOUT = 60
+
+local REASON = {
+ [200] = "OK",
+ [400] = "Bad Request",
+ [404] = "Not Found",
+ [429] = "Too Many Requests",
+ [500] = "Internal Server Error",
+ [502] = "Bad Gateway",
+ [503] = "Service Unavailable",
+}
+
+local CSV_HEADER =
+ "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+local routes = {}
+local cursor = {}
+local calls = {}
+local running = true
+
+local function unescape(value)
+ return (value:gsub("%%(%x%x)", function(hex)
+ return string.char(tonumber(hex, 16))
+ end))
+end
+
+local function read_request(client)
+ local line = client:receive("*l")
+ if not line then
+ return nil
+ end
+
+ local method, target = line:match("^(%u+)%s+(%S+)")
+ if not method then
+ return nil
+ end
+
+ local headers = {}
+ while true do
+ local header = client:receive("*l")
+ if not header or header == "" then
+ break
+ end
+ local name, value = header:match("^([^:]+):%s*(.*)$")
+ if name then
+ headers[name:lower()] = value
+ end
+ end
+
+ local body = ""
+ local length = tonumber(headers["content-length"] or "0") or 0
+ if length > 0 then
+ body = client:receive(length) or ""
+ end
+
+ return method, target, body
+end
+
+local function send(client, status, body, headers)
+ local out = {
+ string.format("HTTP/1.1 %d %s\r\n", status, REASON[status] or "Status"),
+ }
+ for name, value in pairs(headers or {}) do
+ out[#out + 1] = name .. ": " .. tostring(value) .. "\r\n"
+ end
+ out[#out + 1] = "Content-Length: " .. #body .. "\r\n"
+ out[#out + 1] = "Connection: close\r\n\r\n"
+ out[#out + 1] = body
+ client:send(table.concat(out))
+end
+
+local function csv_for(target)
+ local raw = target:match("[?&]ceps=([^&]*)") or ""
+ local rows = { CSV_HEADER }
+ for cep in unescape(raw):gmatch("[^,]+") do
+ rows[#rows + 1] = cep .. ",,,,,X,SP,São Paulo,Sudeste,1,,11,1"
+ end
+ return table.concat(rows, "\n") .. "\n"
+end
+
+local function next_response(path)
+ local route = routes[path]
+ if not route then
+ return nil
+ end
+
+ local responses = route.responses
+ if not responses or #responses == 0 then
+ return route
+ end
+
+ local index = cursor[path] or 1
+ if index < #responses then
+ cursor[path] = index + 1
+ end
+ return responses[index]
+end
+
+local function serve(client, target, body)
+ local path = target:match("^([^?]*)")
+
+ if path == "/__program" then
+ routes = json.decode(body) or {}
+ cursor = {}
+ calls = {}
+ send(client, 200, "{}")
+ return
+ end
+
+ if path == "/__calls" then
+ send(client, 200, json.encode(calls))
+ return
+ end
+
+ if path == "/__stop" then
+ running = false
+ send(client, 200, "{}")
+ return
+ end
+
+ calls[#calls + 1] = target
+
+ local response = next_response(path)
+ if not response then
+ send(client, 404, '{"erro":"cep_nao_encontrado","mensagem":"CEP nao existe"}')
+ return
+ end
+
+ if response.csv_from_query then
+ send(client, response.status or 200, csv_for(target), response.headers)
+ return
+ end
+
+ send(client, response.status or 200, response.body or "", response.headers)
+end
+
+local server = assert(socket.bind("127.0.0.1", 0))
+local _, port = server:getsockname()
+server:settimeout(IDLE_TIMEOUT)
+
+local handle = assert(io.open(arg[1], "w"))
+handle:write(tostring(port))
+handle:close()
+
+while running do
+ local client = server:accept()
+ if not client then
+ break
+ end
+ client:settimeout(10)
+ local _, target, body = read_request(client)
+ if target then
+ serve(client, target, body)
+ end
+ client:close()
+end
+
+server:close()