Files
wanderer/test/support/test_helpers.ex
2025-07-16 20:39:30 +00:00

279 lines
8.4 KiB
Elixir

defmodule WandererApp.TestHelpers do
@moduledoc """
Common test utilities and helpers for the test suite.
"""
import ExUnit.Assertions
@doc """
Converts string keys to atom keys in a map, recursively.
Useful for comparing API responses with expected data.
"""
def atomize_keys(map) when is_map(map) do
Map.new(map, fn {k, v} -> {atomize_key(k), atomize_keys(v)} end)
end
def atomize_keys(list) when is_list(list) do
Enum.map(list, &atomize_keys/1)
end
def atomize_keys(value), do: value
defp atomize_key(key) when is_binary(key), do: String.to_atom(key)
defp atomize_key(key) when is_atom(key), do: key
@doc """
Asserts that a map contains all expected key-value pairs.
Useful for partial matching of API responses.
"""
def assert_maps_equal(actual, expected, message \\ nil) do
missing_keys = Map.keys(expected) -- Map.keys(actual)
if missing_keys != [] do
flunk(
message ||
"Expected map to contain keys #{inspect(missing_keys)}, but they were missing. Actual: #{inspect(actual)}"
)
end
Enum.each(expected, fn {key, expected_value} ->
actual_value = Map.get(actual, key)
assert actual_value == expected_value,
message ||
"Expected #{inspect(key)} to be #{inspect(expected_value)}, got #{inspect(actual_value)}"
end)
end
@doc """
Asserts that a list contains items that match the given criteria.
"""
def assert_list_contains(list, matcher) when is_function(matcher) do
found = Enum.any?(list, matcher)
assert found,
"Expected list to contain an item matching the criteria, but none found. List: #{inspect(list)}"
end
def assert_list_contains(list, expected_item) do
assert expected_item in list,
"Expected list to contain #{inspect(expected_item)}, but it was not found. List: #{inspect(list)}"
end
@doc """
Asserts that a value is within a tolerance of an expected value.
Useful for testing timestamps or floating point values.
"""
def assert_within_tolerance(actual, expected, tolerance)
when is_number(actual) and is_number(expected) do
diff = abs(actual - expected)
assert diff <= tolerance,
"Expected #{actual} to be within #{tolerance} of #{expected}, but difference was #{diff}"
end
@doc """
Asserts that a DateTime is recent (within the last few seconds).
"""
def assert_recent_datetime(datetime, seconds_ago \\ 10)
def assert_recent_datetime(%DateTime{} = datetime, seconds_ago) do
now = DateTime.utc_now()
min_time = DateTime.add(now, -seconds_ago, :second)
assert DateTime.compare(datetime, min_time) != :lt,
"Expected #{datetime} to be within the last #{seconds_ago} seconds, but it was too old"
end
def assert_recent_datetime(%NaiveDateTime{} = naive_datetime, seconds_ago) do
datetime = DateTime.from_naive!(naive_datetime, "Etc/UTC")
assert_recent_datetime(datetime, seconds_ago)
end
@doc """
Retries a function until it succeeds or times out.
Useful for testing eventual consistency or async operations.
"""
def eventually(fun, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 5000)
interval = Keyword.get(opts, :interval, 100)
end_time = System.monotonic_time(:millisecond) + timeout
do_eventually(fun, end_time, interval)
end
defp do_eventually(fun, end_time, interval) do
try do
fun.()
rescue
_ ->
if System.monotonic_time(:millisecond) < end_time do
:timer.sleep(interval)
do_eventually(fun, end_time, interval)
else
# Let it fail with the actual error
fun.()
end
end
end
@doc """
Creates a unique test identifier using the current test name and a counter.
"""
def unique_test_id do
counter = System.unique_integer([:positive])
"test_#{counter}"
end
@doc """
Generates a random string of the specified length.
"""
def random_string(length \\ 10) do
length
|> :crypto.strong_rand_bytes()
|> Base.url_encode64()
|> binary_part(0, length)
end
@doc """
Waits for a GenServer to be available and ready.
"""
def wait_for_genserver(name, timeout \\ 5000) do
end_time = System.monotonic_time(:millisecond) + timeout
do_wait_for_genserver(name, end_time)
end
defp do_wait_for_genserver(name, end_time) do
case GenServer.whereis(name) do
nil ->
if System.monotonic_time(:millisecond) < end_time do
:timer.sleep(100)
do_wait_for_genserver(name, end_time)
else
flunk("GenServer #{name} did not start within timeout")
end
pid ->
pid
end
end
@doc """
Captures and formats Phoenix logs for test assertions.
"""
def capture_log(fun) do
ExUnit.CaptureLog.capture_log(fun)
end
@doc """
Asserts that a log message was captured.
"""
def assert_logged(log_output, expected_message) do
assert log_output =~ expected_message,
"Expected log to contain '#{expected_message}', but got: #{log_output}"
end
@doc """
Ensures a map server is started for testing.
"""
def ensure_map_server_started(map_id) do
case WandererApp.Map.Server.map_pid(map_id) do
pid when is_pid(pid) ->
# Make sure existing server has database access
WandererApp.DataCase.allow_database_access(pid)
# Also allow database access for any spawned processes
allow_map_server_children_database_access(pid)
# Ensure global Mox mode is maintained
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
:ok
nil ->
# Ensure global Mox mode before starting map server
if Code.ensure_loaded?(Mox), do: Mox.set_mox_global()
# Start the map server directly for tests
{:ok, pid} = start_map_server_directly(map_id)
# Grant database access to the new map server process
WandererApp.DataCase.allow_database_access(pid)
# Allow database access for any spawned processes
allow_map_server_children_database_access(pid)
:ok
end
end
defp start_map_server_directly(map_id) do
# Use the same approach as MapManager.start_map_server/1
case DynamicSupervisor.start_child(
{:via, PartitionSupervisor, {WandererApp.Map.DynamicSupervisors, self()}},
{WandererApp.Map.ServerSupervisor, map_id: map_id}
) do
{:ok, pid} ->
# Allow database access for the supervisor and its children
WandererApp.DataCase.allow_genserver_database_access(pid)
# Allow Mox access for the supervisor process if in test mode
WandererApp.Test.MockAllowance.setup_genserver_mocks(pid)
# Also get the actual map server pid and allow access
case WandererApp.Map.Server.map_pid(map_id) do
server_pid when is_pid(server_pid) ->
WandererApp.DataCase.allow_genserver_database_access(server_pid)
# Allow Mox access for the map server process if in test mode
WandererApp.Test.MockAllowance.setup_genserver_mocks(server_pid)
_ ->
:ok
end
{:ok, pid}
{:error, {:already_started, pid}} ->
WandererApp.DataCase.allow_database_access(pid)
{:ok, pid}
{:error, :max_children} ->
# If we hit max children, wait a bit and retry
:timer.sleep(100)
start_map_server_directly(map_id)
error ->
error
end
end
defp allow_map_server_children_database_access(map_server_pid) do
# Allow database access for all children processes
# This is important for MapEventRelay and other spawned processes
# Wait a bit for children to spawn
:timer.sleep(100)
# Get all linked processes
case Process.info(map_server_pid, :links) do
{:links, linked_pids} ->
Enum.each(linked_pids, fn linked_pid ->
if is_pid(linked_pid) and Process.alive?(linked_pid) do
WandererApp.DataCase.allow_database_access(linked_pid)
# Also check for their children
case Process.info(linked_pid, :links) do
{:links, sub_links} ->
Enum.each(sub_links, fn sub_pid ->
if is_pid(sub_pid) and Process.alive?(sub_pid) and sub_pid != map_server_pid do
WandererApp.DataCase.allow_database_access(sub_pid)
end
end)
_ ->
:ok
end
end
end)
_ ->
:ok
end
end
end