Add API to calculate a block reward for a block (#223)
parent
2c0ea545c3
commit
5bdea742f0
@ -0,0 +1,188 @@ |
||||
defmodule Explorer.Chain.Block.Range do |
||||
@moduledoc """ |
||||
Represents a range of block numbers. |
||||
""" |
||||
|
||||
alias Explorer.Chain.Block.Range |
||||
alias Postgrex.Range, as: PGRange |
||||
|
||||
defstruct [:from, :to] |
||||
|
||||
@typedoc """ |
||||
A block number range where range boundaries are inclusive. |
||||
|
||||
* `:from` - Lower inclusive bound of range. |
||||
* `:to` - Upper inclusive bound of range. |
||||
""" |
||||
@type t :: %Range{ |
||||
from: integer() | :negative_infinity, |
||||
to: integer() | :infinity |
||||
} |
||||
|
||||
@behaviour Ecto.Type |
||||
|
||||
@doc """ |
||||
The underlying Postgres type, `int8range`. |
||||
""" |
||||
@impl Ecto.Type |
||||
def type, do: :int8range |
||||
|
||||
@doc """ |
||||
Converts a value to a `Range`. |
||||
|
||||
## Examples |
||||
|
||||
Tuples of integers |
||||
|
||||
iex> cast({1, 5}) |
||||
{:ok, %Range{from: 1, to: 5}} |
||||
|
||||
iex> cast({nil, 5}) |
||||
{:ok, %Range{from: :negative_infinity, to: 5}} |
||||
|
||||
iex> cast({1, nil}) |
||||
{:ok, %Range{from: 1, to: :infinity}} |
||||
|
||||
Postgres range strings |
||||
|
||||
iex> cast("[1,5]") |
||||
{:ok, %Range{from: 1, to: 5}} |
||||
|
||||
iex> cast("(0,6)") |
||||
{:ok, %Range{from: 1, to: 5}} |
||||
|
||||
iex> cast("(,5]") |
||||
{:ok, %Range{from: :negative_infinity, to: 5}} |
||||
|
||||
iex> cast("[1,)") |
||||
{:ok, %Range{from: 1, to: :infinity}} |
||||
|
||||
Range |
||||
|
||||
iex> cast(%Range{from: 1, to: 5}) |
||||
{:ok, %Range{from: 1, to: 5}} |
||||
""" |
||||
@impl Ecto.Type |
||||
def cast({nil, upper}) when is_integer(upper) do |
||||
{:ok, %Range{from: :negative_infinity, to: upper}} |
||||
end |
||||
|
||||
def cast({lower, nil}) when is_integer(lower) do |
||||
{:ok, %Range{from: lower, to: :infinity}} |
||||
end |
||||
|
||||
def cast({lower, upper}) when is_integer(lower) and is_integer(upper) and lower <= upper do |
||||
{:ok, %Range{from: lower, to: upper}} |
||||
end |
||||
|
||||
def cast(range_string) when is_binary(range_string) do |
||||
# Lower boundary should be either `[` or `(` |
||||
lower_boundary_values = "[\\[\\(]" |
||||
# Integer may or may not be present |
||||
integer = "\\d*" |
||||
# Upper boundary should be either `]` or `)` |
||||
upper_boundary_values = "[\\]\\)]" |
||||
range_regex = ~r"(#{lower_boundary_values})(#{integer}),(#{integer})(#{upper_boundary_values})" |
||||
|
||||
case Regex.run(range_regex, range_string, capture: :all_but_first) do |
||||
[lower_boundary, lower, upper, upper_boundary] -> |
||||
block_range = %Range{ |
||||
from: cast_lower(lower, lower_boundary), |
||||
to: cast_upper(upper, upper_boundary) |
||||
} |
||||
|
||||
{:ok, block_range} |
||||
|
||||
_ -> |
||||
:error |
||||
end |
||||
end |
||||
|
||||
def cast(%Range{} = block_range), do: {:ok, block_range} |
||||
|
||||
def cast(_), do: :error |
||||
|
||||
defp cast_lower("", _symbol), do: :negative_infinity |
||||
defp cast_lower(boundary_value, "["), do: String.to_integer(boundary_value) |
||||
defp cast_lower(boundary_value, "("), do: String.to_integer(boundary_value) + 1 |
||||
|
||||
defp cast_upper("", _symbol), do: :infinity |
||||
defp cast_upper(boundary_value, "]"), do: String.to_integer(boundary_value) |
||||
defp cast_upper(boundary_value, ")"), do: String.to_integer(boundary_value) - 1 |
||||
|
||||
@doc """ |
||||
Loads a range from the database and converts it to a `t:Range.t.0`. |
||||
|
||||
## Example |
||||
|
||||
iex> pg_range = %Postgrex.Range{ |
||||
...> lower: 1, |
||||
...> lower_inclusive: true, |
||||
...> upper: 5, |
||||
...> upper_inclusive: true |
||||
...> } |
||||
iex> load(pg_range) |
||||
{:ok, %Range{from: 1, to: 5}} |
||||
""" |
||||
@impl Ecto.Type |
||||
def load(%PGRange{} = range) do |
||||
block_range = %Range{ |
||||
from: parse_lower(range), |
||||
to: parse_upper(range) |
||||
} |
||||
|
||||
{:ok, block_range} |
||||
end |
||||
|
||||
def load(_), do: :error |
||||
|
||||
defp parse_upper(%PGRange{upper: nil}), do: :infinity |
||||
defp parse_upper(%PGRange{upper: upper, upper_inclusive: true}), do: upper |
||||
defp parse_upper(%PGRange{upper: upper, upper_inclusive: false}), do: upper - 1 |
||||
|
||||
defp parse_lower(%PGRange{lower: nil}), do: :negative_infinity |
||||
defp parse_lower(%PGRange{lower: lower, lower_inclusive: true}), do: lower |
||||
defp parse_lower(%PGRange{lower: lower, lower_inclusive: false}), do: lower + 1 |
||||
|
||||
@doc """ |
||||
Converts a `t:Range.t/0` to a persistable data value. |
||||
|
||||
## Example |
||||
|
||||
iex> dump(%Range{from: 1, to: 5}) |
||||
{:ok, |
||||
%Postgrex.Range{ |
||||
lower: 1, |
||||
lower_inclusive: true, |
||||
upper: 5, |
||||
upper_inclusive: true |
||||
}} |
||||
""" |
||||
@impl Ecto.Type |
||||
def dump(%Range{from: from, to: to}) do |
||||
upper = build_upper(to) |
||||
lower = build_lower(from) |
||||
|
||||
range_params = Map.merge(lower, upper) |
||||
|
||||
{:ok, struct!(PGRange, range_params)} |
||||
end |
||||
|
||||
def dump(_), do: :error |
||||
|
||||
defp build_lower(:negative_infinity) do |
||||
%{lower: nil, lower_inclusive: false} |
||||
end |
||||
|
||||
defp build_lower(lower) do |
||||
%{lower: lower, lower_inclusive: true} |
||||
end |
||||
|
||||
defp build_upper(:infinity) do |
||||
%{upper: nil, upper_inclusive: false} |
||||
end |
||||
|
||||
defp build_upper(upper) do |
||||
%{upper: upper, upper_inclusive: true} |
||||
end |
||||
end |
@ -0,0 +1,27 @@ |
||||
defmodule Explorer.Chain.Block.Reward do |
||||
@moduledoc """ |
||||
Represents the static reward given to the miner of a block in a range of block numbers. |
||||
""" |
||||
|
||||
use Ecto.Schema |
||||
|
||||
alias Explorer.Chain.Block.{Range, Reward} |
||||
alias Explorer.Chain.Wei |
||||
|
||||
@typedoc """ |
||||
The static reward given to the miner of a block. |
||||
|
||||
* `:block_range` - Range of block numbers |
||||
* `:reward` - Reward given in Wei |
||||
""" |
||||
@type t :: %Reward{ |
||||
block_range: Range.t(), |
||||
reward: Wei.t() |
||||
} |
||||
|
||||
@primary_key false |
||||
schema "block_rewards" do |
||||
field(:block_range, Range) |
||||
field(:reward, Wei) |
||||
end |
||||
end |
@ -0,0 +1,7 @@ |
||||
defmodule Explorer.Repo.Migrations.CreateBtreeGistExtension do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
execute("CREATE EXTENSION IF NOT EXISTS btree_gist") |
||||
end |
||||
end |
@ -0,0 +1,12 @@ |
||||
defmodule Explorer.Repo.Migrations.CreateBlockRewards do |
||||
use Ecto.Migration |
||||
|
||||
def change do |
||||
create table(:block_rewards, primary_key: false) do |
||||
add(:block_range, :int8range) |
||||
add(:reward, :decimal) |
||||
end |
||||
|
||||
create(constraint(:block_rewards, :no_overlapping_ranges, exclude: ~s|gist (block_range WITH &&)|)) |
||||
end |
||||
end |
@ -0,0 +1,110 @@ |
||||
defmodule Explorer.Chain.Block.RangeTest do |
||||
use ExUnit.Case |
||||
|
||||
alias Explorer.Chain.Block.Range |
||||
alias Postgrex.Range, as: PGRange |
||||
|
||||
doctest Explorer.Chain.Block.Range, import: true |
||||
|
||||
describe "cast/1" do |
||||
test "with negative infinity lower bound and integer" do |
||||
assert Range.cast({nil, 2}) == {:ok, %Range{from: :negative_infinity, to: 2}} |
||||
end |
||||
|
||||
test "with integer and infinity upper bound" do |
||||
assert Range.cast({2, nil}) == {:ok, %Range{from: 2, to: :infinity}} |
||||
end |
||||
|
||||
test "with two integers" do |
||||
assert Range.cast({2, 10}) == {:ok, %Range{from: 2, to: 10}} |
||||
end |
||||
|
||||
test "with a string" do |
||||
assert Range.cast("[2,10]") == {:ok, %Range{from: 2, to: 10}} |
||||
assert Range.cast("(2,10)") == {:ok, %Range{from: 3, to: 9}} |
||||
assert Range.cast("[2,)") == {:ok, %Range{from: 2, to: :infinity}} |
||||
assert Range.cast("(,10]") == {:ok, %Range{from: :negative_infinity, to: 10}} |
||||
assert Range.cast("{2,10}") == :error |
||||
end |
||||
|
||||
test "with a block range" do |
||||
range = %Range{from: 2, to: 10} |
||||
assert Range.cast(range) == {:ok, range} |
||||
end |
||||
|
||||
test "with an invalid input" do |
||||
assert Range.cast(2..10) == :error |
||||
end |
||||
end |
||||
|
||||
describe "load/1" do |
||||
test "with inclusive finite bounds on Range" do |
||||
range = %PGRange{ |
||||
lower: 2, |
||||
lower_inclusive: true, |
||||
upper: 10, |
||||
upper_inclusive: true |
||||
} |
||||
|
||||
assert Range.load(range) == {:ok, %Range{from: 2, to: 10}} |
||||
end |
||||
|
||||
test "with non-inclusive finite bounds on Range" do |
||||
range = %PGRange{ |
||||
lower: 2, |
||||
lower_inclusive: false, |
||||
upper: 10, |
||||
upper_inclusive: false |
||||
} |
||||
|
||||
assert Range.load(range) == {:ok, %Range{from: 3, to: 9}} |
||||
end |
||||
|
||||
test "with infinite bounds" do |
||||
range = %PGRange{ |
||||
lower: nil, |
||||
lower_inclusive: false, |
||||
upper: nil, |
||||
upper_inclusive: false |
||||
} |
||||
|
||||
assert Range.load(range) == {:ok, %Range{from: :negative_infinity, to: :infinity}} |
||||
end |
||||
|
||||
test "with an invalid input" do |
||||
assert Range.load("invalid") == :error |
||||
end |
||||
end |
||||
|
||||
describe "dump/1" do |
||||
test "with infinite bounds" do |
||||
expected = %PGRange{ |
||||
lower: nil, |
||||
lower_inclusive: false, |
||||
upper: nil, |
||||
upper_inclusive: false |
||||
} |
||||
|
||||
assert Range.dump(%Range{from: :negative_infinity, to: :infinity}) == {:ok, expected} |
||||
end |
||||
|
||||
test "with fininte bounds" do |
||||
expected = %PGRange{ |
||||
lower: 2, |
||||
lower_inclusive: true, |
||||
upper: 10, |
||||
upper_inclusive: true |
||||
} |
||||
|
||||
assert Range.dump(%Range{from: 2, to: 10}) == {:ok, expected} |
||||
end |
||||
|
||||
test "with an invalid input" do |
||||
assert Range.dump("invalid") == :error |
||||
end |
||||
end |
||||
|
||||
test "type/0" do |
||||
assert Range.type() == :int8range |
||||
end |
||||
end |
@ -0,0 +1,32 @@ |
||||
defmodule ExplorerWeb.API.RPC.BlockController do |
||||
use ExplorerWeb, :controller |
||||
|
||||
alias Explorer.Chain |
||||
alias ExplorerWeb.Chain, as: ChainWeb |
||||
|
||||
def getblockreward(conn, params) do |
||||
with {:block_param, {:ok, unsafe_block_number}} <- {:block_param, Map.fetch(params, "blockno")}, |
||||
{:ok, block_number} <- ChainWeb.param_to_block_number(unsafe_block_number), |
||||
block_options = [necessity_by_association: %{transactions: :optional}], |
||||
{:ok, block} <- Chain.number_to_block(block_number, block_options) do |
||||
reward = Chain.block_reward(block) |
||||
|
||||
render(conn, :block_reward, block: block, reward: reward) |
||||
else |
||||
{:block_param, :error} -> |
||||
conn |
||||
|> put_status(400) |
||||
|> render(:error, error: "Query parameter 'blockno' is required") |
||||
|
||||
{:error, :invalid} -> |
||||
conn |
||||
|> put_status(400) |
||||
|> render(:error, error: "Invalid block number") |
||||
|
||||
{:error, :not_found} -> |
||||
conn |
||||
|> put_status(404) |
||||
|> render(:error, error: "Block does not exist") |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,68 @@ |
||||
defmodule ExplorerWeb.API.RPC.RPCTranslator do |
||||
@moduledoc """ |
||||
Converts an RPC-style request into a controller action. |
||||
|
||||
Requests are expected to have URL query params like `?module=module&action=action`. |
||||
|
||||
## Configuration |
||||
|
||||
The plugs needs a map relating a `module` string to a controller module. |
||||
|
||||
# In a router |
||||
forward "/api", RPCTranslator, %{"block" => BlockController} |
||||
|
||||
""" |
||||
|
||||
import Plug.Conn |
||||
|
||||
alias ExplorerWeb.API.RPC.RPCView |
||||
alias Plug.Conn |
||||
alias Phoenix.Controller |
||||
|
||||
def init(opts), do: opts |
||||
|
||||
def call(%Conn{params: %{"module" => module, "action" => action}} = conn, translations) do |
||||
with {:ok, controller} <- translate_module(translations, module), |
||||
{:ok, action} <- translate_action(action), |
||||
{:ok, conn} <- call_controller(conn, controller, action) do |
||||
conn |
||||
else |
||||
_ -> |
||||
conn |
||||
|> put_status(400) |
||||
|> Controller.render(RPCView, :error, error: "Unknown action") |
||||
|> halt() |
||||
end |
||||
end |
||||
|
||||
def call(%Conn{} = conn, _) do |
||||
conn |
||||
|> put_status(400) |
||||
|> Controller.render(RPCView, :error, error: "Params 'module' and 'action' are required parameters") |
||||
|> halt() |
||||
end |
||||
|
||||
@doc false |
||||
@spec translate_module(map(), String.t()) :: {:ok, module()} | :error |
||||
def translate_module(translations, module) do |
||||
module_lowercase = String.downcase(module) |
||||
Map.fetch(translations, module_lowercase) |
||||
end |
||||
|
||||
@doc false |
||||
@spec translate_action(String.t()) :: {:ok, atom()} | :error |
||||
def translate_action(action) do |
||||
action_lowercase = String.downcase(action) |
||||
{:ok, String.to_existing_atom(action_lowercase)} |
||||
rescue |
||||
ArgumentError -> :error |
||||
end |
||||
|
||||
@doc false |
||||
@spec call_controller(Conn.t(), module(), atom()) :: {:ok, Conn.t()} | :error |
||||
def call_controller(conn, controller, action) do |
||||
{:ok, controller.call(conn, action)} |
||||
rescue |
||||
Conn.WrapperError -> :error |
||||
end |
||||
end |
@ -0,0 +1,28 @@ |
||||
defmodule ExplorerWeb.API.RPC.BlockView do |
||||
use ExplorerWeb, :view |
||||
|
||||
alias Explorer.Chain.{Hash, Wei} |
||||
alias ExplorerWeb.API.RPC.RPCView |
||||
|
||||
def render("block_reward.json", %{block: block, reward: reward}) do |
||||
reward_as_string = |
||||
reward |
||||
|> Wei.to(:wei) |
||||
|> Decimal.to_string(:normal) |
||||
|
||||
data = %{ |
||||
"blockNumber" => to_string(block.number), |
||||
"timeStamp" => DateTime.to_unix(block.timestamp), |
||||
"blockMiner" => Hash.to_string(block.miner_hash), |
||||
"blockReward" => reward_as_string, |
||||
"uncles" => nil, |
||||
"uncleInclusionReward" => nil |
||||
} |
||||
|
||||
RPCView.render("show.json", data: data) |
||||
end |
||||
|
||||
def render("error.json", %{error: error}) do |
||||
RPCView.render("error.json", error: error) |
||||
end |
||||
end |
@ -0,0 +1,19 @@ |
||||
defmodule ExplorerWeb.API.RPC.RPCView do |
||||
use ExplorerWeb, :view |
||||
|
||||
def render("show.json", %{data: data}) do |
||||
%{ |
||||
"status" => "1", |
||||
"message" => "OK", |
||||
"result" => data |
||||
} |
||||
end |
||||
|
||||
def render("error.json", %{error: message}) do |
||||
%{ |
||||
"status" => "0", |
||||
"message" => message, |
||||
"result" => nil |
||||
} |
||||
end |
||||
end |
@ -0,0 +1,80 @@ |
||||
defmodule ExplorerWeb.API.RPC.BlockControllerTest do |
||||
use ExplorerWeb.ConnCase |
||||
|
||||
alias Explorer.Chain.{Hash, Wei} |
||||
|
||||
describe "getblockreward" do |
||||
test "with missing block number", %{conn: conn} do |
||||
assert response = |
||||
conn |
||||
|> get("/api", %{"module" => "block", "action" => "getblockreward"}) |
||||
|> json_response(400) |
||||
|
||||
assert response["message"] =~ "'blockno' is required" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with an invalid block number", %{conn: conn} do |
||||
assert response = |
||||
conn |
||||
|> get("/api", %{"module" => "block", "action" => "getblockreward", "blockno" => "badnumber"}) |
||||
|> json_response(400) |
||||
|
||||
assert response["message"] =~ "Invalid block number" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with a block that doesn't exist", %{conn: conn} do |
||||
assert response = |
||||
conn |
||||
|> get("/api", %{"module" => "block", "action" => "getblockreward", "blockno" => "42"}) |
||||
|> json_response(404) |
||||
|
||||
assert response["message"] =~ "Block does not exist" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with a valid block", %{conn: conn} do |
||||
%{block_range: range} = block_reward = insert(:block_reward) |
||||
block = insert(:block, number: Enum.random(Range.new(range.from, range.to))) |
||||
|
||||
insert( |
||||
:transaction, |
||||
block: block, |
||||
index: 0, |
||||
gas_price: 1, |
||||
receipt: build(:receipt, gas_used: 1, transaction_index: 0) |
||||
) |
||||
|
||||
expected_reward = |
||||
block_reward.reward |
||||
|> Wei.to(:wei) |
||||
|> Decimal.add(Decimal.new(1)) |
||||
|> Decimal.to_string(:normal) |
||||
|
||||
expected_result = %{ |
||||
"blockNumber" => "#{block.number}", |
||||
"timeStamp" => DateTime.to_unix(block.timestamp), |
||||
"blockMiner" => Hash.to_string(block.miner_hash), |
||||
"blockReward" => expected_reward, |
||||
"uncles" => nil, |
||||
"uncleInclusionReward" => nil |
||||
} |
||||
|
||||
assert response = |
||||
conn |
||||
|> get("/api", %{"module" => "block", "action" => "getblockreward", "blockno" => "#{block.number}"}) |
||||
|> json_response(200) |
||||
|
||||
assert response["result"] == expected_result |
||||
assert response["status"] == "1" |
||||
assert response["message"] == "OK" |
||||
end |
||||
end |
||||
end |
@ -0,0 +1,94 @@ |
||||
defmodule ExplorerWeb.API.RPC.RPCTranslatorTest do |
||||
use ExplorerWeb.ConnCase |
||||
|
||||
alias ExplorerWeb.API.RPC.RPCTranslator |
||||
alias Plug.Conn |
||||
|
||||
defmodule TestController do |
||||
use ExplorerWeb, :controller |
||||
|
||||
def test_action(conn, _) do |
||||
json(conn, %{}) |
||||
end |
||||
end |
||||
|
||||
setup %{conn: conn} do |
||||
conn = Phoenix.Controller.accepts(conn, ["json"]) |
||||
{:ok, conn: conn} |
||||
end |
||||
|
||||
test "init/1" do |
||||
assert RPCTranslator.init([]) == [] |
||||
end |
||||
|
||||
describe "call" do |
||||
test "with a bad module", %{conn: conn} do |
||||
conn = %Conn{conn | params: %{"module" => "test", "action" => "test"}} |
||||
|
||||
result = RPCTranslator.call(conn, %{}) |
||||
assert result.halted |
||||
assert response = json_response(result, 400) |
||||
assert response["message"] =~ "Unknown action" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with a bad action atom", %{conn: conn} do |
||||
conn = %Conn{conn | params: %{"module" => "test", "action" => "some_atom_that_should_not_exist"}} |
||||
|
||||
result = RPCTranslator.call(conn, %{"test" => TestController}) |
||||
assert result.halted |
||||
assert response = json_response(result, 400) |
||||
assert response["message"] =~ "Unknown action" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with an invalid controller action", %{conn: conn} do |
||||
conn = %Conn{conn | params: %{"module" => "test", "action" => "index"}} |
||||
|
||||
result = RPCTranslator.call(conn, %{"test" => TestController}) |
||||
assert result.halted |
||||
assert response = json_response(result, 400) |
||||
assert response["message"] =~ "Unknown action" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with missing params", %{conn: conn} do |
||||
result = RPCTranslator.call(conn, %{"test" => TestController}) |
||||
assert result.halted |
||||
assert response = json_response(result, 400) |
||||
assert response["message"] =~ "'module' and 'action' are required" |
||||
assert response["status"] == "0" |
||||
assert Map.has_key?(response, "result") |
||||
refute response["result"] |
||||
end |
||||
|
||||
test "with a valid request", %{conn: conn} do |
||||
conn = %Conn{conn | params: %{"module" => "test", "action" => "test_action"}} |
||||
|
||||
result = RPCTranslator.call(conn, %{"test" => TestController}) |
||||
assert json_response(result, 200) == %{} |
||||
end |
||||
end |
||||
|
||||
test "translate_module/2" do |
||||
assert RPCTranslator.translate_module(%{"test" => __MODULE__}, "tesT") == {:ok, __MODULE__} |
||||
assert RPCTranslator.translate_module(%{}, "test") == :error |
||||
end |
||||
|
||||
test "translate_action/1" do |
||||
expected = :test_atom |
||||
assert RPCTranslator.translate_action("test_atoM") == {:ok, expected} |
||||
assert RPCTranslator.translate_action("some_atom_that_should_not_exist") == :error |
||||
end |
||||
|
||||
test "call_controller/3", %{conn: conn} do |
||||
assert RPCTranslator.call_controller(conn, TestController, :bad_action) == :error |
||||
assert {:ok, %Plug.Conn{}} = RPCTranslator.call_controller(conn, TestController, :test_action) |
||||
end |
||||
end |
Loading…
Reference in new issue