Enact's own suite covers the pipeline mechanics. Write these tests in the host application; they depend on your actions, schemas, and tenancy model. Import only the helpers you need — import Enact.Test, only: [assert_invalid: 2, assert_rejects_empty_strings: 2, build_ctx: 1]. Phoenix DataCase already defines errors_on/1; use that, or call Enact.Test.errors_on/1 if the host has no helper. A blanket import Enact.Test will collide on errors_on/1.
The action registry
The templates enumerate your actions, so maintain one explicit list, plus a completeness check so new actions cannot be omitted from the suite:
defmodule MyApp.Actions do
@actions [
MyApp.Projects.Actions.CreateProject,
MyApp.Projects.Actions.UpdateProject,
MyApp.Projects.Actions.ArchiveProject
# every action, no exceptions
]
def all, do: @actions
endtest "the registry lists every action module" do
{:ok, modules} = :application.get_key(:my_app, :modules)
implemented =
for mod <- modules,
match?({:module, _}, Code.ensure_loaded(mod)),
Enact.Action in List.flatten(
Keyword.get_values(mod.module_info(:attributes), :behaviour)
),
do: mod
assert Enum.sort(implemented) == Enum.sort(MyApp.Actions.all())
end1. Guardrails in CI
The runner checks input schemas lazily on first run. This test moves the check to CI:
test "every input schema passes guardrails" do
for action <- MyApp.Actions.all(), input = action.input(), input != nil do
mode = Keyword.get(action.config(), :mode, :create)
Enact.Guardrails.assert_valid_input_schema!(input, mode: mode)
end
end2. Cross-tenant sweep
Run every action as a tenant-A actor against tenant-B's subject and references. Subjects must return :not_found. Foreign references must produce only the generic "not found" field error; a precise message would reveal that the record exists.
describe "cross-tenant isolation" do
setup do
%{actor: scope_fixture(org: org_fixture()), other_org: org_fixture()}
end
test "tenant-B subjects are not found", %{actor: actor, other_org: other_org} do
foreign_project = project_fixture(org: other_org)
params = %{"id" => foreign_project.public_id, "name" => "hijack"}
assert {:error, %Enact.Error{type: :not_found}} =
Enact.run(MyApp.Projects.Actions.UpdateProject, params, actor: actor)
end
test "tenant-B references produce only the generic message", %{actor: actor, other_org: other_org} do
foreign_user = user_fixture(org: other_org)
# enumerate reference fields from the resolvers/0 manifests
for action <- MyApp.Actions.all(), {_name, {spec, _fetcher}} <- action.resolvers() do
params = valid_params_for(action, actor) |> put_reference(spec, foreign_user.public_id)
changeset = assert_invalid(Enact.run(action, params, actor: actor))
for {_field, messages} <- errors_on(changeset), message <- List.wrap(messages) do
assert message == "not found",
"#{inspect(action)} leaked a precise message for a foreign reference: #{message}"
end
end
end
endvalid_params_for/2 and put_reference/3 are application-specific: a per-action map of known-good params, with the reference field swapped. The scalar spec form is a field atom; the batch form is [embed_field, item_field].
Anonymous-capable actions (anonymous?: true) scope fetchers through the subject's tenant, not the actor. Run each as an anonymous actor against a reference in another tenant:
test "anonymous actions do not resolve references outside the subject's tenant" do
for action <- MyApp.Actions.all(),
Keyword.get(action.config(), :anonymous?, false),
{_name, {spec, _fetcher}} <- action.resolvers() do
subject = public_subject_for(action, org_fixture())
foreign = user_fixture(org: org_fixture())
params =
valid_anonymous_params_for(action, subject)
|> put_reference(spec, foreign.public_id)
changeset = assert_invalid(Enact.run(action, params, actor: :anonymous))
for {_field, messages} <- errors_on(changeset), message <- List.wrap(messages) do
assert message == "not found",
"#{inspect(action)} leaked a precise message for a foreign reference: #{message}"
end
end
endpublic_subject_for/2 and valid_anonymous_params_for/2 are application-specific: a visible-by-slug subject in one tenant, and params that load it.
3. The PATCH matrix (per patch action)
Eight cases per patch action. This example uses an UpdateProject action where name is required, priority is optional, and milestones is an embed:
describe "UpdateProject PATCH semantics" do
setup do
project = project_fixture(name: "Old", priority: 3, milestones: [milestone_fixture()])
%{actor: scope_for(project), project: project}
end
test "omitted keys are untouched", %{actor: actor, project: project} do
{:ok, updated} = Enact.run(UpdateProject, %{"id" => project.public_id, "priority" => 9}, actor: actor)
assert updated.name == "Old"
assert updated.priority == 9
end
test "explicit null clears an optional scalar", ctx do
{:ok, updated} = run_patch(ctx, %{"priority" => nil})
assert updated.priority == nil
end
test "explicit null on a required field is :invalid", ctx do
assert_invalid(run_patch_raw(ctx, %{"name" => nil}), on: :name)
end
test "an omitted required-but-populated field passes validate_required", ctx do
assert {:ok, _} = run_patch_raw(ctx, %{"priority" => 9})
end
test "[] clears the array (and presence-gated validations still run)", ctx do
{:ok, updated} = run_patch(ctx, %{"milestones" => []})
assert updated.milestones == []
end
test "provided-identical persists as a no-op write", ctx do
assert {:ok, updated} = run_patch(ctx, %{"name" => "Old"})
assert updated.name == "Old"
end
test "a provided reference re-resolves; an absent one survives", ctx do
# provided (even identical) → the fetcher runs, re-authorizing the reference
# absent → untouched, no fetcher call
end
end4. The create matrix
test "omitted optionals fall to DB defaults" do
{:ok, project} = Enact.run(CreateProject, %{"name" => "A", "slug" => "a"}, actor: actor)
# the column default applies
assert project.priority == 1
end
test "explicit nil on a required field fails validate_required" do
assert_invalid(
Enact.run(CreateProject, %{"name" => nil, "slug" => "a"}, actor: actor),
on: :name
)
end5. Projection completeness (per patch-mode input module)
If from_subject/1 omits a field, validations can no longer distinguish nil-clears from omissions for that field. This test fails in CI instead:
test "ProjectInput.from_subject/1 is total over scalar fields" do
# every field populated with a non-nil value
project = fully_populated_project_fixture()
base = ProjectInput.from_subject(project)
embeds = ProjectInput.__schema__(:embeds)
for field <- ProjectInput.fields(:patch), field not in embeds do
refute is_nil(Map.get(base, field)),
"from_subject/1 projects no value for #{inspect(field)}"
end
# embeds stay at structural defaults — never seeded
for embed <- embeds do
assert Map.get(base, embed) in [[], nil]
end
end6. Resolver coverage
This test catches reference fields added without a resolver. Without one, the raw public-ID string reaches persistence:
# legitimately opaque _id fields (external references, idempotency keys) —
# exceptions stay visible instead of weakening the rule
@allowlist %{
MyApp.Payments.Actions.RecordExternalCharge => [:provider_charge_id]
}
test "every *_id input field has a resolver" do
for action <- MyApp.Actions.all(), input = action.input(), input != nil do
mode = Keyword.get(action.config(), :mode, :create)
allowed = Map.get(@allowlist, action, [])
covered =
Enum.flat_map(action.resolvers(), fn
{_name, {field, _fetcher}} when is_atom(field) -> [field]
{_name, {[_embed, item_field], _fetcher}} -> [item_field]
end)
for field <- input.fields(mode),
String.ends_with?(Atom.to_string(field), "_id"),
field not in allowed do
assert field in covered,
"#{inspect(action)}: #{inspect(field)} has no resolver and is not allowlisted"
end
end
end7. Doc-schema reconciliation (if you document your API)
Enact has no knowledge of OpenApiSpex. Drift prevention is a host-side comparison of the fields/1 manifests against your documentation source of truth:
test "ProjectInput matches the documented request schema" do
documented = MyAppWeb.Schemas.ProjectCreateRequest.schema().properties |> Map.keys() |> Enum.sort()
actual = ProjectInput.fields(:create) |> Enum.sort()
assert documented == actual,
"API docs and input schema have drifted — change both deliberately"
end8. Empty-string-at-rest drift
Any persistence field whose struct default is "" is an empty-string-at-rest column (NOT NULL DEFAULT ''). Its input cast must preserve "" instead of coalescing it to nil (see recipe 5 in the Recipes guide). This test derives those fields mechanically and fails when one is wired with a plain cast:
# {input module, persistence schema, mode} for every input casting such fields
@empty_string_pairs [
{CustomerInput, Customer, :create},
{CustomerInput, Customer, :patch}
]
defp empty_string_fields(schema) do
for field <- schema.__schema__(:fields),
Map.get(struct(schema), field) == "",
do: field
end
test "empty-string-at-rest fields survive input casting" do
for {input, persistence, mode} <- @empty_string_pairs,
field <- empty_string_fields(persistence),
field in input.fields(mode) do
changeset = input.changeset(struct(input), %{Atom.to_string(field) => ""}, mode)
assert Ecto.Changeset.get_field(changeset, field) == "",
"#{inspect(input)} coalesced \"\" to nil for #{inspect(field)} — " <>
"list it in cast_input/4's keep_empty_strings: option"
end
end9. Empty-string strictness
Ecto's default cast silently coerces "" to nil on every field type. On non-string fields that turns malformed input into a null-clear instruction instead of a cast error. Enact.Test.assert_rejects_empty_strings/3 probes each non-string castable field with "" and fails unless the module reports a cast error:
test "non-string fields reject empty strings" do
for action <- MyApp.Actions.all(), input = action.input(), input != nil do
mode = Keyword.get(action.config(), :mode, :create)
assert_rejects_empty_strings(input, mode)
end
endThe probe checks behavior, not mechanism: it passes for modules using Enact.InputSchema.cast_input/4 and for modules using stock cast with empty_values: []. Pass except: for fields whose custom types accept "" deliberately.