Current section

Files

Jump to
split_client lib split_client testing sandbox.ex
Raw

lib/split_client/testing/sandbox.ex

defmodule SplitClient.Sandbox do
@moduledoc """
Handles the orchestration of keeping treatment data isolated between
concurrently running tests.
It assumes that the SplitClient calls from your Application code are
running in the same process as the test. If they are not, you will have
to explicitally `allow` some other child process to access the
treatments data or make the test `async: false`
"""
use GenServer
alias SplitClient.Boundary.DataSourceStub
alias SplitClient.Treatment
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, %{async: true, pids: %{}}, opts)
end
@doc """
Change how treatment data is looked up based on whether
the test is `async: false` or `async: true`
"""
def set_async_mode(name \\ __MODULE__, async?) do
GenServer.call(name, {:set_async_mode, async?})
end
@doc """
Associate a test process with a new data source server that
will be isolated from all other tests
"""
@spec setup_test_treatments(
test_pid :: pid(),
data_source_pid :: pid()
) :: :ok
def setup_test_treatments(name \\ __MODULE__, test_pid, data_source_pid) do
GenServer.call(
name,
{:setup_test_treatments, test_pid, data_source_pid}
)
end
@doc """
Teardown logic when a test is done
"""
def teardown_test_treatments(name \\ __MODULE__, test_pid) do
GenServer.call(name, {:teardown_test_treatments, test_pid})
end
@doc """
Associate a process that is making SplitClient calls with
a specific test's isolated Treatment data
## Examples
iex>SplitClient.Sandbox.allow(self(), Process.whereis(:foo))
:ok
"""
@spec allow(test_pid :: pid(), pid :: pid()) :: :ok
def allow(name \\ __MODULE__, test_pid, pid) do
GenServer.call(name, {:allow, test_pid, pid})
end
@doc """
Populate the test environment with split information
that your application code depends on.
Your splits should come as a list of maps. Each map
should have one key for the split_name which has
as its value a map with the treatment information.
`create_splits/1` must be run after `SplitClient.Testing.setup_split_client/1`
```elixir
[
%{
"my_feature" => %{
treatment: "on",
keys: "mock_user_id",
traffic_type: "customer"
}
},
%{
"my_feature" => %{
treatment: "off",
traffic_type: "customer"
}
}
]
```
"""
@spec create_splits(splits :: list(map())) :: :ok
def create_splits(splits) do
GenServer.call(__MODULE__, {:create_splits, splits, self()})
end
@spec get_treatment(key :: String.t(), split_name :: String.t(), options :: keyword()) ::
Treatment.t()
def get_treatment(key, split_name, opts \\ []) do
GenServer.call(server_name(opts), {:get_treatment, key, split_name, opts, self()})
end
@spec get_treatments(key :: String.t(), split_names :: [String.t()], options :: keyword()) ::
map()
def get_treatments(key, split_names, opts \\ []) do
GenServer.call(server_name(opts), {:get_treatments, key, split_names, opts, self()})
end
@spec get_all_treatments(keys :: [map()], options :: keyword()) :: map()
def get_all_treatments(keys, opts \\ []) do
GenServer.call(server_name(opts), {:get_all_treatments, keys, opts, self()})
end
@doc false
def get_state(name \\ __MODULE__) do
GenServer.call(name, :get_state)
end
defp server_name(opts) do
Keyword.get(opts, :sandbox_pid, __MODULE__)
end
@impl true
def init(state \\ %{}) do
{:ok, state}
end
@impl true
def handle_call({:set_async_mode, async?}, _from, state) do
{:reply, :ok, Map.put(state, :async, async?)}
end
@impl true
def handle_call(
{:setup_test_treatments, test_pid, data_source_pid},
_from,
state
) do
state = put_in(state, [:pids, test_pid], %{data_source_pid: data_source_pid})
{:reply, :ok, state}
end
@impl true
def handle_call({:teardown_test_treatments, test_pid}, _from, state) do
pids =
Map.delete(state.pids, test_pid)
|> Enum.reduce(%{}, fn {key, value}, acc ->
if value[:allowed_by] == test_pid do
acc
else
Map.put(acc, key, value)
end
end)
{:reply, :ok, Map.put(state, :pids, pids)}
end
@impl true
def handle_call({:allow, test_pid, pid}, _from, state) do
servers =
get_servers(state, test_pid)
|> Map.put(:allowed_by, test_pid)
{:reply, :ok, put_in(state, [:pids, pid], servers)}
end
@impl true
def handle_call({:create_splits, splits, test_pid}, _from, state) do
servers = get_servers(state, test_pid)
DataSourceStub.create_splits(servers.data_source_pid, splits)
{:reply, :ok, state}
end
@impl true
def handle_call({:get_treatment, key, split_name, opts, test_pid}, _from, state) do
opts = add_pids_to_options(opts, state, test_pid)
{:ok, treatment} = DataSourceStub.get_treatment(key, split_name, opts)
{:reply, treatment, state}
end
@impl true
def handle_call({:get_treatments, key, split_names, opts, test_pid}, _from, state) do
opts = add_pids_to_options(opts, state, test_pid)
{:ok, treatments} = DataSourceStub.get_treatments(key, split_names, opts)
{:reply, treatments, state}
end
@impl true
def handle_call({:get_all_treatments, keys, opts, test_pid}, _from, state) do
opts = add_pids_to_options(opts, state, test_pid)
{:ok, treatments} = DataSourceStub.get_all_treatments(keys, opts)
{:reply, treatments, state}
end
@impl true
def handle_call(:get_state, _from, state) do
{:reply, state, state}
end
defp get_servers(state, test_pid) do
test_pid = get_test_pid(state, test_pid)
Map.get(state.pids, test_pid, %{})
end
# this is for global/async-false mode. If only one key exists
# we'll assume it's the only test running
defp get_test_pid(%{async: false, pids: pids}, _test_pid) when map_size(pids) == 1 do
Map.keys(pids) |> Enum.at(0)
end
defp get_test_pid(_pids, test_pid), do: test_pid
defp add_pids_to_options(opts, state, test_pid) do
servers = get_servers(state, test_pid)
Keyword.put(opts, :data_source_pid, Map.get(servers, :data_source_pid))
end
end