diff --git a/lib/ash_jido/error.ex b/lib/ash_jido/error.ex index 44d1c18..f70d165 100644 --- a/lib/ash_jido/error.ex +++ b/lib/ash_jido/error.ex @@ -66,7 +66,7 @@ defmodule AshJido.Error do @spec extract_underlying_errors(Exception.t()) :: [Exception.t()] def extract_underlying_errors(ash_error) do cond do - Map.has_key?(ash_error, :errors) and is_list(ash_error.errors) -> + Map.has_key?(ash_error, :errors) and is_list(ash_error.errors) and ash_error.errors != [] -> ash_error.errors Map.has_key?(ash_error, :error) -> diff --git a/mix.exs b/mix.exs index 65bf1e2..2a6bd66 100644 --- a/mix.exs +++ b/mix.exs @@ -60,9 +60,10 @@ defmodule AshJido.MixProject do [ # Runtime dependencies {:ash, "~> 3.12"}, - {:jido, "~> 2.0.0-rc"}, - {:jido_action, "~> 2.0.0-rc"}, - {:jido_signal, "~> 2.0.0-rc"}, + {:jido, "~> 2.1"}, + {:jido_action, "~> 2.1"}, + {:jido_signal, "~> 2.1"}, + {:libgraph, "~> 0.16.0", override: true}, {:splode, "~> 0.3"}, {:zoi, "~> 0.14"}, diff --git a/test/ash_jido/error_test.exs b/test/ash_jido/error_test.exs new file mode 100644 index 0000000..1069050 --- /dev/null +++ b/test/ash_jido/error_test.exs @@ -0,0 +1,254 @@ +defmodule AshJido.ErrorTest do + use ExUnit.Case, async: true + + alias AshJido.Error + + describe "from_ash/1" do + test "converts Ash.Error.Invalid to validation_error" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"} + ] + } + + result = Error.from_ash(ash_error) + + assert %Jido.Action.Error.InvalidInputError{} = result + assert result.message =~ "is required" + assert result.details.ash_error == ash_error + assert result.details.fields == %{name: ["is required"]} + end + + test "converts Ash.Error.Forbidden to execution_error with forbidden reason" do + ash_error = %Ash.Error.Forbidden{ + errors: [ + %Ash.Error.Forbidden.Policy{message: "not authorized"} + ] + } + + result = Error.from_ash(ash_error) + + assert %Jido.Action.Error.ExecutionFailureError{} = result + assert result.details.reason == :forbidden + assert result.details.ash_error == ash_error + end + + test "converts Ash.Error.Framework to internal_error" do + ash_error = %Ash.Error.Framework{ + errors: [ + %Ash.Error.Framework.InvalidReturn{message: "invalid return"} + ] + } + + result = Error.from_ash(ash_error) + + assert %Jido.Action.Error.InternalError{} = result + assert result.details.ash_error == ash_error + end + + test "converts Ash.Error.Unknown to internal_error" do + ash_error = %Ash.Error.Unknown{ + errors: [ + %Ash.Error.Unknown.UnknownError{error: "something went wrong"} + ] + } + + result = Error.from_ash(ash_error) + + assert %Jido.Action.Error.InternalError{} = result + assert result.details.ash_error == ash_error + end + + test "converts generic exception to execution_error" do + ash_error = %RuntimeError{message: "runtime error"} + + result = Error.from_ash(ash_error) + + assert %Jido.Action.Error.ExecutionFailureError{} = result + assert result.details.ash_error == ash_error + end + + test "preserves underlying errors in details" do + underlying = [%Ash.Error.Changeset.Required{field: :email, message: "is required"}] + ash_error = %Ash.Error.Invalid{errors: underlying} + + result = Error.from_ash(ash_error) + + assert length(result.details.underlying_errors) == 1 + assert hd(result.details.underlying_errors).__struct__ == Ash.Error.Changeset.Required + end + end + + describe "extract_underlying_errors/1" do + test "extracts errors list from Ash error" do + errors = [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"}, + %Ash.Error.Changeset.Required{field: :email, message: "is required"} + ] + + ash_error = %Ash.Error.Invalid{errors: errors} + + result = Error.extract_underlying_errors(ash_error) + + assert length(result) == 2 + end + + test "extracts single error from error field" do + inner_error = %Ash.Error.Changeset.Required{field: :name, message: "is required"} + ash_error = %Ash.Error.Unknown{error: inner_error, errors: []} + + result = Error.extract_underlying_errors(ash_error) + + assert length(result) == 1 + assert hd(result) == inner_error + end + + test "returns empty list when no errors present" do + ash_error = %Ash.Error.Invalid{errors: []} + + result = Error.extract_underlying_errors(ash_error) + + assert result == [] + end + + test "returns empty list for exception without errors field" do + exception = %RuntimeError{message: "test"} + + result = Error.extract_underlying_errors(exception) + + assert result == [] + end + end + + describe "extract_field_errors/1" do + test "extracts field errors from changeset errors" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"}, + %Ash.Error.Changeset.InvalidAttribute{field: :email, message: "is invalid"} + ] + } + + result = Error.extract_field_errors(ash_error) + + assert result.name == ["is required"] + assert result.email == ["is invalid"] + end + + test "extracts field errors from path-based errors" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.InvalidAttribute{ + path: [:user, :profile, :name], + message: "is too short" + } + ] + } + + result = Error.extract_field_errors(ash_error) + + assert result.name == ["is too short"] + end + + test "groups multiple errors per field" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"}, + %Ash.Error.Changeset.InvalidAttribute{field: :name, message: "is too short"} + ] + } + + result = Error.extract_field_errors(ash_error) + + assert result.name == ["is required", "is too short"] + end + + test "returns empty map for errors without field information" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Framework.InvalidReturn{message: "invalid"} + ] + } + + result = Error.extract_field_errors(ash_error) + + assert result == %{} + end + end + + describe "extract_changeset_errors/1" do + test "extracts changeset-related errors" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"}, + %Ash.Error.Changeset.InvalidAttribute{field: :email, message: "is invalid"}, + %Ash.Error.Framework.InvalidReturn{message: "framework error"} + ] + } + + result = Error.extract_changeset_errors(ash_error) + + assert length(result) == 2 + + changeset_modules = Enum.map(result, & &1.type) + assert Ash.Error.Changeset.Required in changeset_modules + assert Ash.Error.Changeset.InvalidAttribute in changeset_modules + end + + test "extracts validation-related errors" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Validation.InvalidAttribute{field: :age, message: "must be positive"} + ] + } + + result = Error.extract_changeset_errors(ash_error) + + assert length(result) == 1 + assert hd(result).type == Ash.Error.Validation.InvalidAttribute + end + + test "includes error details in result" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"} + ] + } + + result = Error.extract_changeset_errors(ash_error) + + assert hd(result).message =~ "is required" + assert is_map(hd(result).details) + end + + test "returns empty list when no changeset errors" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Framework.InvalidReturn{message: "framework error"} + ] + } + + result = Error.extract_changeset_errors(ash_error) + + assert result == [] + end + end + + describe "build_details/1" do + test "builds complete details map with all error information" do + ash_error = %Ash.Error.Invalid{ + errors: [ + %Ash.Error.Changeset.Required{field: :name, message: "is required"}, + %Ash.Error.Changeset.InvalidAttribute{field: :email, message: "is invalid"} + ] + } + + result = Error.from_ash(ash_error) + + assert result.details.ash_error == ash_error + assert length(result.details.underlying_errors) == 2 + assert map_size(result.details.fields) == 2 + assert length(result.details.changeset_errors) == 2 + end + end +end diff --git a/test/ash_jido/sensor_dispatch_bridge_test.exs b/test/ash_jido/sensor_dispatch_bridge_test.exs new file mode 100644 index 0000000..ac648dc --- /dev/null +++ b/test/ash_jido/sensor_dispatch_bridge_test.exs @@ -0,0 +1,171 @@ +defmodule AshJido.SensorDispatchBridgeTest do + use ExUnit.Case, async: true + + alias AshJido.SensorDispatchBridge + + describe "forward/2" do + test "returns error when runtime is not available (dead pid)" do + # Create a process and kill it + pid = spawn(fn -> nil end) + # Ensure process dies + Process.sleep(10) + + assert {:error, :runtime_unavailable} = SensorDispatchBridge.forward({:signal, nil}, pid) + end + + test "returns error when named process is not registered" do + assert {:error, :runtime_unavailable} = + SensorDispatchBridge.forward({:signal, nil}, :nonexistent_runtime) + end + + test "returns error for invalid signal message format" do + # Start a mock runtime + pid = spawn(fn -> receive_loop() end) + + assert {:error, :invalid_signal_message} = + SensorDispatchBridge.forward({:invalid, nil}, pid) + end + + test "successfully forwards signal to runtime" do + # Start a mock runtime that accepts signals + parent = self() + pid = spawn(fn -> signal_receiver_loop(parent) end) + + signal = Jido.Signal.new("test.signal", %{data: "value"}) + + assert :ok = SensorDispatchBridge.forward(signal, pid) + assert_receive {:signal_received, ^signal}, 500 + end + + test "successfully forwards signal wrapped in tuple" do + parent = self() + pid = spawn(fn -> signal_receiver_loop(parent) end) + + signal = Jido.Signal.new("test.signal", %{data: "value"}) + + assert :ok = SensorDispatchBridge.forward({:signal, signal}, pid) + assert_receive {:signal_received, ^signal}, 500 + end + + test "successfully forwards signal wrapped in ok tuple" do + parent = self() + pid = spawn(fn -> signal_receiver_loop(parent) end) + + signal = Jido.Signal.new("test.signal", %{data: "value"}) + + assert :ok = SensorDispatchBridge.forward({:signal, {:ok, signal}}, pid) + assert_receive {:signal_received, ^signal}, 500 + end + end + + describe "forward_many/2" do + test "forwards multiple signals and counts successes" do + parent = self() + pid = spawn(fn -> signal_receiver_loop(parent) end) + + signal1 = Jido.Signal.new("test.signal1", %{id: 1}) + signal2 = Jido.Signal.new("test.signal2", %{id: 2}) + signal3 = Jido.Signal.new("test.signal3", %{id: 3}) + + messages = [ + signal1, + {:signal, signal2}, + {:signal, {:ok, signal3}} + ] + + result = SensorDispatchBridge.forward_many(messages, pid) + + assert result.forwarded == 3 + assert result.errors == [] + + assert_receive {:signal_received, ^signal1}, 500 + assert_receive {:signal_received, ^signal2}, 500 + assert_receive {:signal_received, ^signal3}, 500 + end + + test "tracks errors for invalid signals" do + parent = self() + pid = spawn(fn -> signal_receiver_loop(parent) end) + + signal = Jido.Signal.new("test.signal", %{}) + invalid_message = {:invalid, "format"} + + messages = [signal, invalid_message] + + result = SensorDispatchBridge.forward_many(messages, pid) + + assert result.forwarded == 1 + assert length(result.errors) == 1 + assert hd(result.errors).reason == :invalid_signal_message + assert hd(result.errors).message == invalid_message + end + + test "tracks errors for unavailable runtime" do + dead_pid = spawn(fn -> nil end) + Process.sleep(10) + + signal = Jido.Signal.new("test.signal", %{}) + + result = SensorDispatchBridge.forward_many([signal], dead_pid) + + assert result.forwarded == 0 + assert length(result.errors) == 1 + assert hd(result.errors).reason == :runtime_unavailable + end + + test "returns empty result for empty list" do + pid = spawn(fn -> receive_loop() end) + + result = SensorDispatchBridge.forward_many([], pid) + + assert result.forwarded == 0 + assert result.errors == [] + end + end + + describe "forward_or_ignore/2" do + test "returns :ok on successful forward" do + parent = self() + pid = spawn(fn -> signal_receiver_loop(parent) end) + + signal = Jido.Signal.new("test.signal", %{}) + + assert :ok = SensorDispatchBridge.forward_or_ignore(signal, pid) + end + + test "returns :ignored for invalid signal message" do + pid = spawn(fn -> receive_loop() end) + + assert :ignored = SensorDispatchBridge.forward_or_ignore({:invalid, nil}, pid) + end + + test "returns error for unavailable runtime" do + dead_pid = spawn(fn -> nil end) + Process.sleep(10) + + signal = Jido.Signal.new("test.signal", %{}) + + assert {:error, :runtime_unavailable} = + SensorDispatchBridge.forward_or_ignore(signal, dead_pid) + end + end + + # Helper functions for test processes + + defp receive_loop do + receive do + _ -> receive_loop() + end + end + + defp signal_receiver_loop(parent) do + receive do + {:signal, signal} -> + send(parent, {:signal_received, signal}) + signal_receiver_loop(parent) + + _ -> + signal_receiver_loop(parent) + end + end +end diff --git a/test/ash_jido/signal_emitter_test.exs b/test/ash_jido/signal_emitter_test.exs new file mode 100644 index 0000000..c06453f --- /dev/null +++ b/test/ash_jido/signal_emitter_test.exs @@ -0,0 +1,469 @@ +defmodule AshJido.SignalEmitterTest do + use ExUnit.Case, async: true + + alias AshJido.SignalEmitter + alias Jido.Action.Error + + defmodule TestResource do + defstruct [:id, :name] + end + + describe "validate_dispatch_config/5" do + test "returns :ok for non-mutating actions regardless of config" do + context = %{} + jido_config = %{emit_signals?: true} + + assert :ok = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :read, + :read + ) + + assert :ok = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :custom, + :custom_action + ) + end + + test "returns :ok when emit_signals? is false" do + context = %{} + jido_config = %{emit_signals?: false, signal_dispatch: nil} + + assert :ok = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :create, + :create + ) + end + + test "returns error when emit_signals? is true but no dispatch configured" do + context = %{domain: AshJido.Test.Domain} + jido_config = %{emit_signals?: true, signal_dispatch: nil} + + assert {:error, %Error.InvalidInputError{} = error} = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :create, + :create + ) + + assert error.message =~ "signal dispatch configuration is required" + assert error.details.field == :signal_dispatch + end + + test "validates dispatch configuration from jido_config" do + context = %{} + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()} + } + + assert :ok = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :create, + :create + ) + end + + test "validates dispatch configuration from context" do + context = %{signal_dispatch: {:pid, target: self()}} + jido_config = %{emit_signals?: true, signal_dispatch: nil} + + assert :ok = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :create, + :create + ) + end + + test "returns error for invalid dispatch configuration" do + context = %{} + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:invalid, option: "value"} + } + + assert {:error, %Error.InvalidInputError{} = error} = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :create, + :create + ) + + assert error.message =~ "invalid signal dispatch configuration" + assert error.details.field == :signal_dispatch + end + + test "context dispatch overrides jido_config dispatch" do + context = %{signal_dispatch: {:pid, target: self()}} + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:invalid, option: "value"} + } + + # Context's valid dispatch should override jido_config's invalid one + assert :ok = + SignalEmitter.validate_dispatch_config( + context, + jido_config, + TestResource, + :create, + :create + ) + end + end + + describe "resolve_dispatch_config/2" do + test "returns context dispatch when present" do + context = %{signal_dispatch: {:pid, target: self()}} + jido_config = %{signal_dispatch: {:bus, name: :test}} + + assert {:pid, target: _} = SignalEmitter.resolve_dispatch_config(context, jido_config) + end + + test "returns jido_config dispatch when context has no dispatch" do + context = %{} + jido_config = %{signal_dispatch: {:bus, name: :test}} + + assert {:bus, name: :test} = SignalEmitter.resolve_dispatch_config(context, jido_config) + end + + test "returns nil when neither has dispatch" do + context = %{} + jido_config = %{signal_dispatch: nil} + + assert nil == SignalEmitter.resolve_dispatch_config(context, jido_config) + end + end + + describe "emit_notifications/5" do + test "emits notification as signal" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "123", name: "Test"}, + metadata: %{} + } + + result = + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + assert result.sent == 1 + assert result.failed == [] + + assert_receive {:signal, %Jido.Signal{}}, 500 + end + + test "tracks failed emissions" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + # Invalid dispatch that will fail + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:invalid, target: nil}, + signal_type: nil, + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "123", name: "Test"}, + metadata: %{} + } + + result = + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + assert result.sent == 0 + assert length(result.failed) == 1 + end + + test "handles empty notifications list" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + result = SignalEmitter.emit_notifications([], context, resource, action_name, jido_config) + + assert result.sent == 0 + assert result.failed == [] + end + + test "handles multiple notifications with mixed success/failure" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + # This will work for first notification, fail for second + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + notification1 = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "1", name: "First"}, + metadata: %{} + } + + # Note: Since we're using the same valid dispatch, both should succeed + # To test failure, we'd need to mock or use a different dispatch + notifications = [notification1] + + result = + SignalEmitter.emit_notifications( + notifications, + context, + resource, + action_name, + jido_config + ) + + assert result.sent == 1 + assert result.failed == [] + end + + test "uses custom signal type when provided" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: "custom.event.type", + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "123", name: "Test"}, + metadata: %{} + } + + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + assert_receive {:signal, %Jido.Signal{type: "custom.event.type"}}, 500 + end + + test "uses custom signal source when provided" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: "/custom/source/path" + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "123", name: "Test"}, + metadata: %{} + } + + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + assert_receive {:signal, %Jido.Signal{source: "/custom/source/path"}}, 500 + end + end + + describe "default signal type and source generation" do + test "generates correct default signal type from resource and action" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "123", name: "Test"}, + metadata: %{} + } + + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + # Default type should be "ash_jido.user.create" + assert_receive {:signal, %Jido.Signal{type: "ash_jido.user.create"}}, 500 + end + + test "generates correct default signal source from resource" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "123", name: "Test"}, + metadata: %{} + } + + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + # Default source should be "/ash_jido/ash_jido/test/user" + assert_receive {:signal, %Jido.Signal{source: "/ash_jido/ash_jido/test/user"}}, 500 + end + + test "extracts subject from data with id" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{id: "user-123", name: "Test"}, + metadata: %{} + } + + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + assert_receive {:signal, %Jido.Signal{subject: "user-123"}}, 500 + end + + test "handles data without id for subject" do + context = %{} + resource = AshJido.Test.User + action_name = :create + + jido_config = %{ + emit_signals?: true, + signal_dispatch: {:pid, target: self()}, + signal_type: nil, + signal_source: nil + } + + notification = %Ash.Notifier.Notification{ + resource: resource, + action: %{type: :create}, + data: %{name: "Test"}, + metadata: %{} + } + + SignalEmitter.emit_notifications( + [notification], + context, + resource, + action_name, + jido_config + ) + + assert_receive {:signal, %Jido.Signal{subject: nil}}, 500 + end + end +end diff --git a/test/integration/telemetry_test.exs b/test/integration/telemetry_test.exs index 56865f4..2ef3525 100644 --- a/test/integration/telemetry_test.exs +++ b/test/integration/telemetry_test.exs @@ -119,7 +119,7 @@ defmodule AshJido.TelemetryTest do result = ResourceWithTelemetry.Jido.Explode.run(%{}, %{domain: Domain}) - assert {:error, %Jido.Action.Error.ExecutionFailureError{}} = result + assert {:error, %Jido.Action.Error.InternalError{}} = result assert_receive {:telemetry_event, [:jido, :action, :ash_jido, :start], _start, start_meta} assert start_meta.generated_module == ResourceWithTelemetry.Jido.Explode