summaryrefslogtreecommitdiffstats
path: root/test
diff options
context:
space:
mode:
authorMurilo Ijanc <[email protected]>2026-07-26 12:19:34 -0300
committerMurilo Ijanc <[email protected]>2026-07-26 12:19:34 -0300
commit075683ff44a254e99adbad8a0372ef80ff06151a (patch)
tree648289c8485afc6f003a3f719fb937c21f149311 /test
downloadelixir-0785ed6b367d134b192c89fbf840532f172a3b4a.tar.gz
Initial importv0.1.0
Diffstat (limited to 'test')
-rw-r--r--test/cepis/batch_test.exs98
-rw-r--r--test/cepis/csv_test.exs49
-rw-r--r--test/cepis/health_test.exs45
-rw-r--r--test/cepis/lookup_test.exs152
-rw-r--r--test/cepis/retry_test.exs90
-rw-r--r--test/cepis_test.exs79
-rw-r--r--test/support/fake_server.ex154
-rw-r--r--test/support/server_case.ex52
-rw-r--r--test/test_helper.exs1
9 files changed, 720 insertions, 0 deletions
diff --git a/test/cepis/batch_test.exs b/test/cepis/batch_test.exs
new file mode 100644
index 0000000..dd8fe83
--- /dev/null
+++ b/test/cepis/batch_test.exs
@@ -0,0 +1,98 @@
+defmodule Cepis.BatchTest do
+ use Cepis.ServerCase
+
+ test "order is preserved and a missing CEP becomes nil", %{server: server, client: client} do
+ body =
+ csv([
+ "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"
+ ])
+
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [first, nil, third]} =
+ Cepis.lookup_many(["01001000", "99999999", "20040020"], client)
+
+ assert first.localidade == "São Paulo"
+ assert third.uf == "RJ"
+
+ assert [path] = FakeServer.calls(server)
+ assert path == "/csv?ceps=01001000%2C99999999%2C20040020"
+ end
+
+ test "batches never carry a coordinate", %{server: server, client: client} do
+ body = csv(["01001000,Praça da Sé,,,Sé,São Paulo,SP,São Paulo,Sudeste,3550308,1004,11,7107"])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [address]} = Cepis.lookup_many(["01001000"], client)
+ assert address.geo == nil
+ end
+
+ test "a quoted field containing a comma is parsed, not split", %{server: server, client: client} do
+ body = csv([~s(01001000,"Rua Um, Dois",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [address]} = Cepis.lookup_many(["01001000"], client)
+ assert address.logradouro == "Rua Um, Dois"
+ end
+
+ test "an escaped quote inside a quoted field is unescaped", %{server: server, client: client} do
+ body = csv([~s(01001000,"Rua ""A""",,,Centro,São Paulo,SP,São Paulo,Sudeste,1,,11,1)])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [address]} = Cepis.lookup_many(["01001000"], client)
+ assert address.logradouro == ~s(Rua "A")
+ end
+
+ test "an empty list makes no request", %{server: server, client: client} do
+ assert Cepis.lookup_many([], client) == {:ok, []}
+ assert FakeServer.calls(server) == []
+ end
+
+ test "more than 500 CEPs become more than one request", %{server: server, client: client} do
+ ceps = Enum.map(0..500, &String.pad_leading(to_string(&1), 8, "0"))
+ {first, second} = Enum.split(ceps, 500)
+
+ rows = fn chunk ->
+ csv(Enum.map(chunk, &"#{&1},,,,,X,SP,São Paulo,Sudeste,1,,11,1"))
+ end
+
+ bodies = %{1 => rows.(first), 2 => rows.(second)}
+ FakeServer.on_request(server, fn _path, n -> {200, Map.fetch!(bodies, n), []} end)
+
+ assert {:ok, result} = Cepis.lookup_many(ceps, client)
+ assert length(result) == 501
+ assert Enum.all?(result, &(&1.localidade == "X"))
+ assert length(FakeServer.calls(server)) == 2
+ end
+
+ test "a truncated response pads rather than shifting", %{server: server, client: client} do
+ body = csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert {:ok, [first, nil]} = Cepis.lookup_many(["01001000", "20040020"], client)
+ assert first.localidade == "São Paulo"
+ end
+
+ test "an empty body yields a full list of nils", %{server: server, client: client} do
+ FakeServer.on_request(server, fn _path, _n -> {200, "", []} end)
+
+ assert {:ok, [nil, nil]} = Cepis.lookup_many(["01001000", "20040020"], client)
+ end
+
+ test "a transport failure fails the whole batch", %{server: server, client: client} do
+ FakeServer.on_request(server, fn _path, _n -> {503, "", []} end)
+
+ assert {:error, %Cepis.ServerError{status: 503}} = Cepis.lookup_many(["01001000"], client)
+ end
+
+ test "the bang variant returns the list", %{server: server, client: client} do
+ body = csv(["01001000,,,,,São Paulo,SP,São Paulo,Sudeste,1,,11,1"])
+ FakeServer.on_request(server, fn _path, _n -> {200, body, []} end)
+
+ assert [%Cepis.Address{}] = Cepis.lookup_many!(["01001000"], client)
+ end
+end
diff --git a/test/cepis/csv_test.exs b/test/cepis/csv_test.exs
new file mode 100644
index 0000000..719de66
--- /dev/null
+++ b/test/cepis/csv_test.exs
@@ -0,0 +1,49 @@
+defmodule Cepis.CSVTest do
+ use ExUnit.Case, async: true
+
+ alias Cepis.CSV
+
+ test "parses plain rows" do
+ assert CSV.parse("a,b\n1,2\n") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "tolerates a missing final newline" do
+ assert CSV.parse("a,b\n1,2") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "tolerates CRLF" do
+ assert CSV.parse("a,b\r\n1,2\r\n") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "keeps empty fields" do
+ assert CSV.parse("a,b,c\n1,,3\n") == [["a", "b", "c"], ["1", "", "3"]]
+ end
+
+ test "unquotes fields containing commas, quotes and newlines" do
+ text = ~s(a,b\n"x, y","he said ""hi"""\n)
+ assert CSV.parse(text) == [["a", "b"], ["x, y", ~s(he said "hi")]]
+
+ assert CSV.parse(~s(a\n"one\ntwo"\n)) == [["a"], ["one\ntwo"]]
+ end
+
+ test "strips a byte order mark" do
+ assert CSV.parse("a,b\n1,2\n") == [["a", "b"], ["1", "2"]]
+ end
+
+ test "keeps multi-byte characters intact" do
+ assert CSV.parse("a\nSão Paulo\n") == [["a"], ["São Paulo"]]
+ end
+
+ test "an empty document has no rows" do
+ assert CSV.parse("") == []
+ assert CSV.parse("\n") == []
+ end
+
+ test "pairs values with the header, padding short rows" do
+ assert CSV.parse_with_header("a,b,c\n1,2\n") == [%{"a" => "1", "b" => "2", "c" => ""}]
+ end
+
+ test "a header alone yields no rows" do
+ assert CSV.parse_with_header("a,b,c\n") == []
+ end
+end
diff --git a/test/cepis/health_test.exs b/test/cepis/health_test.exs
new file mode 100644
index 0000000..47a49c2
--- /dev/null
+++ b/test/cepis/health_test.exs
@@ -0,0 +1,45 @@
+defmodule Cepis.HealthTest do
+ use Cepis.ServerCase
+
+ test "reads the live database identifier", %{server: server, client: client} do
+ body =
+ json(%{
+ "status" => "ok",
+ "dne" => "26071",
+ "ceps" => 1_596_168,
+ "geo" => 894_489,
+ "base_gerada_em" => "2026-07-24T03:48:28Z"
+ })
+
+ FakeServer.respond(server, "/health", {200, body, []})
+
+ assert {:ok, health} = Cepis.health(client)
+ assert Cepis.Health.ok?(health)
+ assert health.dne == "26071"
+ assert health.ceps == 1_596_168
+ assert health.geo == 894_489
+ assert health.base_gerada_em == "2026-07-24T03:48:28Z"
+ end
+
+ test "a degraded API is a diagnosis, not a failure", %{server: server, client: client} do
+ body = json(%{"status" => "degradado", "dne" => "", "ceps" => 0, "geo" => 0})
+ FakeServer.respond(server, "/health", {503, body, []})
+
+ assert {:ok, health} = Cepis.health(client)
+ refute Cepis.Health.ok?(health)
+ assert health.status == "degradado"
+ end
+
+ test "a 503 on /health is not retried", %{server: server, client: client} do
+ FakeServer.respond(server, "/health", {503, json(%{"status" => "degradado"}), []})
+
+ assert {:ok, _health} = Cepis.health(client)
+ assert FakeServer.count(server, "/health") == 1
+ end
+
+ test "the bang variant returns the struct", %{server: server, client: client} do
+ FakeServer.respond(server, "/health", {200, json(%{"status" => "ok"}), []})
+
+ assert %Cepis.Health{status: "ok"} = Cepis.health!(client)
+ end
+end
diff --git a/test/cepis/lookup_test.exs b/test/cepis/lookup_test.exs
new file mode 100644
index 0000000..b68ecbe
--- /dev/null
+++ b/test/cepis/lookup_test.exs
@@ -0,0 +1,152 @@
+defmodule Cepis.LookupTest do
+ use Cepis.ServerCase
+
+ test "resolves a CEP", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture()), []})
+
+ assert {:ok, address} = Cepis.lookup("01001-000", client)
+ assert address.logradouro == "Praça da Sé"
+ assert address.localidade == "São Paulo"
+ assert address.uf == "SP"
+ assert Cepis.Address.formatted_cep(address) == "01001-000"
+ assert address.geo == nil
+ end
+
+ test "reads the coordinate when the API sends one", %{server: server, client: client} do
+ body =
+ address_fixture(%{
+ "geo" => %{"lat" => -23.5, "lon" => -46.6, "raio_m" => 120, "precisao" => "logradouro"}
+ })
+
+ FakeServer.respond(server, "/01310100", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01310100", client)
+ assert address.geo.lat == -23.5
+ assert address.geo.lon == -46.6
+ assert address.geo.raio_m == 120
+ assert Cepis.Geo.precise?(address.geo)
+ end
+
+ test "a municipality centroid is not precise", %{server: server, client: client} do
+ body =
+ address_fixture(%{
+ "geo" => %{"lat" => -23.5, "lon" => -46.6, "raio_m" => 22_000, "precisao" => "localidade"}
+ })
+
+ FakeServer.respond(server, "/01001000", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ refute Cepis.Geo.precise?(address.geo)
+ end
+
+ test "a malformed geo block does not lose the address", %{server: server, client: client} do
+ body =
+ address_fixture(%{
+ "geo" => %{
+ "lat" => "not a number",
+ "lon" => "-46.6",
+ "raio_m" => "120",
+ "precisao" => "logradouro"
+ }
+ })
+
+ FakeServer.respond(server, "/01001000", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.geo == nil
+ assert address.localidade == "São Paulo"
+ end
+
+ test "a truncated geo block does not lose the address", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture(%{"geo" => %{}})), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.geo == nil
+ assert address.uf == "SP"
+ end
+
+ test "a field the API grows is kept in raw", %{server: server, client: client} do
+ body = address_fixture(%{"campo_do_futuro" => "value"})
+ FakeServer.respond(server, "/01001000", {200, json(body), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.raw["campo_do_futuro"] == "value"
+ assert address.localidade == "São Paulo"
+ end
+
+ test "a missing CEP is a not-found error", %{server: server, client: client} do
+ FakeServer.respond(
+ server,
+ "/99999999",
+ {404, json(%{"erro" => "cep_nao_encontrado", "mensagem" => "gone"}), []}
+ )
+
+ assert {:error, %Cepis.NotFoundError{cep: "99999999"} = error} =
+ Cepis.lookup("99999999", client)
+
+ assert Exception.message(error) =~ "99999999"
+ end
+
+ test "a 404 is never retried", %{server: server, client: client} do
+ FakeServer.respond(server, "/99999999", {404, json(%{"erro" => "cep_nao_encontrado"}), []})
+
+ assert {:error, %Cepis.NotFoundError{}} = Cepis.lookup("99999999", client)
+ assert FakeServer.count(server, "/99999999") == 1
+ end
+
+ test "a malformed CEP never leaves the machine", %{server: server, client: client} do
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.lookup("123", client)
+ assert FakeServer.calls(server) == []
+ end
+
+ test "a 400 carries the API's own message", %{server: server, client: client} do
+ FakeServer.respond(
+ server,
+ "/00000000",
+ {400, json(%{"erro" => "cep_invalido", "mensagem" => "rejected by the API"}), []}
+ )
+
+ assert {:error, %Cepis.InvalidCEPError{} = error} = Cepis.lookup("00000000", client)
+ assert Exception.message(error) =~ "rejected by the API"
+ end
+
+ test "a 400 is never retried", %{server: server, client: client} do
+ FakeServer.respond(server, "/00000000", {400, json(%{"erro" => "cep_invalido"}), []})
+
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.lookup("00000000", client)
+ assert FakeServer.count(server, "/00000000") == 1
+ end
+
+ test "a captive portal answering instead of the API is diagnosed", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/01001000", {200, "<html>captive portal</html>", []})
+
+ assert {:error, %Cepis.InvalidResponseError{} = error} = Cepis.lookup("01001000", client)
+ assert Exception.message(error) =~ "captive portal"
+ end
+
+ test "a JSON body that is not an object is rejected", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {200, "[1,2,3]", []})
+
+ assert {:error, %Cepis.InvalidResponseError{}} = Cepis.lookup("01001000", client)
+ end
+
+ test "the bang variant returns the address and raises on failure", %{
+ server: server,
+ client: client
+ } do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture()), []})
+ assert %Cepis.Address{uf: "SP"} = Cepis.lookup!("01001000", client)
+
+ assert_raise Cepis.NotFoundError, fn -> Cepis.lookup!("99999999", client) end
+ end
+
+ test "keyword options work in place of a client struct", %{server: server} do
+ FakeServer.respond(server, "/01001000", {200, json(address_fixture()), []})
+
+ assert {:ok, address} = Cepis.lookup("01001000", base_url: server.url, timeout: 5_000)
+ assert address.localidade == "São Paulo"
+ end
+end
diff --git a/test/cepis/retry_test.exs b/test/cepis/retry_test.exs
new file mode 100644
index 0000000..3308ca4
--- /dev/null
+++ b/test/cepis/retry_test.exs
@@ -0,0 +1,90 @@
+defmodule Cepis.RetryTest do
+ use Cepis.ServerCase
+
+ test "a 500 is retried and the second attempt wins", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", [
+ {500, json(%{"erro" => "interno"}), []},
+ {200, json(address_fixture()), []}
+ ])
+
+ assert {:ok, address} = Cepis.lookup("01001000", client)
+ assert address.localidade == "São Paulo"
+ assert FakeServer.count(server, "/01001000") == 2
+ end
+
+ test "attempts are exhausted and then it gives up", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {503, json(%{"erro" => "interno"}), []})
+
+ assert {:error, %Cepis.ServerError{status: 503}} = Cepis.lookup("01001000", client)
+ assert FakeServer.count(server, "/01001000") == 3
+ end
+
+ test "max_attempts: 1 disables retrying", %{server: server} do
+ client = Cepis.Client.new(base_url: server.url, max_attempts: 1, timeout: 5_000)
+ FakeServer.respond(server, "/01001000", {500, "{}", []})
+
+ assert {:error, %Cepis.ServerError{status: 500}} = Cepis.lookup("01001000", client)
+ assert FakeServer.count(server, "/01001000") == 1
+ end
+
+ test "a 429 exposes Retry-After in milliseconds", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {429, "{}", [{"retry-after", "7"}]})
+
+ assert {:error, %Cepis.RateLimitError{retry_after: 7_000}} = Cepis.lookup("01001000", client)
+ end
+
+ test "a Retry-After HTTP date is understood", %{server: server, client: client} do
+ date =
+ DateTime.utc_now()
+ |> DateTime.add(30, :second)
+ |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT")
+
+ FakeServer.respond(server, "/01001000", {429, "{}", [{"retry-after", date}]})
+
+ assert {:error, %Cepis.RateLimitError{retry_after: retry_after}} =
+ Cepis.lookup("01001000", client)
+
+ assert retry_after > 20_000
+ assert retry_after <= 30_000
+ end
+
+ test "an absurd Retry-After cannot park the caller", %{server: server, client: client} do
+ FakeServer.respond(server, "/01001000", {429, "{}", [{"retry-after", "3600"}]})
+
+ {elapsed, result} = :timer.tc(fn -> Cepis.lookup("01001000", client) end, :millisecond)
+
+ assert {:error, %Cepis.RateLimitError{}} = result
+ assert elapsed < 2_000
+ end
+
+ test "nothing listening is a network error" do
+ client =
+ Cepis.Client.new(
+ base_url: "http://127.0.0.1:1",
+ max_attempts: 2,
+ backoff_base: 1,
+ timeout: 1_000
+ )
+
+ assert {:error, %Cepis.NetworkError{}} = Cepis.lookup("01001000", client)
+ end
+
+ test "total_timeout bounds the whole call", %{server: server} do
+ client =
+ Cepis.Client.new(
+ base_url: server.url,
+ max_attempts: 50,
+ backoff_base: 50,
+ backoff_max: 50,
+ total_timeout: 300
+ )
+
+ FakeServer.respond(server, "/01001000", {500, "{}", []})
+
+ {elapsed, result} = :timer.tc(fn -> Cepis.lookup("01001000", client) end, :millisecond)
+
+ assert {:error, %Cepis.ServerError{}} = result
+ assert elapsed < 3_000
+ assert FakeServer.count(server, "/01001000") < 50
+ end
+end
diff --git a/test/cepis_test.exs b/test/cepis_test.exs
new file mode 100644
index 0000000..a6bedb1
--- /dev/null
+++ b/test/cepis_test.exs
@@ -0,0 +1,79 @@
+defmodule CepisTest do
+ use ExUnit.Case, async: true
+
+ doctest Cepis
+ doctest Cepis.Client
+
+ describe "normalize_cep/1" do
+ test "accepts the mask and the bare digits" do
+ for value <- ["01001000", "01001-000", " 01001-000 ", "01001.000"] do
+ assert Cepis.normalize_cep(value) == {:ok, "01001000"}
+ end
+ end
+
+ test "rejects anything that is not 8 digits" do
+ for value <- ["", "123", "0100100a", "010010000", "abcdefgh"] do
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.normalize_cep(value)
+ end
+ end
+
+ test "rejects a value that is not a string" do
+ assert {:error, %Cepis.InvalidCEPError{}} = Cepis.normalize_cep(1_001_000)
+ end
+
+ test "the bang variant raises" do
+ assert_raise Cepis.InvalidCEPError, fn -> Cepis.normalize_cep!("123") end
+ end
+ end
+
+ describe "Cepis.Client.new/1" do
+ test "strips a trailing slash so separators cannot double up" do
+ assert Cepis.Client.new(base_url: "http://x/").base_url == "http://x"
+ assert Cepis.Client.new(base_url: "http://x//").base_url == "http://x"
+ end
+
+ test "rejects an impossible configuration" do
+ assert_raise ArgumentError, fn -> Cepis.Client.new(max_attempts: 0) end
+ assert_raise ArgumentError, fn -> Cepis.Client.new(timeout: 0) end
+ end
+
+ test "rejects an unknown option" do
+ assert_raise KeyError, fn -> Cepis.Client.new(retries: 3) end
+ end
+
+ test "the default user agent identifies the library" do
+ assert Cepis.Client.new([]).user_agent =~ "cepis-elixir/"
+ end
+
+ test "an already built client passes through untouched" do
+ client = Cepis.Client.new(base_url: "http://x")
+ assert Cepis.Client.new(client) == client
+ end
+ end
+
+ describe "Cepis.Error" do
+ test "splits retriable transport failures from definitive answers" do
+ assert Cepis.Error.retriable?(%Cepis.ServerError{status: 500})
+ assert Cepis.Error.retriable?(%Cepis.RateLimitError{})
+ assert Cepis.Error.retriable?(%Cepis.NetworkError{})
+ refute Cepis.Error.retriable?(%Cepis.NotFoundError{})
+ refute Cepis.Error.retriable?(%Cepis.InvalidCEPError{})
+ end
+
+ test "recognises its own errors" do
+ assert Cepis.Error.error?(%Cepis.NotFoundError{})
+ refute Cepis.Error.error?(%RuntimeError{})
+ refute Cepis.Error.error?(:oops)
+ end
+ end
+
+ describe "Cepis.Address.formatted_cep/1" do
+ test "adds the mask" do
+ assert Cepis.Address.formatted_cep(%Cepis.Address{cep: "01001000"}) == "01001-000"
+ end
+
+ test "leaves an unexpected value alone" do
+ assert Cepis.Address.formatted_cep(%Cepis.Address{cep: ""}) == ""
+ end
+ end
+end
diff --git a/test/support/fake_server.ex b/test/support/fake_server.ex
new file mode 100644
index 0000000..bd1fc5a
--- /dev/null
+++ b/test/support/fake_server.ex
@@ -0,0 +1,154 @@
+defmodule Cepis.FakeServer do
+ @moduledoc false
+
+ defstruct [:pid, :agent, :port, :url]
+
+ @type t :: %__MODULE__{pid: pid(), agent: pid(), port: :inet.port_number(), url: String.t()}
+
+ @listen_options [
+ :binary,
+ ip: {127, 0, 0, 1},
+ packet: :http_bin,
+ packet_size: 1_048_576,
+ active: false,
+ reuseaddr: true,
+ backlog: 128
+ ]
+
+ @not_found {404, ~s({"erro":"cep_nao_encontrado","mensagem":"CEP não existe na base do DNE"}),
+ []}
+
+ def start do
+ {:ok, agent} = Agent.start_link(fn -> %{routes: %{}, calls: [], handler: nil} end)
+ parent = self()
+
+ pid =
+ spawn_link(fn ->
+ {:ok, listen} = :gen_tcp.listen(0, @listen_options)
+ {:ok, port} = :inet.port(listen)
+ send(parent, {:listening, self(), port})
+ accept_loop(listen, agent)
+ end)
+
+ port =
+ receive do
+ {:listening, ^pid, port} -> port
+ after
+ 5_000 -> raise "fake server did not start"
+ end
+
+ %__MODULE__{pid: pid, agent: agent, port: port, url: "http://127.0.0.1:#{port}"}
+ end
+
+ def stop(%__MODULE__{} = server) do
+ Process.exit(server.pid, :kill)
+ Process.exit(server.agent, :kill)
+ :ok
+ end
+
+ def respond(%__MODULE__{agent: agent}, path, response) do
+ Agent.update(agent, fn state -> put_in(state.routes[path], response) end)
+ end
+
+ def on_request(%__MODULE__{agent: agent}, fun) when is_function(fun, 2) do
+ Agent.update(agent, fn state -> %{state | handler: fun} end)
+ end
+
+ def calls(%__MODULE__{agent: agent}) do
+ Agent.get(agent, & &1.calls)
+ end
+
+ def count(%__MODULE__{} = server, prefix) do
+ server |> calls() |> Enum.count(&String.starts_with?(&1, prefix))
+ end
+
+ defp accept_loop(listen, agent) do
+ case :gen_tcp.accept(listen) do
+ {:ok, socket} ->
+ worker = spawn(fn -> serve(socket, agent) end)
+ :gen_tcp.controlling_process(socket, worker)
+ send(worker, :ready)
+ accept_loop(listen, agent)
+
+ {:error, _reason} ->
+ :ok
+ end
+ end
+
+ defp serve(socket, agent) do
+ receive do
+ :ready -> :ok
+ after
+ 5_000 -> :ok
+ end
+
+ with {:ok, path} <- read_request(socket) do
+ {status, body, headers} = dispatch(agent, path)
+ :gen_tcp.send(socket, encode(status, body, headers))
+ end
+
+ :gen_tcp.close(socket)
+ end
+
+ defp read_request(socket) do
+ case :gen_tcp.recv(socket, 0, 5_000) do
+ {:ok, {:http_request, _method, {:abs_path, path}, _version}} ->
+ with :ok <- drain_headers(socket), do: {:ok, path}
+
+ other ->
+ other
+ end
+ end
+
+ defp drain_headers(socket) do
+ case :gen_tcp.recv(socket, 0, 5_000) do
+ {:ok, :http_eoh} -> :ok
+ {:ok, {:http_header, _length, _field, _reserved, _value}} -> drain_headers(socket)
+ other -> other
+ end
+ end
+
+ defp dispatch(agent, path) do
+ Agent.get_and_update(agent, fn state ->
+ state = %{state | calls: state.calls ++ [path]}
+ select(state, path)
+ end)
+ end
+
+ defp select(%{handler: handler} = state, path) when is_function(handler, 2) do
+ {handler.(path, length(state.calls)), state}
+ end
+
+ defp select(state, path) do
+ case Map.get(state.routes, path) do
+ nil -> {@not_found, state}
+ [single] -> {single, state}
+ [next | rest] -> {next, put_in(state.routes[path], rest)}
+ response -> {response, state}
+ end
+ end
+
+ defp encode(status, body, headers) do
+ payload = IO.iodata_to_binary(body)
+
+ head = [
+ "HTTP/1.1 #{status} #{reason(status)}\r\n",
+ Enum.map(headers, fn {key, value} -> "#{key}: #{value}\r\n" end),
+ "content-type: text/plain; charset=utf-8\r\n",
+ "content-length: #{byte_size(payload)}\r\n",
+ "connection: close\r\n\r\n"
+ ]
+
+ IO.iodata_to_binary([head, payload])
+ end
+
+ defp reason(200), do: "OK"
+ defp reason(400), do: "Bad Request"
+ defp reason(404), do: "Not Found"
+ defp reason(429), do: "Too Many Requests"
+ defp reason(500), do: "Internal Server Error"
+ defp reason(502), do: "Bad Gateway"
+ defp reason(503), do: "Service Unavailable"
+ defp reason(504), do: "Gateway Timeout"
+ defp reason(_status), do: "Status"
+end
diff --git a/test/support/server_case.ex b/test/support/server_case.ex
new file mode 100644
index 0000000..a64bb05
--- /dev/null
+++ b/test/support/server_case.ex
@@ -0,0 +1,52 @@
+defmodule Cepis.ServerCase do
+ @moduledoc false
+
+ use ExUnit.CaseTemplate
+
+ using do
+ quote do
+ import Cepis.ServerCase
+
+ alias Cepis.FakeServer
+ end
+ end
+
+ setup do
+ server = Cepis.FakeServer.start()
+ on_exit(fn -> Cepis.FakeServer.stop(server) end)
+
+ client =
+ Cepis.Client.new(
+ base_url: server.url,
+ timeout: 5_000,
+ backoff_base: 1,
+ backoff_max: 10
+ )
+
+ %{server: server, client: client}
+ end
+
+ @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"
+ }
+
+ @csv_header "cep,logradouro,complemento,unidade,bairro,localidade,uf,estado,regiao,ibge,gia,ddd,siafi"
+
+ def address_fixture(extra \\ %{}), do: Map.merge(@address, extra)
+
+ def json(term), do: IO.iodata_to_binary(:json.encode(term))
+
+ def csv(rows), do: Enum.join([@csv_header | rows], "\n") <> "\n"
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
new file mode 100644
index 0000000..869559e
--- /dev/null
+++ b/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()