Current section
Files
Jump to
Current section
Files
lib/cyclium/test/workflow_case.ex
defmodule Cyclium.Test.WorkflowCase do
@moduledoc """
Test helpers for validating workflow definitions.
Checks DAG validity, step input functions, failure policy coverage,
and structural correctness for both compiled and dynamic workflows.
## Usage
defmodule MyApp.Workflows.VendorOnboardingTest do
use ExUnit.Case, async: true
use Cyclium.Test.WorkflowCase
test "workflow is valid" do
assert_valid_workflow(MyApp.Workflows.VendorOnboarding)
end
test "step inputs don't crash" do
assert_step_inputs_safe(MyApp.Workflows.VendorOnboarding,
trigger: %{"vendor_id" => "v123"}
)
end
end
"""
defmacro __using__(_opts) do
quote do
import Cyclium.Test.WorkflowCase
end
end
@doc """
Assert that a workflow module has a valid definition: trigger set,
steps form a DAG, no duplicate IDs, all depends_on references exist.
"""
defmacro assert_valid_workflow(workflow_module) do
quote bind_quoted: [workflow_module: workflow_module] do
Cyclium.Test.WorkflowCase.validate_workflow!(workflow_module)
end
end
@doc """
Assert that all failure policies reference existing steps and use
valid policy types.
"""
defmacro assert_failure_policies_valid(workflow_module) do
quote bind_quoted: [workflow_module: workflow_module] do
Cyclium.Test.WorkflowCase.validate_failure_policies!(workflow_module)
end
end
@doc """
Assert that every step has a failure policy defined. Useful for
enforcing that teams explicitly handle failures for all steps.
"""
defmacro assert_failure_policies_complete(workflow_module) do
quote bind_quoted: [workflow_module: workflow_module] do
Cyclium.Test.WorkflowCase.validate_failure_policies_complete!(workflow_module)
end
end
@doc """
Assert that all step input functions execute without crashing for
the given trigger payload. Uses empty prior results for root steps
and mock prior results for dependent steps.
## Options
* `:trigger` — trigger payload map (required)
* `:prior` — mock prior step results (default: auto-generated empty maps)
"""
defmacro assert_step_inputs_safe(workflow_module, opts) do
quote bind_quoted: [workflow_module: workflow_module, opts: opts] do
Cyclium.Test.WorkflowCase.validate_step_inputs!(workflow_module, opts)
end
end
# --- Implementation functions ---
@doc false
def validate_workflow!(workflow_module) do
Code.ensure_loaded!(workflow_module)
unless function_exported?(workflow_module, :__workflow_config__, 0) do
raise ArgumentError,
message:
"#{inspect(workflow_module)} must define __workflow_config__/0 (use Cyclium.Workflow)"
end
config = workflow_module.__workflow_config__()
unless config.trigger do
raise ArgumentError, message: "#{inspect(workflow_module)} must declare a trigger"
end
validate_workflow_trigger!(workflow_module, config.trigger)
unless map_size(config.steps) > 0 do
raise ArgumentError, message: "#{inspect(workflow_module)} must define at least one step"
end
Enum.each(config.steps, fn {step_id, step} ->
unless step.actor do
raise ArgumentError, message: "Step #{inspect(step_id)}: must declare an actor"
end
unless step.expectation do
raise ArgumentError, message: "Step #{inspect(step_id)}: must declare an expectation"
end
unless is_atom(step.expectation) do
raise ArgumentError,
message: "Step #{inspect(step_id)}: expectation must be an atom"
end
end)
adj = Map.new(config.steps, fn {id, step} -> {id, step.depends_on} end)
:ok = Cyclium.Workflow.DAG.validate!(adj)
step_ids = MapSet.new(Map.keys(config.steps))
Enum.each(config.steps, fn {step_id, step} ->
Enum.each(step.depends_on, fn dep ->
unless MapSet.member?(step_ids, dep) do
raise ArgumentError,
message: "Step #{inspect(step_id)} depends_on unknown step #{inspect(dep)}"
end
end)
end)
:ok
end
@doc false
def validate_failure_policies!(workflow_module) do
config = workflow_module.__workflow_config__()
step_ids = MapSet.new(Map.keys(config.steps))
Enum.each(config.failure_policies, fn {step_id, policy} ->
unless MapSet.member?(step_ids, step_id) do
raise ArgumentError,
message: "Failure policy references unknown step #{inspect(step_id)}"
end
unless policy.policy in [:abort, :retry, :continue] do
raise ArgumentError,
message: "Step #{inspect(step_id)}: invalid failure policy #{inspect(policy.policy)}"
end
if policy.policy == :retry do
if max = Map.get(policy, :max_step_attempts) do
unless is_integer(max) and max > 0 do
raise ArgumentError,
message: "Step #{inspect(step_id)}: max_step_attempts must be positive"
end
end
if backoff = Map.get(policy, :backoff_ms) do
unless is_integer(backoff) and backoff >= 0 do
raise ArgumentError,
message: "Step #{inspect(step_id)}: backoff_ms must be non-negative"
end
end
end
end)
end
@doc false
def validate_failure_policies_complete!(workflow_module) do
config = workflow_module.__workflow_config__()
Enum.each(config.steps, fn {step_id, _step} ->
unless Map.has_key?(config.failure_policies, step_id) do
raise ArgumentError,
message: "Step #{inspect(step_id)} has no failure policy defined"
end
end)
end
@doc false
def validate_step_inputs!(workflow_module, opts) do
trigger = Keyword.fetch!(opts, :trigger)
config = workflow_module.__workflow_config__()
default_prior =
Map.new(config.steps, fn {step_id, _} -> {step_id, %{"status" => "done"}} end)
prior = Keyword.get(opts, :prior, default_prior)
if function_exported?(workflow_module, :__workflow_step_input__, 3) do
Enum.each(config.steps, fn {step_id, _step} ->
try do
result = workflow_module.__workflow_step_input__(step_id, trigger, prior)
unless is_map(result) do
raise ArgumentError,
message:
"Step #{inspect(step_id)} input function must return a map, got: #{inspect(result)}"
end
rescue
e in ArgumentError ->
reraise e, __STACKTRACE__
e ->
raise ArgumentError,
message: "Step #{inspect(step_id)} input function crashed: #{Exception.message(e)}"
end
end)
end
end
@doc false
def validate_workflow_trigger!(workflow_module, trigger) do
case trigger do
{:event, type} when is_binary(type) ->
:ok
{:schedule, ms} when is_integer(ms) and ms > 0 ->
:ok
other ->
raise ArgumentError,
message: "#{inspect(workflow_module)} has invalid trigger: #{inspect(other)}"
end
end
end