summaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:32:15 -0300
commit36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch)
treebfd0a067edb5e802725bab48ebc7a0a09838feb1 /test
downloadruby-36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c.tar.gz
Initial importv0.1.0
Diffstat (limited to 'test')
-rw-r--r--test/cepis_test.rb293
-rw-r--r--test/test_helper.rb166
2 files changed, 459 insertions, 0 deletions
diff --git a/test/cepis_test.rb b/test/cepis_test.rb
new file mode 100644
index 0000000..06a6bf6
--- /dev/null
+++ b/test/cepis_test.rb
@@ -0,0 +1,293 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+# The suite brings up a real HTTP server on loopback and points the client at
+# it. No test touches the network, and no test stubs Net::HTTP: 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.
+
+class NormalizationTest < Minitest::Test
+ def test_accepts_masked_and_unmasked
+ ["01001000", "01001-000", " 01001-000 ", "01001.000"].each do |value|
+ assert_equal "01001000", Cepis.normalize_cep(value)
+ end
+ end
+
+ def test_rejects_bad_format
+ ["", "123", "0100100a", "010010000", "abcdefgh"].each do |value|
+ assert_raises(Cepis::InvalidCEP) { Cepis.normalize_cep(value) }
+ end
+ end
+
+ def test_rejects_wrong_type
+ assert_raises(Cepis::InvalidCEP) { Cepis.normalize_cep(1_001_000) }
+ end
+
+ def test_one_rescue_catches_everything
+ assert_raises(Cepis::Error) { Cepis.normalize_cep("123") }
+ end
+end
+
+class LookupTest < ServerTest
+ def test_successful_lookup
+ routes.respond("/01001000", [200, JSON.generate(SE_ADDRESS), {}])
+ address = @client.lookup("01001-000")
+
+ assert_equal "São Paulo", address.localidade
+ assert_equal "SP", address.uf
+ assert_equal "01001-000", address.formatted_cep
+ assert_nil address.geo
+ end
+
+ def test_reads_the_coordinate_when_present
+ body = SE_ADDRESS.merge(
+ "geo" => { "lat" => -23.5, "lon" => -46.6, "raio_m" => 120, "precisao" => "logradouro" }
+ )
+ routes.respond("/01310100", [200, JSON.generate(body), {}])
+ address = @client.lookup("01310100")
+
+ assert_in_delta(-23.5, address.geo.lat)
+ assert_in_delta(-46.6, address.geo.lon)
+ assert_equal 120, address.geo.raio_m
+ assert address.geo.precise?
+ end
+
+ def test_municipality_coordinate_is_not_precise
+ body = SE_ADDRESS.merge(
+ "geo" => { "lat" => -23.5, "lon" => -46.6, "raio_m" => 22_000, "precisao" => "localidade" }
+ )
+ routes.respond("/01001000", [200, JSON.generate(body), {}])
+
+ refute @client.lookup("01001000").geo.precise?
+ end
+
+ def test_malformed_geo_does_not_lose_the_address
+ body = SE_ADDRESS.merge("geo" => { "lat" => "not a number" })
+ routes.respond("/01001000", [200, JSON.generate(body), {}])
+ address = @client.lookup("01001000")
+
+ assert_nil address.geo
+ assert_equal "São Paulo", address.localidade
+ end
+
+ def test_new_api_field_does_not_break
+ body = SE_ADDRESS.merge("campo_do_futuro" => "value")
+ routes.respond("/01001000", [200, JSON.generate(body), {}])
+
+ assert_equal "value", @client.lookup("01001000").raw["campo_do_futuro"]
+ end
+
+ def test_not_found
+ routes.respond(
+ "/99999999",
+ [404, JSON.generate({ "erro" => "cep_nao_encontrado", "mensagem" => "gone" }), {}]
+ )
+ error = assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") }
+
+ assert_includes error.message, "99999999"
+ end
+
+ def test_not_found_is_not_retried
+ routes.respond("/99999999", [404, JSON.generate({ "erro" => "x" }), {}])
+ assert_raises(Cepis::CEPNotFound) { @client.lookup("99999999") }
+
+ assert_equal 1, routes.count("/99999999")
+ end
+
+ def test_invalid_cep_never_leaves_the_machine
+ assert_raises(Cepis::InvalidCEP) { @client.lookup("123") }
+
+ assert_empty routes.calls
+ end
+
+ def test_api_400_becomes_invalid_cep
+ routes.respond(
+ "/00000000",
+ [400, JSON.generate({ "erro" => "cep_invalido", "mensagem" => "rejected by the API" }), {}]
+ )
+ error = assert_raises(Cepis::InvalidCEP) { @client.lookup("00000000") }
+
+ assert_includes error.message, "rejected by the API"
+ end
+
+ def test_response_that_is_not_json
+ routes.respond("/01001000", [200, "<html>captive portal</html>", {}])
+
+ assert_raises(Cepis::InvalidResponse) { @client.lookup("01001000") }
+ end
+end
+
+class RetryTest < ServerTest
+ def test_retries_on_500_then_succeeds
+ routes.respond("/01001000", [
+ [500, JSON.generate({ "erro" => "interno" }), {}],
+ [200, JSON.generate(SE_ADDRESS), {}]
+ ])
+
+ assert_equal "São Paulo", @client.lookup("01001000").localidade
+ assert_equal 2, routes.count("/01001000")
+ end
+
+ def test_exhausts_attempts_then_raises
+ routes.respond("/01001000", [503, JSON.generate({ "erro" => "interno" }), {}])
+ error = assert_raises(Cepis::ServerError) { @client.lookup("01001000") }
+
+ assert_equal 503, error.status
+ assert_equal 3, routes.count("/01001000")
+ end
+
+ def test_max_attempts_one_disables_retry
+ client = Cepis::Client.new(base_url: @server.url, max_attempts: 1, timeout: 5.0)
+ routes.respond("/01001000", [500, "{}", {}])
+
+ assert_raises(Cepis::ServerError) { client.lookup("01001000") }
+ assert_equal 1, routes.count("/01001000")
+ end
+
+ def test_429_becomes_rate_limited_with_retry_after
+ routes.respond("/01001000", [429, "{}", { "Retry-After" => "7" }])
+ error = assert_raises(Cepis::RateLimited) { @client.lookup("01001000") }
+
+ assert_in_delta 7.0, error.retry_after
+ end
+
+ def test_retry_after_accepts_an_http_date
+ routes.respond(
+ "/01001000",
+ [429, "{}", { "Retry-After" => (Time.now + 4).httpdate }]
+ )
+ error = assert_raises(Cepis::RateLimited) { @client.lookup("01001000") }
+
+ assert_operator error.retry_after, :>, 0
+ assert_operator error.retry_after, :<=, 5
+ end
+
+ def test_retry_after_honours_the_cap
+ routes.respond("/01001000", [429, "{}", { "Retry-After" => "3600" }])
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ assert_raises(Cepis::RateLimited) { @client.lookup("01001000") }
+
+ assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 5.0
+ end
+
+ def test_network_error_when_nothing_listens
+ client = Cepis::Client.new(
+ base_url: "http://127.0.0.1:1", max_attempts: 2, backoff_base: 0.001, timeout: 1.0
+ )
+
+ assert_raises(Cepis::NetworkError) { client.lookup("01001000") }
+ end
+
+ def test_total_timeout_bounds_the_whole_call
+ client = Cepis::Client.new(
+ base_url: @server.url, max_attempts: 50, backoff_base: 0.05,
+ backoff_max: 0.05, total_timeout: 0.3
+ )
+ routes.respond("/01001000", [500, "{}", {}])
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+ assert_raises(Cepis::ServerError) { client.lookup("01001000") }
+ assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - started, :<, 3.0
+ assert_operator routes.count("/01001000"), :<, 50
+ end
+end
+
+class BatchTest < ServerTest
+ def test_preserves_order_and_marks_missing
+ body = 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"
+ ])
+ routes.respond("/csv?ceps=01001000%2C99999999%2C20040020", [200, body, {}])
+ result = @client.lookup_many(%w[01001000 99999999 20040020])
+
+ assert_equal 3, result.length
+ assert_equal "São Paulo", result[0].localidade
+ assert_nil result[1]
+ assert_equal "RJ", result[2].uf
+ end
+
+ def test_batch_carries_no_coordinate
+ body = csv_body(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ routes.respond("/csv?ceps=01001000", [200, body, {}])
+
+ assert_nil @client.lookup_many(["01001000"]).first.geo
+ end
+
+ def test_field_with_comma_is_parsed
+ body = csv_body(['01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1'])
+ routes.respond("/csv?ceps=01001000", [200, body, {}])
+
+ assert_equal "Rua Um, Dois", @client.lookup_many(["01001000"]).first.logradouro
+ end
+
+ def test_empty_list_makes_no_request
+ assert_empty @client.lookup_many([])
+ assert_empty routes.calls
+ end
+
+ def test_chunks_above_the_api_limit
+ ceps = Array.new(501) { |i| format("%08d", i) }
+ rows = ceps.map { |cep| "#{cep},,,,,X,SP,São Paulo,Sudeste,1,,11,1" }
+ routes.handler = lambda do |_path, call|
+ [200, csv_body(call == 1 ? rows[0, 500] : rows[500..]), {}]
+ end
+ result = @client.lookup_many(ceps)
+
+ assert_equal 2, routes.calls.length
+ assert_equal 501, result.length
+ end
+
+ def test_truncated_response_does_not_shift
+ body = csv_body(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ routes.respond("/csv?ceps=01001000%2C20040020", [200, body, {}])
+ result = @client.lookup_many(%w[01001000 20040020])
+
+ assert_equal 2, result.length
+ assert_nil result[1]
+ end
+end
+
+class HealthTest < ServerTest
+ def test_reads_health
+ body = JSON.generate(
+ { "status" => "ok", "dne" => "26071", "ceps" => 1_596_168,
+ "geo" => 894_489, "base_gerada_em" => "2026-07-24T03:48:28Z" }
+ )
+ routes.respond("/health", [200, body, {}])
+ health = @client.health
+
+ assert health.ok?
+ assert_equal "26071", health.dne
+ assert_equal 1_596_168, health.ceps
+ end
+
+ def test_degraded_does_not_raise
+ body = JSON.generate({ "status" => "degradado", "dne" => "", "ceps" => 0, "geo" => 0 })
+ routes.respond("/health", [503, body, {}])
+
+ refute @client.health.ok?
+ end
+end
+
+class ConfigurationTest < Minitest::Test
+ def test_trailing_slash_is_not_duplicated
+ assert_equal "http://x", Cepis::Client.new(base_url: "http://x/").base_url
+ end
+
+ def test_invalid_parameters
+ assert_raises(ArgumentError) { Cepis::Client.new(max_attempts: 0) }
+ assert_raises(ArgumentError) { Cepis::Client.new(timeout: 0) }
+ end
+
+ def test_user_agent_identifies_the_library
+ assert_includes Cepis::Client.new.user_agent, "cepis-ruby/#{Cepis::VERSION}"
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
new file mode 100644
index 0000000..e12bd9d
--- /dev/null
+++ b/test/test_helper.rb
@@ -0,0 +1,166 @@
+# frozen_string_literal: true
+
+require "json"
+require "minitest/autorun"
+require "socket"
+
+require "cepis"
+
+# A CEP as the API returns it, used as the fixture across the suite. The field
+# names stay Portuguese because they mirror the API's JSON.
+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"
+}.freeze
+
+CSV_HEADER = "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado," \
+ "regiao,ibge,gia,ddd,siafi"
+
+# Per-test programmable routes, plus a request counter.
+class Router
+ attr_reader :calls
+ attr_accessor :handler
+
+ def initialize
+ @routes = {}
+ @calls = []
+ @handler = nil
+ @lock = Mutex.new
+ end
+
+ # Register the response for a path.
+ #
+ # The response may be a `[status, body, headers]` triple or an array of them
+ # — in which case each request consumes the next one, which is how retrying
+ # is tested. The last entry repeats once the list runs out.
+ def respond(path, response)
+ @lock.synchronize { @routes[path] = response }
+ end
+
+ # Record the call and return the response registered for `path`.
+ def next_response(path)
+ @lock.synchronize do
+ @calls << path
+ return @handler.call(path, @calls.length) if @handler
+
+ response = @routes[path]
+ return [404, JSON.generate({ "erro" => "cep_nao_encontrado" }), {}] if response.nil?
+ return response unless response.first.is_a?(Array)
+
+ response.length > 1 ? response.shift : response.first
+ end
+ end
+
+ # How many requests so far began with `prefix`.
+ def count(prefix)
+ @lock.synchronize { @calls.count { |path| path.start_with?(prefix) } }
+ end
+end
+
+# A throwaway HTTP/1.1 server on a random loopback port.
+#
+# Deliberately built on TCPServer rather than on a web server gem: the suite
+# must run against a stock Ruby, with nothing installed.
+class FakeServer
+ REASONS = {
+ 200 => "OK", 400 => "Bad Request", 404 => "Not Found",
+ 429 => "Too Many Requests", 500 => "Internal Server Error",
+ 502 => "Bad Gateway", 503 => "Service Unavailable"
+ }.freeze
+
+ attr_reader :router
+
+ def initialize
+ @router = Router.new
+ @server = TCPServer.new("127.0.0.1", 0)
+ @thread = Thread.new { accept_loop }
+ end
+
+ def url
+ "http://127.0.0.1:#{@server.addr[1]}"
+ end
+
+ def stop
+ @server.close
+ @thread.join(5)
+ end
+
+ private
+
+ def accept_loop
+ loop do
+ socket = begin
+ @server.accept
+ rescue IOError, Errno::EBADF
+ break
+ end
+ Thread.new(socket) { |client| serve(client) }
+ end
+ end
+
+ def serve(socket)
+ request_line = socket.gets
+ return if request_line.nil?
+
+ path = request_line.split(" ")[1]
+ while (line = socket.gets)
+ break if line == "\r\n"
+ end
+
+ status, body, headers = @router.next_response(path)
+ write_response(socket, status, body.to_s.b, headers || {})
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
+ nil
+ ensure
+ begin
+ socket.close
+ rescue IOError
+ nil
+ end
+ end
+
+ def write_response(socket, status, body, headers)
+ head = +"HTTP/1.1 #{status} #{REASONS.fetch(status, 'Unknown')}\r\n"
+ headers.each { |key, value| head << "#{key}: #{value}\r\n" }
+ head << "Content-Type: text/plain; charset=utf-8\r\n"
+ head << "Content-Length: #{body.bytesize}\r\n"
+ head << "Connection: close\r\n\r\n"
+ socket.write(head.b + body)
+ end
+end
+
+# Fixture with a fake server and a client whose backoff is near zero.
+#
+# The backoff is tiny because what is under test is the retry logic, not
+# `sleep`'s ability to sleep.
+class ServerTest < Minitest::Test
+ def setup
+ @server = FakeServer.new
+ @client = Cepis::Client.new(
+ base_url: @server.url, timeout: 5.0, backoff_base: 0.001, backoff_max: 0.01
+ )
+ end
+
+ def teardown
+ @server.stop
+ end
+
+ def routes
+ @server.router
+ end
+
+ def csv_body(rows)
+ "#{CSV_HEADER}\n#{rows.join("\n")}\n"
+ end
+end