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