Testing Host Applications

Copy Markdown View Source

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
end
test "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())
end

1. 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
end

2. 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
end

valid_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
end

public_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
end

4. 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
  )
end

5. 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
end

6. 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
end

7. 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"
end

8. 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
end

9. 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
end

The 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.