Packages

Elixir client for Interactive Brokers' Client Portal API

Current section

Files

Jump to
ibkr_api test support http_sandbox.ex
Raw

test/support/http_sandbox.ex

defmodule IbkrApi.Support.HTTPSandbox do
@moduledoc """
HTTP sandbox for mocking HTTP requests in tests.
Stores response functions in a Registry under the PID of the test process.
In test, the IbkrApi.SharedUtils.HTTP module will check this sandbox before
making actual HTTP requests.
"""
@sleep 10
@state "state"
@registry :http_sandbox
@keys :unique
@type action :: :get | :post | :put | :delete
@type url :: String.t()
@type body :: map() | String.t()
@type headers :: keyword() | list()
@type options :: keyword()
@doc """
Start the HTTP sandbox registry.
Should be called from test_helper.exs
"""
@spec start_link :: {:error, any} | {:ok, pid}
def start_link do
Registry.start_link(keys: @keys, name: @registry)
end
@doc """
Checks if the HTTP sandbox is disabled.
Used by the HTTP client to determine whether to use mock responses.
Always returns false in tests, meaning the sandbox is always enabled.
"""
@spec sandbox_disabled? :: boolean
def sandbox_disabled? do
false
end
@doc """
Retrieves the response for a HEAD request
"""
@spec head_response(url, headers, options) :: any
def head_response(url, headers, options) do
func = find!(:head, url)
case :erlang.fun_info(func)[:arity] do
0 -> func.()
3 -> func.(url, headers, options)
_ -> raise_arity_error(func)
end
end
@doc """
Retrieves the response for a GET request
"""
@spec get_response(url, headers, options) :: any
def get_response(url, headers, options) do
func = find!(:get, url)
case :erlang.fun_info(func)[:arity] do
0 -> func.()
3 -> func.(url, headers, options)
_ -> raise_arity_error(func)
end
end
@doc """
Retrieves the response for a POST request
"""
@spec post_response(url, body, headers, options) :: any
def post_response(url, body, headers, options) do
func = find!(:post, url)
case :erlang.fun_info(func)[:arity] do
0 -> func.()
1 -> func.(body)
3 -> func.(url, headers, options)
4 -> func.(url, body, headers, options)
_ -> raise_arity_error(func)
end
end
@doc """
Retrieves the response for a PUT request
"""
@spec put_response(url, body, headers, options) :: any
def put_response(url, body, headers, options) do
func = find!(:put, url)
case :erlang.fun_info(func)[:arity] do
0 -> func.()
1 -> func.(body)
3 -> func.(url, headers, options)
4 -> func.(url, body, headers, options)
_ -> raise_arity_error(func)
end
end
@doc """
Retrieves the response for a DELETE request
"""
@spec delete_response(url, headers, options) :: any
def delete_response(url, headers, options) do
func = find!(:delete, url)
case :erlang.fun_info(func)[:arity] do
0 -> func.()
3 -> func.(url, headers, options)
_ -> raise_arity_error(func)
end
end
@doc """
Sets sandbox responses for GET requests.
## Examples
HTTPSandbox.set_get_responses([
{"https://localhost:5000/v1/api/iserver/auth/status", fn ->
{:ok, %{
"authenticated" => true,
"competing" => false,
"connected" => true,
"message" => ""
}, %IbkrApi.SharedUtils.HTTP.Response{status: 200}}
end}
])
"""
@spec set_get_responses([{String.t(), fun}]) :: :ok
def set_get_responses(tuples) do
tuples
|> Map.new(fn {url, func} -> {{:get, url}, func} end)
|> register_responses()
end
@doc """
Sets sandbox responses for POST requests.
## Examples
HTTPSandbox.set_post_responses([
{"https://localhost:5000/v1/api/iserver/account", fn ->
{:ok, %{"acctId" => "U12345678"}, %IbkrApi.SharedUtils.HTTP.Response{status: 200}}
end}
])
"""
@spec set_post_responses([{String.t(), fun}]) :: :ok
def set_post_responses(tuples) do
tuples
|> Map.new(fn {url, func} -> {{:post, url}, func} end)
|> register_responses()
end
@doc """
Sets sandbox responses for PUT requests.
## Examples
HTTPSandbox.set_put_responses([
{"https://localhost:5000/v1/api/iserver/account/orders", fn ->
{:ok, %{"id" => "12345"}, %IbkrApi.SharedUtils.HTTP.Response{status: 200}}
end}
])
"""
@spec set_put_responses([{String.t(), fun}]) :: :ok
def set_put_responses(tuples) do
tuples
|> Map.new(fn {url, func} -> {{:put, url}, func} end)
|> register_responses()
end
@doc """
Sets sandbox responses for DELETE requests.
## Examples
HTTPSandbox.set_delete_responses([
{"https://localhost:5000/v1/api/iserver/account/order/12345", fn ->
{:ok, %{"id" => "12345"}, %IbkrApi.SharedUtils.HTTP.Response{status: 200}}
end}
])
"""
@spec set_delete_responses([{String.t(), fun}]) :: :ok
def set_delete_responses(tuples) do
tuples
|> Map.new(fn {url, func} -> {{:delete, url}, func} end)
|> register_responses()
end
@doc """
Sets sandbox responses for HEAD requests.
## Examples
HTTPSandbox.set_head_responses([
{"https://localhost:5000/v1/api/iserver/auth/status", fn ->
{:ok, %{}, %IbkrApi.SharedUtils.HTTP.Response{status: 200}}
end}
])
"""
@spec set_head_responses([{String.t(), fun}]) :: :ok
def set_head_responses(tuples) do
tuples
|> Map.new(fn {url, func} -> {{:head, url}, func} end)
|> register_responses()
end
@doc """
Finds the appropriate response function for the given action and URL.
Returns the function or raises an error.
"""
@spec find!(action, url) :: fun
def find!(action, url) do
case Registry.lookup(@registry, @state) do
[{_pid, funcs}] ->
find_response!(funcs, action, url)
[] ->
raise """
No HTTP sandbox responses registered for #{inspect(self())}
Action: #{inspect(action)}
URL: #{inspect(url)}
======= Use: =======
#{format_example(action, url)}
=== in your test ===
"""
end
end
# Register responses in the registry
defp register_responses(responses) do
case Registry.lookup(@registry, @state) do
[{_pid, _existing}] ->
Registry.update_value(@registry, @state, &Map.merge(&1, responses))
[] ->
Registry.register(@registry, @state, responses)
end
Process.sleep(@sleep)
:ok
end
# Find the appropriate response function
defp find_response!(funcs, action, url) do
key = {action, url}
case Map.get(funcs, key) do
nil ->
# Try regex matching
regex_match = Enum.find(funcs, fn {{act, pattern}, _} ->
act == action && is_struct(pattern, Regex) && Regex.match?(pattern, url)
end)
case regex_match do
{{_, _}, func} when is_function(func) -> func
nil -> raise_function_not_found(action, url, funcs)
end
func when is_function(func) ->
func
end
end
# Helper for raising arity errors
defp raise_arity_error(func) do
raise """
This function's signature is not supported.
#{inspect(func)}
Please provide a function with appropriate arity for the HTTP method.
"""
end
# Helper for raising function not found errors
defp raise_function_not_found(action, url, funcs) do
functions_text =
Enum.map_join(funcs, "\n", fn {k, v} -> "#{inspect(k)} => #{inspect(v)}" end)
raise """
Function not found for {#{inspect(action)}, #{inspect(url)}} in #{inspect(self())}
Found:
#{functions_text}
======= Use: =======
#{format_example(action, url)}
=== in your test ===
"""
end
# Helper for generating example usage
defp format_example(action, url) do
"""
alias IbkrApi.Support.HTTPSandbox
setup do
HTTPSandbox.set_#{action}_responses([
{#{inspect(url)}, fn ->
{:ok, response_data, %IbkrApi.SharedUtils.HTTP.Response{status: 200}}
end}
# or for regex matching:
{~r|https://localhost:5000/v1/api|, fn -> response end}
])
end
"""
end
end