Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/test.ex
defmodule AshDispatch.Test do
@moduledoc ~S"""
Test helpers for apps: render every letter the app can send, and fail on
any that would go out broken.
The one-line test:
test "every mail renders" do
assert AshDispatch.Test.assert_all_render(seeds: 25) == 42
end
`assert_all_render/1` renders every row of `AshDispatch.Preview.events/0`
(event × channel × variant × audience) from its sample data, once per seed,
through the send path's own renderer, and fails — listing every finding —
when a letter:
- could not be rendered at all;
- is an email with an empty subject, or with neither an HTML nor a text
body;
- still contains template source: `<%`, `{@`, `#{`, a HEEx block
expression (`{if `, `{for `, `{case `, `{unless `, `{cond `, `{with `), or
`TODO`.
It returns how many letters it rendered. Assert on that number, exactly: a
test that turns green because nothing was rendered is the failure it
exists to prevent, and a hard-coded count goes red when an event loses a
channel or a template, instead of quietly checking less.
## Why the rendered output and not the templates
`AshDispatch.StrictTemplates` checks the source. This checks what comes
out, which also catches what no source check can: an expression that was
converted but still printed its own code, a value that renders as
`#{...}` because it was built with the wrong quotes, an assign that came
out empty.
## Options
- `:seeds` - an integer `n` (seeds `1..n`) or a list of seeds. Default `[1]`
- `:only` / `:except` - event ids to include or leave out
- `:transports` - only these transports (default: all)
- `:forbid` - more strings that must not appear (e.g. `["lorem ipsum"]`)
- any other option is passed to `AshDispatch.Preview.render/3` (`:locale`,
`:variables`, …)
"""
alias AshDispatch.Preview
# ExUnit ships with Elixir but is not started in a release; these helpers
# only ever run inside a test.
@compile {:no_warn_undefined, ExUnit.AssertionError}
@markers ["<%", "{@", "\#{", "{if ", "{for ", "{case ", "{unless ", "{cond ", "{with ", "TODO"]
@render_opts_skip [:seeds, :only, :except, :transports, :forbid]
@typedoc "One rendered letter and what is wrong with it (`[]` when nothing)."
@type result :: %{
row: map(),
seed: integer(),
rendered: Preview.Rendered.t() | nil,
problems: [String.t()]
}
@doc """
Renders every row of `AshDispatch.Preview.events/0` for every seed and
returns one `t:result/0` per letter. See the moduledoc for the options and
the checks.
"""
@spec render_all(keyword()) :: [result()]
def render_all(opts \\ []) do
render_opts = Keyword.drop(opts, @render_opts_skip)
forbid = @markers ++ List.wrap(Keyword.get(opts, :forbid, []))
for row <- rows(opts), seed <- seeds(opts), result <- render_row(row, seed, render_opts) do
case result do
{:ok, rendered} ->
%{row: row, seed: seed, rendered: rendered, problems: problems(rendered, forbid)}
{:error, reason} ->
%{row: row, seed: seed, rendered: nil, problems: ["could not render: #{reason}"]}
end
end
end
@doc """
Fails with every finding of `render_all/1`, or returns the number of
letters rendered. Fails as well when that number is zero.
"""
@spec assert_all_render(keyword()) :: non_neg_integer()
def assert_all_render(opts \\ []) do
results = render_all(opts)
case Enum.reject(results, &(&1.problems == [])) do
[] when results == [] ->
raise ExUnit.AssertionError,
message:
"AshDispatch.Test.assert_all_render/1 rendered nothing — no events with " <>
"channels were found (is :otp_app configured, and are the domains listed?)"
[] ->
length(results)
failing ->
raise ExUnit.AssertionError,
message: """
#{length(failing)} of #{length(results)} letters would go out broken:
#{Enum.map_join(failing, "\n", &format_failure/1)}
"""
end
end
@doc """
The problems `render_all/1` would report for one rendered letter — `[]`
when there are none. For checking a letter rendered some other way, e.g. by
`AshDispatch.Preview.render/3` from a specific record. Takes `:forbid`.
"""
@spec problems_for(Preview.Rendered.t(), keyword()) :: [String.t()]
def problems_for(%Preview.Rendered{} = rendered, opts \\ []) do
problems(rendered, @markers ++ List.wrap(Keyword.get(opts, :forbid, [])))
end
# ── Rendering ───────────────────────────────────────────────────────
defp rows(opts) do
only = Keyword.get(opts, :only)
except = Keyword.get(opts, :except, [])
transports = Keyword.get(opts, :transports)
Preview.events()
|> Enum.filter(fn row ->
(is_nil(only) or row.event_id in only) and row.event_id not in except and
(is_nil(transports) or row.transport in transports)
end)
end
defp seeds(opts) do
case Keyword.get(opts, :seeds, [1]) do
n when is_integer(n) and n > 0 -> Enum.to_list(1..n)
list when is_list(list) -> list
end
end
defp render_row(row, seed, render_opts) do
case Preview.render(row, :sample, Keyword.put(render_opts, :seed, seed)) do
{:ok, []} -> [{:error, "no channel matched #{describe(row)}"}]
{:ok, rendered} -> Enum.map(rendered, &{:ok, &1})
{:error, reason} -> [{:error, to_string_reason(reason)}]
end
end
# ── Checks ──────────────────────────────────────────────────────────
defp problems(%Preview.Rendered{error: error}, _forbid) when is_binary(error),
do: ["could not render: #{error}"]
defp problems(%Preview.Rendered{} = rendered, forbid) do
envelope_problems(rendered) ++
Enum.flat_map(fields(rendered), fn {field, text} ->
leftovers(field, text, forbid)
end)
end
defp envelope_problems(%{transport: :email} = rendered) do
no_body? = blank?(rendered.html_body) and blank?(rendered.text_body)
[
blank?(rendered.subject) && "empty subject",
no_body? && "neither an HTML nor a text body"
]
|> Enum.filter(&is_binary/1)
end
defp envelope_problems(_rendered), do: []
defp fields(rendered) do
[
subject: rendered.subject,
html_body: without_styles(rendered.html_body),
text_body: rendered.text_body,
title: rendered.title,
message: rendered.message
]
|> Enum.filter(fn {_field, text} -> is_binary(text) end)
end
defp leftovers(field, text, forbid) do
forbid
|> Enum.filter(&leftover?(text, &1))
|> Enum.map(fn marker -> "#{field} contains `#{marker}`: …#{excerpt(text, marker)}…" end)
end
# `{@` inside a `#{@...}` is the same finding as the `#{`, reported once.
defp leftover?(text, "{@"), do: Regex.match?(~r/(?<!#)\{@/, text)
defp leftover?(text, marker), do: String.contains?(text, marker)
defp excerpt(text, marker) do
case String.split(text, marker, parts: 2) do
[before, after_marker] ->
(String.slice(before, -20, 20) <> marker <> String.slice(after_marker, 0, 60))
|> String.replace(~r/\s+/, " ")
|> String.trim()
_ ->
""
end
end
# CSS is not template output: a minified `@supports (...){@media ...}` would
# read as a leftover `{@`.
defp without_styles(nil), do: nil
defp without_styles(html), do: String.replace(html, ~r/<style\b.*?<\/style>/is, "")
defp blank?(nil), do: true
defp blank?(text) when is_binary(text), do: String.trim(text) == ""
# ── Reporting ───────────────────────────────────────────────────────
defp format_failure(%{row: row, seed: seed, problems: problems}) do
" #{describe(row)} (seed #{seed})\n" <> Enum.map_join(problems, "\n", &(" - " <> &1))
end
defp describe(row) do
variant = if row.variant, do: " variant #{row.variant}", else: ""
"#{row.event_id} #{row.transport}/#{row.audience}#{variant}"
end
defp to_string_reason(reason) when is_binary(reason), do: reason
defp to_string_reason(reason), do: inspect(reason)
end