Current section

Files

Jump to
ex_gram lib ex_gram adapter test.ex
Raw

lib/ex_gram/adapter/test.ex

defmodule ExGram.Adapter.Test do
@moduledoc """
HTTP Adapter for testing with per-process isolation using NimbleOwnership.
Supports both private mode (per-process stubs for async tests) and global mode
(shared stubs for synchronous tests).
This module implements the low-level adapter. In tests you typically interact
with it through `ExGram.Test`, which provides a cleaner interface and handles
process isolation automatically via `start_bot/3`.
## Usage
# In test_helper.exs or application supervision tree
ExGram.Adapter.Test.start_link()
# In your test module - use ExGram.Test for the full setup
defmodule MyBotTest do
use ExUnit.Case, async: true
use ExGram.Test # sets up verify_on_exit! and set_from_context automatically
setup context do
{bot_name, _} = ExGram.Test.start_bot(context, MyApp.Bot)
{:ok, bot_name: bot_name}
end
test "my test", %{bot_name: bot_name} do
ExGram.Test.expect(:send_message, fn body ->
assert body[:text] == "value"
{:ok, %{message_id: 1}}
end)
ExGram.Test.push_update(bot_name, update)
# You can inspect calls after the fact
calls = ExGram.Test.get_calls()
# calls = [{:post, :send_message, %{chat_id: 123, ...}}]
end
end
You can learn more in the [testing guide](https://hexdocs.pm/ex_gram/testing.html)
## API Methods
All stub and expect functions accept action atoms (`:send_message`) that match the names of the
ExGram functions (`ExGram.get_me` -> `:get_me`).
"""
@behaviour ExGram.Adapter
@timeout 30_000
@ownership_key :ex_gram_adapter_test
@ownership_server ExGram.Adapter.Test.Ownership
# Metadata structure per owner
defstruct responses: %{},
errors: %{},
expectations: %{},
calls: [],
catch_all_stub: nil,
catch_all_expectations: [],
unexpected_calls: []
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
defmodule UnexpectedCallError do
@moduledoc false
defexception [:message]
end
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@doc """
Starts the NimbleOwnership server used to track per-test ownership for ExGram.Adapter.Test.
Accepts the same options as NimbleOwnership.start_link/1. By default sets the `:name` option to the module's ownership server name so the server can be referenced globally; call this once (for example from test_helper.exs or your application supervision tree).
"""
@spec start_link(Keyword.t()) :: {:ok, pid()} | {:error, term()}
def start_link(opts \\ []) do
opts = Keyword.put_new(opts, :name, @ownership_server)
NimbleOwnership.start_link(opts)
end
@doc """
Provide a child specification for supervising the test ownership server.
Parameters
- opts: Keyword list of options forwarded to start_link/1.
The returned map is a child specification suitable for use in a supervision tree.
"""
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 500
}
end
@doc """
Register a catch-all stub callback for the current owner to handle requests that have no path-specific stub.
The provided `callback` will be invoked with the action (an atom) and the request body when no action-specific stub or expectation matches; its return value is used as the response (for example `{:ok, value}` or `{:error, reason}`).
## Parameters
- callback: a function of arity 2 receiving `(action :: atom(), body :: any())`.
## Example
stub(fn action, body ->
case action do
:send_message -> {:ok, %ExGram.Model.Message{...}}
:send_chat_action -> {:ok, true}
end
end)
"""
@spec stub((atom(), any() -> any())) :: :ok
def stub(callback) when is_function(callback, 2) do
update_owner_metadata(fn meta ->
%{meta | catch_all_stub: callback}
end)
:ok
end
@doc """
Stub a response for a Telegram API path or action atom. Always returns this response.
Owned by the calling process.
The response can be:
- A static value (will be wrapped in `{:ok, value}` if not already a tuple)
- An arity-1 function that receives the request body
- An arity-2 function that receives path and body (use `stub/1` instead for catch-all)
## Examples
# Static response with atom
stub(:send_message, %ExGram.Model.Message{...})
# Dynamic response based on body
stub(:send_message, fn body ->
assert body["text"] =~ "Hello"
{:ok, %ExGram.Model.Message{...}}
end)
"""
def stub(action, response) do
update_owner_metadata(fn meta ->
%{meta | responses: Map.put(meta.responses, action, response)}
end)
:ok
end
@doc """
Stub an error for a Telegram API path or action atom. Always returns this error.
Owned by the calling process.
"""
def stub_error(action, error) do
update_owner_metadata(fn meta ->
%{meta | errors: Map.put(meta.errors, action, error)}
end)
:ok
end
@doc """
Expect a catch-all response with a callback that receives action atom and body.
Consumed after the first call (n=1).
## Example
expect(fn action, body ->
assert action == :send_message
{:ok, %ExGram.Model.Message{...}}
end)
"""
def expect(callback) when is_function(callback, 2) do
expect(1, callback)
end
@doc """
Expect a catch-all response with a callback that receives action atom and body.
Consumed after n calls.
## Example
expect(2, fn action, body ->
{:ok, %ExGram.Model.Message{...}}
end)
"""
def expect(n, callback) when is_integer(n) and n > 0 and is_function(callback, 2) do
update_owner_metadata(fn meta ->
%{meta | catch_all_expectations: meta.catch_all_expectations ++ [{callback, n}]}
end)
:ok
end
@doc """
Expect a response for a path or action atom. Consumed after n calls, then removed.
Expectations are checked before stubs.
The response can be:
- A static value (will be wrapped in `{:ok, value}` if not already a tuple)
- An arity-1 function that receives the request body
## Examples
# Static response with atom, default n=1
expect(:send_message, %ExGram.Model.Message{...})
# Static response with count
expect(:send_message, 2, %ExGram.Model.Message{...})
# Dynamic response based on body
expect(:send_message, fn body ->
assert body["text"] =~ "Welcome"
{:ok, %ExGram.Model.Message{...}}
end)
# Dynamic response with count
expect(:send_message, 2, fn body ->
{:ok, %ExGram.Model.Message{...}}
end)
"""
def expect(action, n \\ 1, response) when is_integer(n) and n > 0 do
update_owner_metadata(fn meta ->
expectations =
Map.update(meta.expectations, action, [{response, n}], fn existing ->
existing ++ [{response, n}]
end)
%{meta | expectations: expectations}
end)
:ok
end
@doc """
Get all calls recorded for the current owner.
"""
def get_calls do
case fetch_owner_metadata() do
{:ok, meta} -> meta.calls
:error -> []
end
end
@doc """
Verify all expectations were consumed.
Raises if any expectations remain.
"""
def verify!(pid \\ self()) do
case fetch_owner_metadata(pid) do
{:ok, meta} ->
errors = []
# Check for unexpected calls (calls with no stub or expectation)
errors =
if meta.unexpected_calls == [] do
errors
else
paths =
meta.unexpected_calls |> Enum.map(&elem(&1, 0)) |> Enum.reverse() |> Enum.join(", ")
["Unexpected API calls with no stub or expectation: #{paths}" | errors]
end
# Check for unfulfilled path-specific expectations
errors =
if map_size(meta.expectations) > 0 do
["Path-specific expectations not fulfilled: #{inspect(meta.expectations)}" | errors]
else
errors
end
# Check for unfulfilled catch-all expectations
errors =
if meta.catch_all_expectations == [] do
errors
else
[
"Catch-all expectations not fulfilled: #{length(meta.catch_all_expectations)} remaining"
| errors
]
end
if errors == [] do
:ok
else
raise Enum.join(errors, "\n")
end
:error ->
# No metadata found - process may have already cleaned up, that's ok
:ok
end
end
# We need to ignore warnings because ExUnit is not loaded
@dialyzer {:nowarn_function, verify_on_exit!: 1}
@doc """
Registers cleanup on test exit that verifies expectations were met.
Call this in your test's setup block via `setup {ExGram.Test, :verify_on_exit!}`.
"""
def verify_on_exit!(_context) do
pid = self()
# Use manual cleanup to prevent auto-cleanup when test process dies.
# This ensures ownership persists through the on_exit callback so verify! can check it.
NimbleOwnership.set_owner_to_manual_cleanup(@ownership_server, pid)
ExUnit.Callbacks.on_exit({:ex_gram_test, pid}, fn ->
# Verify expectations and clean up ownership
verify!(pid)
NimbleOwnership.cleanup_owner(@ownership_server, pid)
end)
end
@doc """
Allow another process to use this process's stubs.
"""
def allow(owner_pid, allowed_pid) do
NimbleOwnership.allow(@ownership_server, owner_pid, allowed_pid, @ownership_key)
end
@doc false
def ensure_owner(pid \\ self()) do
case get_and_update(
pid,
fn
nil -> {nil, %__MODULE__{}}
existing -> {existing, existing}
end
) do
{:ok, _} ->
:ok
{:error, %NimbleOwnership.Error{reason: {:not_shared_owner, actual_owner_pid}}} ->
ensure_owner(actual_owner_pid)
{:error, %NimbleOwnership.Error{}} ->
raise "Failed to ensure ownership for #{format_process()}"
end
end
@doc """
Switch to global mode (one owner serves all callers).
## Examples
setup {ExGram.Test, :set_global}
"""
def set_global(context \\ %{})
def set_global(%{async: true}) do
raise "ExGram.Test cannot be set to global mode when the ExUnit case is async. " <>
"If you want to use ExGram.Test in global mode, remove \"async: true\" when using ExUnit.Case"
end
def set_global(_) do
NimbleOwnership.set_mode_to_shared(@ownership_server, self())
end
@doc """
Switch to private mode (per-process isolation).
## Examples
setup {ExGram.Test, :set_private}
"""
def set_private(_context \\ %{}) do
NimbleOwnership.set_mode_to_private(@ownership_server)
end
@doc """
Chooses the ExGram.Test mode based on context.
When `async: true` is used, `set_private/1` is called,
otherwise `set_global/1` is used.
## Examples
setup {ExGram.Test, :set_from_context}
"""
@spec set_from_context(term()) :: :ok
def set_from_context(%{async: true} = _context), do: set_private()
def set_from_context(_context), do: set_global()
# ---------------------------------------------------------------------------
# Backward-compatible wrappers (thin wrappers over new API)
# ---------------------------------------------------------------------------
@doc """
Backward-compatible wrapper. The name parameter is ignored - isolation is now per-process.
"""
def backdoor_request(path, response, _name \\ nil), do: stub(path, response)
@doc """
Backward-compatible wrapper. The name parameter is ignored - isolation is now per-process.
"""
def backdoor_error(path, error, _name \\ nil), do: stub_error(path, error)
@doc """
Backward-compatible wrapper. The name parameter is ignored.
"""
def get_calls(_name), do: get_calls()
@doc """
Backward-compatible wrapper. Cleans the current owner's state.
"""
def clean(_name \\ nil) do
update_owner_metadata(fn _meta -> %__MODULE__{} end)
:ok
end
# ---------------------------------------------------------------------------
# ExGram.Adapter callback
# ---------------------------------------------------------------------------
@impl ExGram.Adapter
def request(verb, path, body, _opts) do
# Find the owner through the callers chain
callers = [self() | caller_pids()]
action = path |> clean_path() |> to_action()
case fetch_owner_from_callers(callers) do
{:ok, owner_pid} ->
handle_request(owner_pid, verb, action, body)
:no_expectation ->
raise UnexpectedCallError,
"no expectation defined for action #{action} in #{format_process()} with body #{inspect(body)}"
end
end
# ---------------------------------------------------------------------------
# Private helpers
# ---------------------------------------------------------------------------
defp format_process do
callers = caller_pids()
"process #{inspect(self())}" <>
if Enum.empty?(callers) do
""
else
" (or in its callers #{inspect(callers)})"
end
end
# Find the pid of the actual caller
defp caller_pids do
case Process.get(:"$callers") do
nil -> []
pids when is_list(pids) -> pids
end
end
# Atomically fetch metadata and record the call to prevent race conditions.
# If ownership exists but get_and_update fails (race with cleanup_owner),
# this returns :error and the caller should handle appropriately.
defp fetch_owner_and_record(owner_pid, call) do
case get_and_update(owner_pid, fn
nil ->
# Ownership exists but metadata is nil - initialize and record
# This can happen on first call or after a reset
new_meta = %__MODULE__{calls: [call]}
{new_meta, new_meta}
meta ->
# Ownership and metadata both exist - record the call
updated_meta = %{meta | calls: meta.calls ++ [call]}
{meta, updated_meta}
end) do
{:ok, meta} ->
{:ok, meta}
{:error, %NimbleOwnership.Error{reason: {:not_shared_owner, actual_owner_pid}}} ->
fetch_owner_and_record(actual_owner_pid, call)
{:error, %NimbleOwnership.Error{}} ->
:error
end
end
defp handle_request(owner_pid, verb, action, body) do
# Atomically fetch metadata and record call to prevent race conditions
case fetch_owner_and_record(owner_pid, {verb, action, body}) do
{:ok, meta} ->
get_response(owner_pid, meta, action, body)
:error ->
# Record as unexpected call
update_owner_metadata(owner_pid, fn m ->
%{m | unexpected_calls: [{action, body} | m.unexpected_calls]}
end)
{:error, %ExGram.Error{code: 404, message: "Owner metadata not found"}}
end
end
defp get_response(owner_pid, meta, action, body) do
case get_response_from_expect_or_stub(owner_pid, meta, action, body) do
{:ok, response} ->
normalize_response(response)
:not_found ->
# Record as unexpected call
update_owner_metadata(owner_pid, fn m ->
%{m | unexpected_calls: [{action, body} | m.unexpected_calls]}
end)
{:error, %ExGram.Error{code: 404, message: "No stub or expectation found for #{action}"}}
end
end
defp get_response_from_expect_or_stub(owner_pid, meta, action, body) do
# Priority order:
# 1. Path-specific expectations
# 2. Catch-all expectations
# 3. Path-specific stubs/errors
# 4. Catch-all stub
case consume_expect_expectation(owner_pid, meta, action, body) do
{:ok, response} ->
{:ok, response}
:not_found ->
check_stub_expectation(meta, action, body)
end
end
def consume_expect_expectation(owner_pid, meta, action, body) do
case consume_expectation(owner_pid, meta, action, body) do
{:ok, response} ->
{:ok, response}
:not_found ->
consume_catch_all_expectation(owner_pid, meta, action, body)
end
end
defp check_stub_expectation(meta, action, body) do
case check_stubs_and_errors(meta, action, body) do
:not_found ->
check_catch_all_stub(meta, action, body)
response ->
response
end
end
defp normalize_response({:ok, response}), do: {:ok, response}
defp normalize_response({:error, response}), do: {:error, response}
defp normalize_response(response), do: {:ok, response}
defp consume_expectation(owner_pid, meta, action, body) do
case Map.get(meta.expectations, action) do
nil ->
:not_found
[] ->
:not_found
[{response, 1}] ->
# Last one - remove the action entirely
update_owner_metadata(owner_pid, fn m ->
%{m | expectations: Map.delete(m.expectations, action)}
end)
{:ok, invoke_response(response, action, body)}
[{response, 1} | rest] ->
# Last one but more in the list
update_owner_metadata(owner_pid, fn m ->
%{m | expectations: Map.put(m.expectations, action, rest)}
end)
{:ok, invoke_response(response, action, body)}
[{response, n} | rest] when n > 1 ->
# Decrement count
update_owner_metadata(owner_pid, fn m ->
%{m | expectations: Map.put(m.expectations, action, [{response, n - 1} | rest])}
end)
{:ok, invoke_response(response, action, body)}
[_ | rest] ->
# Move to next expectation
update_owner_metadata(owner_pid, fn m ->
%{m | expectations: Map.put(m.expectations, action, rest)}
end)
consume_expectation(
owner_pid,
%{meta | expectations: Map.put(meta.expectations, action, rest)},
action,
body
)
end
end
defp consume_catch_all_expectation(owner_pid, meta, action, body) do
case meta.catch_all_expectations do
[] ->
:not_found
[{callback, 1}] ->
# Last one - remove it
update_owner_metadata(owner_pid, fn m ->
%{m | catch_all_expectations: []}
end)
{:ok, invoke_response(callback, action, body)}
[{callback, 1} | rest] ->
# Last one - remove it
update_owner_metadata(owner_pid, fn m ->
%{m | catch_all_expectations: rest}
end)
{:ok, invoke_response(callback, action, body)}
[{callback, n} | rest] when n > 1 ->
# Decrement count
update_owner_metadata(owner_pid, fn m ->
%{m | catch_all_expectations: [{callback, n - 1} | rest]}
end)
{:ok, invoke_response(callback, action, body)}
[_head | rest] ->
# Move to next expectation
update_owner_metadata(owner_pid, fn m ->
%{m | catch_all_expectations: rest}
end)
consume_catch_all_expectation(
owner_pid,
%{meta | catch_all_expectations: rest},
action,
body
)
end
end
defp check_stubs_and_errors(meta, action, body) do
# Check errors first, then stubs
cond do
Map.has_key?(meta.errors, action) ->
{:ok, {:error, Map.get(meta.errors, action)}}
Map.has_key?(meta.responses, action) ->
{:ok, invoke_response(Map.get(meta.responses, action), action, body)}
true ->
:not_found
end
end
defp check_catch_all_stub(meta, action, body) do
case meta.catch_all_stub do
nil ->
:not_found
callback ->
{:ok, invoke_response(callback, action, body)}
end
end
defp wrap_response({:ok, response}), do: {:ok, response}
defp wrap_response({:error, error}), do: {:error, error}
defp wrap_response(response), do: {:ok, response}
# Invoke a response - handles both static values and callbacks
# For callbacks: action parameter is an atom like :send_message
defp invoke_response(response, _action, body) when is_function(response, 1) do
response.(body)
end
defp invoke_response(response, action, body) when is_function(response, 2) do
response.(action, body)
end
defp invoke_response(response, _action, _body) do
wrap_response(response)
end
defp fetch_owner_metadata do
fetch_owner_metadata(self())
end
defp fetch_owner_metadata(owner_pid) do
case get_and_update(owner_pid, fn
nil -> {nil, %__MODULE__{}}
meta -> {meta, meta}
end) do
{:ok, nil} -> :error
{:ok, meta} -> {:ok, meta}
{:error, _} -> :error
end
end
defp update_owner_metadata(fun) do
update_owner_metadata(self(), fun)
end
defp update_owner_metadata(owner_pid, fun) do
get_and_update!(owner_pid, fn
nil ->
new_meta = fun.(%__MODULE__{})
{new_meta, new_meta}
meta ->
new_meta = fun.(meta)
{new_meta, new_meta}
end)
end
defp get_and_update(owner_pid, update_fun) do
NimbleOwnership.get_and_update(@ownership_server, owner_pid, @ownership_key, update_fun, @timeout)
end
defp get_and_update!(owner_pid, update_fun) do
case get_and_update(owner_pid, update_fun) do
{:ok, return} -> return
{:error, %NimbleOwnership.Error{} = error} -> raise error
end
end
defp fetch_owner_from_callers(caller_pids) do
# If the mock doesn't have an owner, it can't have expectations so we return :no_expectation.
case NimbleOwnership.fetch_owner(@ownership_server, caller_pids, @ownership_key, @timeout) do
{tag, owner_pid} when tag in [:shared_owner, :ok] -> {:ok, owner_pid}
:error -> :no_expectation
end
end
# ---------------------------------------------------------------------------
# Conversion helpers
# ---------------------------------------------------------------------------
defp to_action(path) when is_binary(path) do
path
|> String.trim_leading("/")
|> Macro.underscore()
|> String.to_atom()
end
defp to_action(action) when is_atom(action), do: action
defp clean_path("/bot" <> _ = path) do
"/" <> (path |> String.split("/") |> Enum.drop(2) |> Enum.join("/"))
end
defp clean_path(path), do: path
end