diff options
| author | Murilo Ijanc <[email protected]> | 2026-07-26 12:32:15 -0300 |
|---|---|---|
| committer | Murilo Ijanc <[email protected]> | 2026-07-26 12:32:15 -0300 |
| commit | 36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c (patch) | |
| tree | bfd0a067edb5e802725bab48ebc7a0a09838feb1 /test | |
| download | ruby-36a0ddb01a4fcb8fb2fefd7c407ecca0e3e56e7c.tar.gz | |
Initial importv0.1.0
Diffstat (limited to 'test')
| -rw-r--r-- | test/cepis_test.rb | 293 | ||||
| -rw-r--r-- | test/test_helper.rb | 166 |
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 |