Current section

Files

Jump to
double_down lib double_down testing.ex
Raw

lib/double_down/testing.ex

defmodule DoubleDown.Testing do
@moduledoc """
Test helpers for DoubleDown contracts.
Start the ownership server in `test/test_helper.exs`:
DoubleDown.Testing.start()
Then in your tests, register handlers per-contract:
setup do
DoubleDown.Testing.set_module_handler(MyApp.Todos, MyApp.Todos.InMemory)
:ok
end
Handlers are process-scoped via `NimbleOwnership`, so `async: true`
tests are isolated. Use `allow/3` to share handlers with child processes.
"""
alias DoubleDown.Dispatch.HandlerMeta
alias DoubleDown.Dispatch.Keys
@doc """
Start the DoubleDown ownership server.
Call this once in `test/test_helper.exs`.
"""
@spec start() :: {:ok, pid()} | {:error, term()}
def start do
result = NimbleOwnership.start_link(name: Keys.ownership_server())
DoubleDown.DynamicFacade.Meck.install()
result
end
@doc """
Register a module as the handler for a contract.
The module must implement the contract's `@behaviour`.
"""
@spec set_module_handler(module(), module()) :: :ok
def set_module_handler(contract, impl) do
set_meta(contract, HandlerMeta.Module.new(impl))
end
@doc """
Register a function as the handler for a contract.
The function receives `(contract, operation, args)` and returns the result.
"""
@spec set_stateless_handler(module(), (module(), atom(), [term()] -> term())) :: :ok
def set_stateless_handler(contract, fun) when is_function(fun, 3) do
set_meta(contract, HandlerMeta.Stateless.new(fun))
end
@doc """
Register a stateful handler for a contract.
The function may be 4-arity or 5-arity:
* **4-arity:** `fn contract, operation, args, state -> {result, new_state} end`
* **5-arity:** `fn contract, operation, args, state, all_states -> {result, new_state} end`
5-arity handlers receive a read-only snapshot of all contract states as
the 5th argument. This enables cross-contract state access (e.g. a Queries
handler reading the Repo InMemory store). The `all_states` map is keyed by
contract module and includes a `DoubleDown.Contract.GlobalState` sentinel key.
The handler must return only its own contract's new state — not the global map.
State is stored in NimbleOwnership and updated atomically on each dispatch.
"""
@spec set_stateful_handler(module(), DoubleDown.Dispatch.Types.stateful_fun(), term()) ::
:ok
def set_stateful_handler(contract, fun, initial_state)
when is_function(fun, 4) or is_function(fun, 5) do
set_meta(contract, HandlerMeta.Stateful.new(fun, initial_state))
end
@doc """
Allow a child process to use the current process's handlers.
Use this when spawning Tasks or other processes that need to
dispatch through the same test handlers.
"""
@spec allow(module(), pid() | (-> pid() | [pid()])) :: :ok | {:error, term()}
@spec allow(module(), pid(), pid() | (-> pid() | [pid()])) :: :ok | {:error, term()}
def allow(contract, owner_pid \\ self(), child_pid) do
NimbleOwnership.allow(Keys.ownership_server(), owner_pid, child_pid, contract)
end
@doc """
Enable dispatch logging for a contract.
After enabling, all dispatches through the contract's facade will be
recorded. Retrieve with `get_log/1`.
"""
@spec enable_log(module()) :: :ok
def enable_log(contract) do
log_key = Keys.log_key(contract)
NimbleOwnership.get_and_update(Keys.ownership_server(), self(), log_key, fn _ ->
{:ok, []}
end)
:ok
end
@doc """
Retrieve the dispatch log for a contract.
Returns a list of `{contract, operation, args, result}` tuples
in the order they were dispatched.
"""
@spec get_log(module()) :: [{module(), atom(), [term()], term()}]
def get_log(contract) do
log_key = Keys.log_key(contract)
case NimbleOwnership.get_owned(Keys.ownership_server(), self()) do
%{^log_key => log} -> Enum.reverse(log)
_ -> []
end
end
@doc """
Reset all handlers and logs for a process.
Clears all NimbleOwnership entries owned by `pid`. Defaults to
`self()`.
**`on_exit` caveat:** `reset()` (without arguments) uses `self()`,
which inside an `on_exit` callback is the callback process — not
the test process. To reset the test process's handlers from
`on_exit`, capture the pid first:
setup do
pid = self()
on_exit(fn -> DoubleDown.Testing.reset(pid) end)
:ok
end
In most cases you don't need explicit cleanup — NimbleOwnership
automatically cleans up when the owning process exits.
"""
@spec reset(pid()) :: :ok
def reset(pid \\ self()) do
NimbleOwnership.cleanup_owner(Keys.ownership_server(), pid)
end
# -- Internal --
defp set_meta(contract, meta) do
DoubleDown.DynamicFacade.ensure_shimmed(contract)
case NimbleOwnership.get_and_update(Keys.ownership_server(), self(), contract, fn
nil ->
{:ok, meta}
existing ->
{{:error, existing}, existing}
end) do
{:ok, {:error, existing}} ->
raise ArgumentError, """
A handler is already installed for #{inspect(contract)}.
Found: #{inspect(existing)}
Call DoubleDown.Testing.reset() first to clear all handlers \
before installing a new one.
"""
{:ok, _} ->
:ok
{:error, %NimbleOwnership.Error{} = error} ->
raise ArgumentError, """
Failed to install handler for #{inspect(contract)}: \
#{Exception.message(error)}
Ensure the handler is installed from the test process's \
setup block, not from a child process.
"""
end
end
end