diff --git a/.gitignore b/.gitignore index ecab88a..6d48b80 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ pluggy_elixir-*.tar # Temporary files for e.g. tests /tmp + +.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3e9d2..2314610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,5 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Get all webhooks +- Get transactions by account [unreleased]: https://github.com/brainnco/strong_params/compare/main diff --git a/lib/pluggy_elixir/http_adapter/tesla.ex b/lib/pluggy_elixir/http_adapter/tesla.ex index 2c3e622..abf1710 100644 --- a/lib/pluggy_elixir/http_adapter/tesla.ex +++ b/lib/pluggy_elixir/http_adapter/tesla.ex @@ -1,70 +1,72 @@ -defmodule PluggyElixir.HttpAdapter.Tesla do - @moduledoc """ - Implements `PluggyElixir.HttpAdapter` behaviour using `Tesla`. - """ - alias PluggyElixir.Config - alias PluggyElixir.HttpAdapter.Response +if Code.ensure_loaded?(Tesla) do + defmodule PluggyElixir.HttpAdapter.Tesla do + @moduledoc """ + Implements `PluggyElixir.HttpAdapter` behaviour using `Tesla`. + """ + alias PluggyElixir.Config + alias PluggyElixir.HttpAdapter.Response - @behaviour PluggyElixir.HttpAdapter + @behaviour PluggyElixir.HttpAdapter - @impl true - def post(url, body, query \\ [], headers \\ [], %Config{} = config) do - config - |> build_client() - |> Tesla.post(url, body, build_options(query, headers, config)) - |> format_response() - end + @impl true + def post(url, body, query \\ [], headers \\ [], %Config{} = config) do + config + |> build_client() + |> Tesla.post(url, body, build_options(query, headers, config)) + |> format_response() + end - @impl true - def get(url, query \\ [], headers \\ [], %Config{} = config) do - config - |> build_client() - |> Tesla.get(url, build_options(query, headers, config)) - |> format_response() - end + @impl true + def get(url, query \\ [], headers \\ [], %Config{} = config) do + config + |> build_client() + |> Tesla.get(url, build_options(query, headers, config)) + |> format_response() + end - @impl true - def patch(url, body, query \\ [], headers \\ [], %Config{} = config) do - config - |> build_client() - |> Tesla.patch(url, body, build_options(query, headers, config)) - |> format_response() - end + @impl true + def patch(url, body, query \\ [], headers \\ [], %Config{} = config) do + config + |> build_client() + |> Tesla.patch(url, body, build_options(query, headers, config)) + |> format_response() + end - defp build_options(query, headers, %{sandbox: sandbox}) do - [ - query: build_query(query, sandbox), - headers: headers - ] - end + defp build_options(query, headers, %{sandbox: sandbox}) do + [ + query: build_query(query, sandbox), + headers: headers + ] + end - defp build_query(query, true), do: Keyword.merge(query, sandbox: true) - defp build_query(query, _false), do: query + defp build_query(query, true), do: Keyword.merge(query, sandbox: true) + defp build_query(query, _false), do: query - defp build_client(config) do - Tesla.client( - [ - {Tesla.Middleware.BaseUrl, host_uri(config)}, - {Tesla.Middleware.Headers, [{"content-type", "application/json"}]}, - Tesla.Middleware.JSON - ], - get_tesla_adapter(config) - ) - end + defp build_client(config) do + Tesla.client( + [ + {Tesla.Middleware.BaseUrl, host_uri(config)}, + {Tesla.Middleware.Headers, [{"content-type", "application/json"}]}, + Tesla.Middleware.JSON + ], + get_tesla_adapter(config) + ) + end - defp format_response({:ok, %Tesla.Env{body: body, headers: headers, status: status}}), - do: {:ok, %Response{body: body, headers: headers, status: status}} + defp format_response({:ok, %Tesla.Env{body: body, headers: headers, status: status}}), + do: {:ok, %Response{body: body, headers: headers, status: status}} - defp format_response({:error, reason}) when is_atom(reason), - do: {:error, Atom.to_string(reason)} + defp format_response({:error, reason}) when is_atom(reason), + do: {:error, Atom.to_string(reason)} - defp format_response({:error, {Tesla.Middleware.JSON, :decode, _error}}), - do: {:error, "response body is not a valid JSON"} + defp format_response({:error, {Tesla.Middleware.JSON, :decode, %{data: invalid_json}}}), + do: {:error, %{message: "response body is not a valid JSON", details: invalid_json}} - defp format_response({:error, reason}), do: {:error, inspect(reason)} + defp format_response({:error, reason}), do: {:error, inspect(reason)} - defp get_tesla_adapter(%{adapter: %{configs: adapter_config}}), - do: Keyword.fetch!(adapter_config, :adapter) + defp get_tesla_adapter(%{adapter: %{configs: adapter_config}}), + do: Keyword.fetch!(adapter_config, :adapter) - defp host_uri(%{host: host}), do: to_string(host) + defp host_uri(%{host: host}), do: to_string(host) + end end diff --git a/lib/pluggy_elixir/http_client.ex b/lib/pluggy_elixir/http_client.ex index 88991f8..20f36ac 100644 --- a/lib/pluggy_elixir/http_client.ex +++ b/lib/pluggy_elixir/http_client.ex @@ -91,5 +91,8 @@ defmodule PluggyElixir.HttpClient do defp handle_result({:ok, %Response{} = response}), do: {:error, Error.parse(response)} + defp handle_result({:error, %{message: message, details: details}}), + do: {:error, %Error{code: 500, message: message, details: details}} + defp handle_result({:error, _reason} = error), do: error end diff --git a/lib/pluggy_elixir/test.ex b/lib/pluggy_elixir/test.ex new file mode 100644 index 0000000..d131387 --- /dev/null +++ b/lib/pluggy_elixir/test.ex @@ -0,0 +1,95 @@ +defmodule PluggyElixir.Test do + @moduledoc """ + Support module to crate test form `PluggyElixir` + """ + alias PluggyElixir.Auth.Guard + + def create_and_save_api_key do + auth = %PluggyElixir.Auth{api_key: "generated_api_key_#{:rand.uniform()}"} + + Guard.set_auth(auth) + + auth.api_key + end + + if Code.ensure_loaded?(Bypass) do + alias Plug.Conn + + def bypass_expect(bypass, method, url, mock_func) do + caller = self() + + Bypass.expect(bypass, method, url, fn conn -> + conn + |> validate_content_type() + |> Conn.read_body() + |> json_decode() + |> Conn.put_resp_header("content-type", "application/json") + |> notify_caller(caller) + |> mock_func.() + end) + end + + defmacro assert_pluggy(value, timeout \\ 3000) do + assertion = + if(is_fn(value), + do: quote(do: unquote(value).(conn)), + else: quote(do: assert(unquote(value) = conn)) + ) + + quote do + receive do + {:bypass, conn} -> + unquote(assertion) + after + unquote(timeout) -> raise("Bypass message not received") + end + end + end + + defp is_fn({:fn, _line, _code}), do: true + defp is_fn(_any), do: false + + defp validate_content_type(conn) do + conn + |> Conn.get_req_header("content-type") + |> case do + ["application/json" <> _tail] -> conn + _other -> custom_raise("Pluggy API requires content-type: application/json header") + end + end + + defp json_decode({:ok, body, conn}) do + body + |> case do + "" -> {:ok, %{}} + json -> Jason.decode(json) + end + |> add_body(conn) + end + + defp add_body({:ok, body}, conn) when is_map(body) do + %{conn | body_params: body, params: Map.merge(conn.params, body)} + end + + defp add_body({:error, reason}, _conn) do + custom_raise( + "Pluggy API just accept body with JSON format. The Jason.decode/1 failed with #{ + inspect(reason) + }" + ) + end + + defp notify_caller(%Conn{} = conn, caller) do + Process.send( + caller, + {:bypass, + Map.take(conn, [:params, :body_params, :query_params, :path_params, :req_headers])}, + [] + ) + + conn + end + + defp custom_raise(msg), do: raise("\n\n\n>>>>>> Bypass error:\n\n#{msg}\n\n<<<<<<\n\n\n") + end +end diff --git a/lib/pluggy_elixir/test/bypass.ex b/lib/pluggy_elixir/test/bypass.ex new file mode 100644 index 0000000..e69de29 diff --git a/lib/pluggy_elixir/transaction.ex b/lib/pluggy_elixir/transaction.ex new file mode 100644 index 0000000..0c3b9ee --- /dev/null +++ b/lib/pluggy_elixir/transaction.ex @@ -0,0 +1,214 @@ +defmodule PluggyElixir.Transaction do + @moduledoc """ + Handle transactions actions. + """ + + alias PluggyElixir.{Config, HttpClient} + + defstruct [ + :id, + :description, + :description_raw, + :currency_code, + :amount, + :date, + :balance, + :category, + :account_id, + :provider_code, + :status, + :payment_data + ] + + @type t :: %__MODULE__{ + id: binary(), + description: binary(), + description_raw: binary(), + currency_code: binary(), + amount: float(), + date: NaiveDateTime.t(), + balance: float(), + category: binary(), + account_id: binary(), + provider_code: binary(), + status: binary(), + payment_data: payment_data() + } + + @type payment_data :: %{ + payer: identity(), + receiver: identity(), + reason: binary(), + payment_method: binary(), + reference_number: binary() + } + + @type identity :: %{ + type: binary(), + branch_number: binary(), + account_number: binary(), + routing_number: binary(), + document_number: document() + } + + @type document :: %{ + type: binary(), + value: binary() + } + + @transactions_path "/transactions" + @default_page_size 20 + @default_page_number 1 + + @doc """ + List transactions supporting filters (by account and period) and pagination. + + ### Examples + + iex> params = %{ + account_id: "03cc0eff-4ec5-495c-adb3-1ef9611624fc", + from: ~D[2021-04-01], + to: ~D[2021-05-01], + page_size: 100, + page: 1 + } + iex> Transaction.all_by_account(params) + {:ok, + %{ + page: 1, + total: 1, + total_pages: 1, + transactions: [ + %Transaction{ + account_id: "03cc0eff-4ec5-495c-adb3-1ef9611624fc", + amount: 1_500, + balance: 3_000, + category: "Transfer", + currency_code: "BRL", + date: ~N[2021-04-12 00:00:00.000], + description: "TED Example", + description_raw: nil, + id: "6ec156fe-e8ac-4d9a-a4b3-7770529ab01c", + payment_data: %{ + payer: %{ + account_number: "1234-5", + branch_number: "090", + document_number: %{ + type: "CPF", + value: "882.937.076-23" + }, + routing_number: "001", + type: nil + }, + payment_method: "TED", + reason: "Taxa de serviço", + receiver: %{ + account_number: "9876-1", + branch_number: "999", + document_number: %{ + type: "CNPJ", + value: "08.050.608/0001-32" + }, + routing_number: "002", + type: nil + }, + reference_number: "123456789" + }, + provider_code: "123", + status: "POSTED" + } + ] + }} + """ + + @spec all_by_account( + %{ + :account_id => String.t(), + :from => Date.t(), + :to => Date.t(), + optional(:page_size) => integer(), + optional(:page) => integer() + }, + Config.config_overrides() + ) :: + {:ok, %{page: integer(), total_pages: integer(), total: integer(), transactions: [t()]}} + | {:error, PluggyElixir.HttpClient.Error.t() | String.t()} + def all_by_account(params, config_overrides \\ []) + + def all_by_account(%{account_id: _, from: _, to: _} = params, config_overrides) do + @transactions_path + |> HttpClient.get(format_params(params), Config.override(config_overrides)) + |> handle_response + end + + def all_by_account(_params, _config_overrides), + do: {:error, ":account_id, :from, and :to are required"} + + defp handle_response({:ok, %{status: 200, body: body}}) do + result = %{ + page: body["page"], + total_pages: body["totalPages"], + total: body["total"], + transactions: Enum.map(body["results"], &parse_transaction/1) + } + + {:ok, result} + end + + defp handle_response({:error, _reason} = error), do: error + + defp format_params(params) do + [ + accountId: params[:account_id], + from: params[:from], + to: params[:to], + pageSize: Map.get(params, :page_size, @default_page_size), + page: Map.get(params, :page, @default_page_number) + ] + end + + defp parse_transaction(transaction) do + %__MODULE__{ + id: transaction["id"], + description: transaction["description"], + description_raw: transaction["descriptionRaw"], + currency_code: transaction["currencyCode"], + amount: parse_float(transaction["amount"]), + date: NaiveDateTime.from_iso8601!(transaction["date"]), + balance: parse_float(transaction["balance"]), + category: transaction["category"], + account_id: transaction["accountId"], + provider_code: transaction["providerCode"], + status: transaction["status"], + payment_data: parse_payment_data(transaction["paymentData"]) + } + end + + defp parse_payment_data(nil), do: nil + + defp parse_payment_data(payment_data) do + %{ + payer: parse_identity(payment_data["payer"]), + receiver: parse_identity(payment_data["receiver"]), + reason: payment_data["reason"], + payment_method: payment_data["paymentMethod"], + reference_number: payment_data["referenceNumber"] + } + end + + defp parse_identity(identity) do + %{ + type: identity["type"], + branch_number: identity["branchNumber"], + account_number: identity["accountNumber"], + routing_number: identity["routingNumber"], + document_number: %{ + type: get_in(identity, ["documentNumber", "type"]), + value: get_in(identity, ["documentNumber", "value"]) + } + } + end + + defp parse_float(number) when is_float(number), do: number + defp parse_float(number), do: "#{number}" |> Float.parse() |> elem(0) +end diff --git a/mix.exs b/mix.exs index 1a8b7e3..8aed18b 100644 --- a/mix.exs +++ b/mix.exs @@ -77,7 +77,10 @@ defmodule PluggyElixir.MixProject do PluggyElixir.HttpAdapter.Tesla ], Errors: [ - ~r/PluggyElixir\.Error\..*/ + PluggyElixir.HttpClient.Error + ], + Test: [ + PluggyElixir.Test ] ] ] diff --git a/test/pluggy_elixir/http_adapter/tesla_test.exs b/test/pluggy_elixir/http_adapter/tesla_test.exs index 55d152c..b35a679 100644 --- a/test/pluggy_elixir/http_adapter/tesla_test.exs +++ b/test/pluggy_elixir/http_adapter/tesla_test.exs @@ -12,8 +12,6 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "POST", url, fn conn -> - assert conn.body_params == body - conn |> Conn.put_resp_header("custom-header", "custom-value") |> Conn.resp(200, ~s<{"message": "ok"}>) @@ -21,6 +19,8 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do response = Tesla.post(url, body, [], config_overrides) + assert_pluggy(%{body_params: ^body}) + assert {:ok, %Response{ status: 200, @@ -39,12 +39,11 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "POST", url, fn conn -> - assert conn.query_params == %{"sandbox" => "true", "custom" => "custom-value"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.post(url, %{}, query, config_overrides) + assert_pluggy(%{query_params: %{"sandbox" => "true", "custom" => "custom-value"}}) end test "sending custom headers", %{bypass: bypass} do @@ -53,12 +52,14 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "POST", url, fn conn -> - assert Enum.any?(headers, fn header -> [header] == headers end) == true - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.post(url, %{}, [], headers, config_overrides) + + assert_pluggy(fn %{req_headers: req_headers} -> + assert Enum.any?(req_headers, fn h -> [h] == headers end) + end) end test "send query sandbox as true when sandbox is configured", %{bypass: bypass} do @@ -66,12 +67,11 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}", sandbox: true) bypass_expect(bypass, "POST", url, fn conn -> - assert conn.query_params == %{"sandbox" => "true"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.post(url, %{}, [], config_overrides) + assert_pluggy(%{query_params: %{"sandbox" => "true"}}) end test "don't send query sandbox when sandbox is configured to false", %{bypass: bypass} do @@ -79,12 +79,14 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}", sandbox: false) bypass_expect(bypass, "POST", url, fn conn -> - assert conn.query_params == %{} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.post(url, %{}, [], config_overrides) + + assert_pluggy(fn conn -> + assert conn.query_params == %{} + end) end test "return success even when response status is a client error", %{bypass: bypass} do @@ -116,11 +118,12 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "POST", url, fn conn -> - Conn.resp(conn, 200, ~s) + Conn.resp(conn, 200, ~s) end) assert Tesla.post(url, %{}, [], config_overrides) == - {:error, "response body is not a valid JSON"} + {:error, + %{message: "response body is not a valid JSON", details: "plain text response"}} end test "return error when server is down", %{bypass: bypass} do @@ -170,12 +173,11 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "GET", url, fn conn -> - assert conn.query_params == %{"sandbox" => "true", "custom" => "custom-value"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.get(url, query, config_overrides) + assert_pluggy(%{query_params: %{"sandbox" => "true", "custom" => "custom-value"}}) end test "sending custom headers", %{bypass: bypass} do @@ -184,12 +186,14 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "GET", url, fn conn -> - assert Enum.any?(headers, fn header -> [header] == headers end) == true - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.get(url, [], headers, config_overrides) + + assert_pluggy(fn %{req_headers: req_headers} -> + assert Enum.any?(req_headers, fn h -> [h] == headers end) + end) end test "send query sandbox as true when sandbox is configured", %{bypass: bypass} do @@ -197,12 +201,12 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}", sandbox: true) bypass_expect(bypass, "GET", url, fn conn -> - assert conn.query_params == %{"sandbox" => "true"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.get(url, config_overrides) + + assert_pluggy(%{query_params: %{"sandbox" => "true"}}) end test "don't send query sandbox when sandbox is configured to false", %{bypass: bypass} do @@ -210,12 +214,14 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}", sandbox: false) bypass_expect(bypass, "GET", url, fn conn -> - assert conn.query_params == %{} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.get(url, config_overrides) + + assert_pluggy(fn conn -> + assert conn.query_params == %{} + end) end test "return success even when response status is a client error", %{bypass: bypass} do @@ -247,10 +253,12 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "GET", url, fn conn -> - Conn.resp(conn, 200, ~s) + Conn.resp(conn, 200, ~s) end) - assert Tesla.get(url, config_overrides) == {:error, "response body is not a valid JSON"} + assert Tesla.get(url, config_overrides) == + {:error, + %{message: "response body is not a valid JSON", details: "plain text response"}} end test "return error when server is down", %{bypass: bypass} do @@ -278,8 +286,6 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "PATCH", url, fn conn -> - assert conn.body_params == body - conn |> Conn.put_resp_header("custom-header", "custom-value") |> Conn.resp(200, ~s<{"message": "ok"}>) @@ -287,6 +293,8 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do response = Tesla.patch(url, body, [], config_overrides) + assert_pluggy(%{body_params: ^body}) + assert {:ok, %Response{ status: 200, @@ -305,12 +313,11 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "PATCH", url, fn conn -> - assert conn.query_params == %{"sandbox" => "true", "custom" => "custom-value"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.patch(url, %{}, query, config_overrides) + assert_pluggy(%{query_params: %{"sandbox" => "true", "custom" => "custom-value"}}) end test "sending custom headers", %{bypass: bypass} do @@ -319,12 +326,14 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "PATCH", url, fn conn -> - assert Enum.any?(headers, fn header -> [header] == headers end) == true - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.patch(url, %{}, [], headers, config_overrides) + + assert_pluggy(fn %{req_headers: req_headers} -> + assert Enum.any?(req_headers, fn h -> [h] == headers end) + end) end test "send query sandbox as true when sandbox is configured", %{bypass: bypass} do @@ -332,12 +341,12 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}", sandbox: true) bypass_expect(bypass, "PATCH", url, fn conn -> - assert conn.query_params == %{"sandbox" => "true"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.patch(url, %{}, [], config_overrides) + + assert_pluggy(%{query_params: %{"sandbox" => "true"}}) end test "don't send query sandbox when sandbox is configured to false", %{bypass: bypass} do @@ -345,12 +354,14 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}", sandbox: false) bypass_expect(bypass, "PATCH", url, fn conn -> - assert conn.query_params == %{} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) assert {:ok, %Response{}} = Tesla.patch(url, %{}, [], config_overrides) + + assert_pluggy(fn conn -> + assert conn.query_params == %{} + end) end test "return success even when response status is a client error", %{bypass: bypass} do @@ -382,11 +393,12 @@ defmodule PluggyElixir.HttpAdapter.TeslaTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "PATCH", url, fn conn -> - Conn.resp(conn, 200, ~s) + Conn.resp(conn, 200, ~s) end) assert Tesla.patch(url, %{}, [], config_overrides) == - {:error, "response body is not a valid JSON"} + {:error, + %{message: "response body is not a valid JSON", details: "plain text response"}} end test "return error when server is down", %{bypass: bypass} do diff --git a/test/pluggy_elixir/http_client_test.exs b/test/pluggy_elixir/http_client_test.exs index 7d280bc..fe01e89 100644 --- a/test/pluggy_elixir/http_client_test.exs +++ b/test/pluggy_elixir/http_client_test.exs @@ -17,10 +17,8 @@ defmodule PluggyElixir.HttpClientTest do Conn.resp(conn, 200, ~s<{"apiKey": "#{created_api_key}"}>) end) - bypass_expect(bypass, "GET", url, fn conn -> - assert Enum.any?(conn.req_headers, fn header -> - header == {"x-api-key", created_api_key} - end) + bypass_expect(bypass, "GET", url, fn %{req_headers: headers} = conn -> + assert Enum.any?(headers, &(&1 == {"x-api-key", created_api_key})) Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) @@ -62,15 +60,15 @@ defmodule PluggyElixir.HttpClientTest do Guard.set_auth(%Auth{api_key: created_api_key}) bypass_expect(bypass, "GET", url, fn conn -> - assert Enum.any?(conn.req_headers, fn header -> - header == {"x-api-key", created_api_key} - end) - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) response = HttpClient.get(url, config_overrides) + assert_pluggy(fn %{req_headers: headers} -> + assert Enum.any?(headers, &(&1 == {"x-api-key", created_api_key})) + end) + assert {:ok, %Response{}} = response end @@ -134,13 +132,12 @@ defmodule PluggyElixir.HttpClientTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "GET", url, fn conn -> - assert conn.query_params == %{"custom" => "custom-value", "sandbox" => "true"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) response = HttpClient.get(url, query, config_overrides) + assert_pluggy(%{query_params: %{"custom" => "custom-value", "sandbox" => "true"}}) assert {:ok, %Response{status: 200, body: %{"message" => "ok"}}} = response end end @@ -156,14 +153,16 @@ defmodule PluggyElixir.HttpClientTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "POST", url, fn conn -> - assert conn.query_params == %{"custom" => "custom-value", "sandbox" => "true"} - assert conn.body_params == %{"key" => "value"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) response = HttpClient.post(url, body, query, config_overrides) + assert_pluggy(%{ + query_params: %{"custom" => "custom-value", "sandbox" => "true"}, + body_params: %{"key" => "value"} + }) + assert {:ok, %Response{status: 200, body: %{"message" => "ok"}}} = response end end @@ -179,14 +178,16 @@ defmodule PluggyElixir.HttpClientTest do config_overrides = Config.override(host: "http://localhost:#{bypass.port}") bypass_expect(bypass, "PATCH", url, fn conn -> - assert conn.query_params == %{"custom" => "custom-value", "sandbox" => "true"} - assert conn.body_params == %{"key" => "value"} - Conn.resp(conn, 200, ~s<{"message": "ok"}>) end) response = HttpClient.patch(url, body, query, config_overrides) + assert_pluggy(%{ + query_params: %{"custom" => "custom-value", "sandbox" => "true"}, + body_params: %{"key" => "value"} + }) + assert {:ok, %Response{status: 200, body: %{"message" => "ok"}}} = response end end @@ -207,5 +208,33 @@ defmodule PluggyElixir.HttpClientTest do assert response == {:error, %Error{message: "Forbidden", code: 403}} end + + test "handle non JSON response errors", %{bypass: bypass} do + create_and_save_api_key() + + url = "/transactions" + + config_overrides = Config.override(host: "http://localhost:#{bypass.port}") + + bypass_expect(bypass, "GET", url, fn conn -> + Conn.resp( + conn, + 500, + ~s[Error
Internal Server Error
] + ) + end) + + response = HttpClient.get(url, config_overrides) + + assert response == { + :error, + %Error{ + code: 500, + message: "response body is not a valid JSON", + details: + ~s[Error
Internal Server Error
] + } + } + end end end diff --git a/test/pluggy_elixir/transaction_test.exs b/test/pluggy_elixir/transaction_test.exs new file mode 100644 index 0000000..140fb53 --- /dev/null +++ b/test/pluggy_elixir/transaction_test.exs @@ -0,0 +1,218 @@ +defmodule PluggyElixir.TransactionTest do + use PluggyElixir.Case + + alias PluggyElixir.HttpClient.Error + alias PluggyElixir.Transaction + + describe "all_by_account/4" do + test "list transactions of an account and period", %{bypass: bypass} do + params = %{ + account_id: "d619cfde-a8d7-4fe0-a10d-6de488bde4e0", + from: ~D[2020-01-01], + to: ~D[2020-02-01] + } + + config_overrides = [host: "http://localhost:#{bypass.port}"] + + create_and_save_api_key() + + bypass_expect(bypass, "GET", "/transactions", fn conn -> + Conn.resp( + conn, + 200, + ~s<{"total": 3, "totalPages": 1, "page": 1, "results": [{"id": "5d6b9f9a-06aa-491f-926a-15ba46c6366d", "accountId": "03cc0eff-4ec5-495c-adb3-1ef9611624fc", "description": "Rappi", "currencyCode": "BRL", "amount": 41.58, "date": "2020-06-08T00:00:00.000Z", "balance": 41.58, "category": "Online Payment", "status": "POSTED"}, {"id": "6ec156fe-e8ac-4d9a-a4b3-7770529ab01c", "description": "TED Example", "descriptionRaw": null, "currencyCode": "BRL", "amount": 1500, "date": "2021-04-12T00:00:00.000Z", "balance": 3000, "category": "Transfer", "accountId": "03cc0eff-4ec5-495c-adb3-1ef9611624fc", "providerCode": "123", "status": "POSTED", "paymentData": {"payer": {"name": "Tiago Rodrigues Santos", "branchNumber": "090", "accountNumber": "1234-5", "routingNumber": "001", "documentNumber": {"type": "CPF", "value": "882.937.076-23"}}, "reason": "Taxa de serviço", "receiver": {"name": "Pluggy", "branchNumber": "999", "accountNumber": "9876-1", "routingNumber": "002", "documentNumber": {"type": "CNPJ", "value": "08.050.608/0001-32"}}, "paymentMethod": "TED", "referenceNumber": "123456789"}}]} > + ) + end) + + assert {:ok, result} = Transaction.all_by_account(params, config_overrides) + + assert_pluggy(%{ + query_params: %{ + "accountId" => "d619cfde-a8d7-4fe0-a10d-6de488bde4e0", + "from" => "2020-01-01", + "sandbox" => "true", + "to" => "2020-02-01", + "pageSize" => "20", + "page" => "1" + } + }) + + assert result == %{ + page: 1, + total: 3, + total_pages: 1, + transactions: [ + %Transaction{ + account_id: "03cc0eff-4ec5-495c-adb3-1ef9611624fc", + amount: 41.58, + balance: 41.58, + category: "Online Payment", + currency_code: "BRL", + date: ~N[2020-06-08 00:00:00.000], + description: "Rappi", + description_raw: nil, + id: "5d6b9f9a-06aa-491f-926a-15ba46c6366d", + payment_data: nil, + provider_code: nil, + status: "POSTED" + }, + %Transaction{ + account_id: "03cc0eff-4ec5-495c-adb3-1ef9611624fc", + amount: 1_500, + balance: 3_000, + category: "Transfer", + currency_code: "BRL", + date: ~N[2021-04-12 00:00:00.000], + description: "TED Example", + description_raw: nil, + id: "6ec156fe-e8ac-4d9a-a4b3-7770529ab01c", + payment_data: %{ + payer: %{ + account_number: "1234-5", + branch_number: "090", + document_number: %{ + type: "CPF", + value: "882.937.076-23" + }, + routing_number: "001", + type: nil + }, + payment_method: "TED", + reason: "Taxa de serviço", + receiver: %{ + account_number: "9876-1", + branch_number: "999", + document_number: %{ + type: "CNPJ", + value: "08.050.608/0001-32" + }, + routing_number: "002", + type: nil + }, + reference_number: "123456789" + }, + provider_code: "123", + status: "POSTED" + } + ] + } + end + + test "allow custom pagination", %{bypass: bypass} do + params = %{ + account_id: "d619cfde-a8d7-4fe0-a10d-6de488bde4e0", + from: ~D[2020-01-01], + to: ~D[2020-02-01], + page_size: 1, + page: 2 + } + + config_overrides = [host: "http://localhost:#{bypass.port}"] + + create_and_save_api_key() + + bypass_expect(bypass, "GET", "/transactions", fn conn -> + Conn.resp( + conn, + 200, + ~s<{"total": 3, "totalPages": 3, "page": 2, "results": [{"id": "5d6b9f9a-06aa-491f-926a-15ba46c6366d", "accountId": "03cc0eff-4ec5-495c-adb3-1ef9611624fc", "description": "Rappi", "currencyCode": "BRL", "amount": 41.58, "date": "2020-06-08T00:00:00.000Z", "balance": 41.58, "category": "Online Payment", "status": "POSTED"}]}> + ) + end) + + assert {:ok, result} = Transaction.all_by_account(params, config_overrides) + + assert_pluggy(%{ + query_params: %{ + "accountId" => "d619cfde-a8d7-4fe0-a10d-6de488bde4e0", + "from" => "2020-01-01", + "sandbox" => "true", + "to" => "2020-02-01", + "pageSize" => "1", + "page" => "2" + } + }) + + assert result == %{ + page: 2, + total: 3, + total_pages: 3, + transactions: [ + %Transaction{ + account_id: "03cc0eff-4ec5-495c-adb3-1ef9611624fc", + amount: 41.58, + balance: 41.58, + category: "Online Payment", + currency_code: "BRL", + date: ~N[2020-06-08 00:00:00.000], + description: "Rappi", + description_raw: nil, + id: "5d6b9f9a-06aa-491f-926a-15ba46c6366d", + payment_data: nil, + provider_code: nil, + status: "POSTED" + } + ] + } + end + + test "handle empty results", %{bypass: bypass} do + params = %{ + account_id: "d619cfde-a8d7-4fe0-a10d-6de488bde4e0", + from: "invalid-date", + to: ~D[2020-02-01] + } + + config_overrides = [host: "http://localhost:#{bypass.port}"] + + create_and_save_api_key() + + bypass_expect(bypass, "GET", "/transactions", fn conn -> + Conn.resp(conn, 200, ~s<{"total": 0, "totalPages": 0, "page": 1, "results": []}>) + end) + + assert {:ok, result} = Transaction.all_by_account(params, config_overrides) + + assert result == %{ + page: 1, + total: 0, + total_pages: 0, + transactions: [] + } + end + + test "when params are invalid, returns a validation error" do + invalid_params = %{} + + assert Transaction.all_by_account(invalid_params) == + {:error, ":account_id, :from, and :to are required"} + end + + test "when has error to get transactions list, returns that error", %{bypass: bypass} do + params = %{ + account_id: "invalid-account-id", + from: "invalid-date", + to: ~D[2020-02-01] + } + + config_overrides = [host: "http://localhost:#{bypass.port}"] + + create_and_save_api_key() + + bypass_expect(bypass, "GET", "/transactions", fn conn -> + Conn.resp( + conn, + 500, + ~s<{"message": "There was an error processing your request", "code": 500}> + ) + end) + + assert {:error, reason} = Transaction.all_by_account(params, config_overrides) + + assert reason == %Error{ + code: 500, + details: nil, + message: "There was an error processing your request" + } + end + end +end diff --git a/test/pluggy_elixir/webhook_test.exs b/test/pluggy_elixir/webhook_test.exs index b1579ea..6c210ef 100644 --- a/test/pluggy_elixir/webhook_test.exs +++ b/test/pluggy_elixir/webhook_test.exs @@ -80,6 +80,10 @@ defmodule PluggyElixir.WebhookTest do assert {:ok, result} = Webhook.create(params, config_overrides) + assert_pluggy(%{ + body_params: %{"event" => "all", "url" => "https://finbits.com.br/webhook"} + }) + assert result == %Webhook{ created_at: ~N[2020-06-24 21:29:40.300], event: params.event, @@ -192,8 +196,6 @@ defmodule PluggyElixir.WebhookTest do config_overrides = [host: "http://localhost:#{bypass.port}"] bypass_expect(bypass, "PATCH", "/webhooks/:id", fn %{body_params: body} = conn -> - assert conn.params["id"] == id - Conn.resp( conn, 200, @@ -203,6 +205,11 @@ defmodule PluggyElixir.WebhookTest do assert {:ok, result} = Webhook.update(params, config_overrides) + assert_pluggy(%{ + params: %{"id" => ^id}, + body_params: %{"event" => "all", "url" => "https://finbits.com.br/updated_webhook"} + }) + assert result == %Webhook{ created_at: ~N[2020-06-24 21:29:40.300], event: params.event, diff --git a/test/support/bypass_expect.ex b/test/support/bypass_expect.ex deleted file mode 100644 index 8a3d37c..0000000 --- a/test/support/bypass_expect.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule PluggyElixir.BypassExpect do - alias Plug.Conn - - def bypass_expect(bypass, method, url, mock_func) do - Bypass.expect(bypass, method, url, fn conn -> - conn - |> validate_content_type() - |> Conn.read_body() - |> json_decode() - |> Conn.put_resp_header("content-type", "application/json") - |> mock_func.() - end) - end - - defp validate_content_type(conn) do - conn - |> Conn.get_req_header("content-type") - |> case do - ["application/json" <> _tail] -> conn - _other -> custom_raise("Pluggy API requires content-type: application/json header") - end - end - - defp json_decode({:ok, body, conn}) do - body - |> case do - "" -> {:ok, %{}} - json -> Jason.decode(json) - end - |> add_body(conn) - end - - defp add_body({:ok, body}, conn) when is_map(body) do - %{conn | body_params: body, params: Map.merge(conn.params, body)} - end - - defp add_body({:error, reason}, _conn) do - custom_raise( - "Pluggy API just accept body with JSON format. The Jason.decode/1 failed with #{ - inspect(reason) - }" - ) - end - - defp custom_raise(msg), do: raise("\n\n\n>>>>>> Bypass error:\n\n#{msg}\n\n<<<<<<\n\n\n") -end diff --git a/test/support/case.ex b/test/support/case.ex index 09d8aa1..59778a0 100644 --- a/test/support/case.ex +++ b/test/support/case.ex @@ -4,14 +4,7 @@ defmodule PluggyElixir.Case do using do quote do alias Plug.Conn - import PluggyElixir.BypassExpect - - def create_and_save_api_key do - auth = %PluggyElixir.Auth{api_key: "generated_api_key_#{:rand.uniform()}"} - PluggyElixir.Auth.Guard.set_auth(auth) - - auth.api_key - end + import PluggyElixir.Test end end