Packages
Reusable structured audit logging, DB change tracking, and crash reporting for Elixir/Phoenix apps
Current section
Files
Jump to
Current section
Files
lib/audit_trail/test.ex
defmodule AuditTrail.Test do
@moduledoc """
Test helpers for asserting on audit events, meant to be used together
with `AuditTrail.Adapters.Test` (see its moduledoc for setup).
test "approving an item logs an audit event" do
AuditTrail.Test.clear()
{:ok, item} = Items.approve(item_id)
AuditTrail.Test.assert_logged("item:approved", %{resource_id: item.id})
end
Matching is done against the raw, atom-keyed log map (as built by
`AuditTrail.Logger`) — top-level fields like `:resource_id`/`:status`, or
anything nested under `:meta`, e.g. `%{meta: %{changes: %{status: %{to: "approved"}}}}`.
"""
import ExUnit.Assertions
# emit/monitor/log_schema hand off to AuditTrail.Buffer via GenServer.cast,
# which flushes to the adapter from a separately spawned Task — so the log
# is not necessarily visible the instant the call that produced it returns.
# Poll briefly instead of asserting on a single snapshot.
@poll_timeout_ms 200
@poll_interval_ms 10
@doc "Clears every captured log. Call in `setup` to isolate tests."
def clear, do: AuditTrail.Adapters.Test.clear()
@doc "All logs captured so far, oldest first."
def logs, do: AuditTrail.Adapters.Test.all()
@doc """
Asserts an event matching `event_type` (and optionally a map of expected
fields, checked recursively) was logged. Polls for up to #{@poll_timeout_ms}ms
since logging happens asynchronously. Returns the first matching log.
"""
def assert_logged(event_type, expected \\ %{}) do
case poll_until(fn -> Enum.filter(logs(), &matches?(&1, event_type, expected)) end) do
[match | _] ->
match
[] ->
captured = logs()
flunk(
"expected an event #{inspect(event_type)} matching #{inspect(expected)}, " <>
"but none of #{length(captured)} captured logs matched after #{@poll_timeout_ms}ms.\n" <>
"Captured: #{inspect(captured, pretty: true, limit: :infinity)}"
)
end
end
@doc """
Asserts no event matching `event_type` (and optional expected fields) was
logged. Waits out the same poll window as `assert_logged/2` so it isn't
fooled by a log that just hasn't been flushed yet — call this *after*
whatever action you expect to (not) produce the event.
"""
def assert_not_logged(event_type, expected \\ %{}) do
Process.sleep(@poll_timeout_ms)
matches = Enum.filter(logs(), &matches?(&1, event_type, expected))
assert matches == [],
"expected no event #{inspect(event_type)} matching #{inspect(expected)}, " <>
"but found #{length(matches)}: #{inspect(matches, pretty: true, limit: :infinity)}"
:ok
end
defp poll_until(fun, elapsed \\ 0) do
case fun.() do
[] when elapsed < @poll_timeout_ms ->
Process.sleep(@poll_interval_ms)
poll_until(fun, elapsed + @poll_interval_ms)
result ->
result
end
end
defp matches?(log, event_type, expected) do
to_string(Map.get(log, :event_type)) == to_string(event_type) and
Enum.all?(expected, fn {k, v} -> match_field?(Map.get(log, k), v) end)
end
defp match_field?(actual, expected) when is_map(expected) and is_map(actual) do
Enum.all?(expected, fn {k, v} -> match_field?(Map.get(actual, k), v) end)
end
defp match_field?(actual, expected), do: actual == expected
end