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