defmodule Polyn.Sandbox do @moduledoc """ Sandbox environment for mocking NATS and keeping tests isolated Add the following to your test_helper.ex ```elixir Polyn.Sandbox.start_link() ``` ## Nested Processes `Polyn.Testing` associates each test process with its own NATS mock. To allow other processes that will call `Polyn` functions to use the same NATS mock as the rest of the test use the `Polyn.Sandbox.allow/2` function. If you don't have access to the `pid` or name of a process that is using `Polyn` you will need to make your file `async: false`. """ use Agent @doc """ Start the Sandbox """ @spec start_link(any()) :: Agent.on_start() def start_link(_initial_value) do Agent.start_link(fn -> %{async: false, pids: %{}} end, name: __MODULE__) end @doc """ Get the full state """ @spec state() :: map() def state do Agent.get(__MODULE__, & &1) end @doc """ Get the nats server for a given pid """ @spec get!(pid()) :: pid() def get!(pid) do result = Agent.get(__MODULE__, &lookup_nats(&1, pid)) case result do nil -> raise Polyn.TestingException, no_nats_server_msg(pid) nats_pid -> nats_pid end end # When async: false we're assuming only 1 test is running # and only one association should exist defp lookup_nats(%{async: false, pids: pids}, _pid) when map_size(pids) == 1 do Map.values(pids) |> Enum.at(0) |> Map.get(:nats) end defp lookup_nats(%{pids: pids}, pid) do get_in(pids, [pid, :nats]) end @doc """ Get the async mode of the Sandbox. Defaults to false """ @spec get_async_mode() :: boolean() def get_async_mode do Agent.get(__MODULE__, &Map.get(&1, :async, false)) end @doc """ Setup a test with a mock nats server association """ @spec setup_test(test_pid :: pid(), nats_pid :: pid()) :: :ok def setup_test(test_pid, nats_pid) do Agent.update(__MODULE__, &put_in(&1, [:pids, test_pid], %{nats: nats_pid})) end @doc """ Remove the nats server assocation when a test is finished """ @spec teardown_test(test_pid :: pid()) :: :ok def teardown_test(test_pid) do Agent.update(__MODULE__, fn state -> pids = Map.delete(state.pids, test_pid) |> remove_allowed_pids(test_pid) Map.put(state, :pids, pids) end) end defp remove_allowed_pids(pids, test_pid) do Enum.reduce(pids, %{}, fn {key, value}, acc -> if value[:allowed_by] == test_pid do acc else Map.put(acc, key, value) end end) end @doc """ Make the sandbox async false or true """ @spec set_async_mode(mode :: boolean()) :: :ok def set_async_mode(mode) do Agent.update(__MODULE__, &Map.put(&1, :async, mode)) end @doc """ Allow a child process, that is not the test process, to access the running MockNats server. You cannot allow the same process on multiple tests. ## Examples iex>Polyn.Sandbox.allow(self(), Process.whereis(:foo)) :ok """ @spec allow(test_pid :: pid(), other_pid :: pid()) :: :ok def allow(test_pid, other_pid) do Agent.update(__MODULE__, fn state -> validate_allowance!(state.pids, other_pid) mock_nats = state.pids[test_pid][:nats] put_in(state, [:pids, other_pid], %{nats: mock_nats, allowed_by: test_pid}) end) end defp validate_allowance!(test_pids, pid) do case test_pids[pid] do nil -> :ok existing -> raise Polyn.TestingException, already_allowed_msg(pid, existing.allowed_by) end end defp already_allowed_msg(pid, allowed_by) do """ \nYou tried to call `Polyn.Sandbox.allow/2` with a `pid` of #{inspect(pid)} that is already associated with a running test #{inspect(allowed_by)}. This is possibly because you have a shared process that has a lifecycle that spans multiple tests. This can cause tests to be flaky and have race conditions as the NATS state will not be isolated. Instead, refactor code so that the process is not shared between tests or make these tests `async: false` """ end defp no_nats_server_msg(pid) do """ \nTo keep NATS data isolated in concurrently running tests each test needs its own MockNats Server. There are no MockNats servers associated with process #{inspect(pid)}. This could happen for several reasons: 1. Did you forget to add ``` import Polyn.Testing setup :setup_polyn ```` to the top of your test file? 2. Is your call to `Polyn` happening in a Process other than the test process? If so you'll need to explicitly associate that process by using `Polyn.Sandbox.allow/2` 3. If your `Polyn` calls are happening in a Process that isn't accessible to you, you'll need to make your test `async: false` """ end end