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