From bcd58c2469c7f24de3e3719ec241b17364b35006 Mon Sep 17 00:00:00 2001 From: Zaf Agent Date: Sat, 21 Mar 2026 06:22:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20push=20notification=20CRUD=20=E2=80=94?= =?UTF-8?q?=20full=20set/get/list/delete=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full push notification config lifecycle: - TaskStore behaviour: set_push_config/2, get_push_config/3, list_push_configs/2, delete_push_config/3 (optional callbacks) - ETS implementation: keyed by {task_id, config_id} - JSONRPC handler callbacks: handle_set_push_config/3, handle_get_push_config/4, handle_list_push_configs/3, handle_delete_push_config/4 (optional, falls back to -32003) - Server dispatch: wires all 4 PascalCase + legacy method names - Client: create_push_config/3, get_push_config/4, list_push_configs/3, delete_push_config/4 - Plug: auto-delegates to TaskStore when configured - New structs: PushNotificationConfig, AuthenticationInfo - New behaviour: PushNotificationSender with HTTP default impl - JSON codec: encode/decode for push notification configs - 37 new tests covering CRUD, JSON codec, and dispatch Verified against a2a.proto HEAD (TaskPushNotificationConfig, AuthenticationInfo, Get/List/Delete request types). Part of #13 (A2A v1.0 Protocol Support) --- lib/a2a/agent.ex | 4 + lib/a2a/authentication_info.ex | 8 + lib/a2a/client.ex | 115 +++++++++++ lib/a2a/json.ex | 43 ++++ lib/a2a/jsonrpc.ex | 116 ++++++++++- lib/a2a/plug.ex | 78 ++++++++ lib/a2a/push_notification_config.ex | 13 ++ lib/a2a/push_notification_sender.ex | 34 ++++ lib/a2a/task_store.ex | 18 +- lib/a2a/task_store/ets.ex | 42 ++++ test/a2a/json_push_config_test.exs | 72 +++++++ test/a2a/jsonrpc_push_test.exs | 218 +++++++++++++++++++++ test/a2a/push_notification_config_test.exs | 40 ++++ test/a2a/task_store/push_config_test.exs | 92 +++++++++ test/support/push_handler.ex | 71 +++++++ 15 files changed, 960 insertions(+), 4 deletions(-) create mode 100644 lib/a2a/authentication_info.ex create mode 100644 lib/a2a/push_notification_config.ex create mode 100644 lib/a2a/push_notification_sender.ex create mode 100644 test/a2a/json_push_config_test.exs create mode 100644 test/a2a/jsonrpc_push_test.exs create mode 100644 test/a2a/push_notification_config_test.exs create mode 100644 test/a2a/task_store/push_config_test.exs create mode 100644 test/support/push_handler.ex diff --git a/lib/a2a/agent.ex b/lib/a2a/agent.ex index a9fb99f..f2acd2f 100644 --- a/lib/a2a/agent.ex +++ b/lib/a2a/agent.ex @@ -354,6 +354,10 @@ defmodule A2A.Agent do {:reply, __MODULE__.agent_card(), state} end + def handle_call(:get_task_store, _from, state) do + {:reply, state.task_store, state} + end + @impl GenServer def handle_cast({:stream_done, task_id, parts}, state) do case A2A.Agent.State.get_task(state, task_id) do diff --git a/lib/a2a/authentication_info.ex b/lib/a2a/authentication_info.ex new file mode 100644 index 0000000..400993a --- /dev/null +++ b/lib/a2a/authentication_info.ex @@ -0,0 +1,8 @@ +defmodule A2A.AuthenticationInfo do + @moduledoc "Authentication details for push notifications." + + @type t :: %__MODULE__{scheme: String.t(), credentials: String.t() | nil} + + @enforce_keys [:scheme] + defstruct [:scheme, :credentials] +end diff --git a/lib/a2a/client.ex b/lib/a2a/client.ex index bf08c3f..62dea76 100644 --- a/lib/a2a/client.ex +++ b/lib/a2a/client.ex @@ -264,6 +264,66 @@ if Code.ensure_loaded?(Req) do end end + @doc "Creates a push notification config for a task." + @spec create_push_config(target(), A2A.PushNotificationConfig.t(), keyword()) :: + {:ok, A2A.PushNotificationConfig.t()} | {:error, term()} + def create_push_config(target, %A2A.PushNotificationConfig{} = config, opts \\ []) do + client = ensure_client(target) + req_opts = take_req_opts(opts) + {:ok, params} = A2A.JSON.encode(config) + body = jsonrpc_request("tasks/pushNotificationConfig/set", params) + + case post(client, body, req_opts) do + {:ok, response} -> decode_jsonrpc_result(response, :push_notification_config) + {:error, _} = error -> error + end + end + + @doc "Gets a push notification config for a task." + @spec get_push_config(target(), String.t(), String.t(), keyword()) :: + {:ok, A2A.PushNotificationConfig.t()} | {:error, term()} + def get_push_config(target, task_id, config_id, opts \\ []) do + client = ensure_client(target) + req_opts = take_req_opts(opts) + params = %{"taskId" => task_id, "id" => config_id} + body = jsonrpc_request("tasks/pushNotificationConfig/get", params) + + case post(client, body, req_opts) do + {:ok, response} -> decode_jsonrpc_result(response, :push_notification_config) + {:error, _} = error -> error + end + end + + @doc "Lists push notification configs for a task." + @spec list_push_configs(target(), String.t(), keyword()) :: + {:ok, [A2A.PushNotificationConfig.t()]} | {:error, term()} + def list_push_configs(target, task_id, opts \\ []) do + client = ensure_client(target) + req_opts = take_req_opts(opts) + params = %{"taskId" => task_id} + body = jsonrpc_request("tasks/pushNotificationConfig/list", params) + + case post(client, body, req_opts) do + {:ok, response} -> decode_push_configs_result(response) + {:error, _} = error -> error + end + end + + @doc "Deletes a push notification config for a task." + @spec delete_push_config(target(), String.t(), String.t(), keyword()) :: + :ok | {:error, term()} + def delete_push_config(target, task_id, config_id, opts \\ []) do + client = ensure_client(target) + req_opts = take_req_opts(opts) + params = %{"taskId" => task_id, "id" => config_id} + body = jsonrpc_request("tasks/pushNotificationConfig/delete", params) + + case post(client, body, req_opts) do + {:ok, response} -> decode_empty_result(response) + {:error, _} = error -> error + end + end + # ------------------------------------------------------------------- # Private — Request building # ------------------------------------------------------------------- @@ -383,6 +443,61 @@ if Code.ensure_loaded?(Req) do {:error, {:unexpected_body, body}} end + defp decode_push_configs_result(%Req.Response{body: body}) when is_map(body) do + decode_push_configs_body(body) + end + + defp decode_push_configs_result(%Req.Response{body: body}) when is_binary(body) do + case Jason.decode(body) do + {:ok, decoded} -> decode_push_configs_body(decoded) + {:error, _} = error -> error + end + end + + defp decode_push_configs_body(%{"error" => error_map}) do + {:error, + %Error{ + code: error_map["code"], + message: error_map["message"], + data: error_map["data"] + }} + end + + defp decode_push_configs_body(%{"result" => %{"configs" => configs}}) do + decoded = + Enum.map(configs, fn c -> + {:ok, config} = A2A.JSON.decode(c, :push_notification_config) + config + end) + + {:ok, decoded} + end + + defp decode_push_configs_body(body), do: {:error, {:unexpected_body, body}} + + defp decode_empty_result(%Req.Response{body: body}) when is_map(body) do + decode_empty_body(body) + end + + defp decode_empty_result(%Req.Response{body: body}) when is_binary(body) do + case Jason.decode(body) do + {:ok, decoded} -> decode_empty_body(decoded) + {:error, _} = error -> error + end + end + + defp decode_empty_body(%{"error" => error_map}) do + {:error, + %Error{ + code: error_map["code"], + message: error_map["message"], + data: error_map["data"] + }} + end + + defp decode_empty_body(%{"result" => _}), do: :ok + defp decode_empty_body(body), do: {:error, {:unexpected_body, body}} + # ------------------------------------------------------------------- # Private — SSE streaming # ------------------------------------------------------------------- diff --git a/lib/a2a/json.ex b/lib/a2a/json.ex index 015a303..77cc77a 100644 --- a/lib/a2a/json.ex +++ b/lib/a2a/json.ex @@ -209,6 +209,22 @@ defmodule A2A.JSON do {:ok, map} end + def encode(%A2A.PushNotificationConfig{} = config) do + map = + %{} + |> put_unless_nil("id", config.id) + |> put_unless_nil("taskId", config.task_id) + |> put_unless_nil("url", config.url) + |> put_unless_nil("token", config.token) + |> put_unless_nil("authentication", encode_authentication_info(config.authentication)) + + {:ok, map} + end + + def encode(%A2A.AuthenticationInfo{} = auth) do + {:ok, encode_authentication_info(auth)} + end + def encode(%{__struct__: mod}) do {:error, {:unsupported_type, mod}} end @@ -499,6 +515,17 @@ defmodule A2A.JSON do end end + def decode(map, :push_notification_config) do + {:ok, + %A2A.PushNotificationConfig{ + id: Map.get(map, "id"), + task_id: Map.get(map, "taskId"), + url: Map.get(map, "url"), + token: Map.get(map, "token"), + authentication: decode_authentication_info(Map.get(map, "authentication")) + }} + end + @doc """ Decodes a JSON map into an Elixir struct, raising on failure. """ @@ -629,6 +656,22 @@ defmodule A2A.JSON do Map.put(map, key, encoded) end + defp encode_authentication_info(nil), do: nil + + defp encode_authentication_info(%A2A.AuthenticationInfo{} = auth) do + %{"scheme" => auth.scheme} + |> put_unless_nil("credentials", auth.credentials) + end + + defp decode_authentication_info(nil), do: nil + + defp decode_authentication_info(map) when is_map(map) do + %A2A.AuthenticationInfo{ + scheme: Map.get(map, "scheme", ""), + credentials: Map.get(map, "credentials") + } + end + # ------------------------------------------------------------------- # Private — Decoding helpers # ------------------------------------------------------------------- diff --git a/lib/a2a/jsonrpc.ex b/lib/a2a/jsonrpc.ex index 31708c1..a2e8e56 100644 --- a/lib/a2a/jsonrpc.ex +++ b/lib/a2a/jsonrpc.ex @@ -74,7 +74,41 @@ defmodule A2A.JSONRPC do @callback handle_list(params :: map(), context :: map()) :: {:ok, map()} | {:error, Error.t()} - @optional_callbacks handle_list: 2 + @doc "Called for push notification config create. Optional." + @callback handle_set_push_config( + A2A.PushNotificationConfig.t(), + params :: map(), + context :: map() + ) :: {:ok, A2A.PushNotificationConfig.t()} | {:error, Error.t()} + + @doc "Called for push notification config get. Optional." + @callback handle_get_push_config( + task_id :: String.t(), + config_id :: String.t(), + params :: map(), + context :: map() + ) :: {:ok, A2A.PushNotificationConfig.t()} | {:error, Error.t()} + + @doc "Called for push notification config list. Optional." + @callback handle_list_push_configs( + task_id :: String.t(), + params :: map(), + context :: map() + ) :: {:ok, [A2A.PushNotificationConfig.t()]} | {:error, Error.t()} + + @doc "Called for push notification config delete. Optional." + @callback handle_delete_push_config( + task_id :: String.t(), + config_id :: String.t(), + params :: map(), + context :: map() + ) :: :ok | {:error, Error.t()} + + @optional_callbacks handle_list: 2, + handle_set_push_config: 3, + handle_get_push_config: 4, + handle_list_push_configs: 3, + handle_delete_push_config: 4 @doc """ Parses a JSON-RPC 2.0 request map and dispatches to the handler. @@ -167,8 +201,77 @@ defmodule A2A.JSONRPC do {:stream, "tasks/resubscribe", req.params, req.id} end - defp dispatch(%Request{method: "tasks/pushNotificationConfig/" <> _} = req, _, _) do - {:reply, Response.error(req.id, Error.push_notification_not_supported())} + defp dispatch(%Request{method: "tasks/pushNotificationConfig/set"} = req, handler, ctx) do + if function_exported?(handler, :handle_set_push_config, 3) do + with {:ok, config} <- decode_push_config(req.params), + {:ok, result} <- + safe_call(fn -> handler.handle_set_push_config(config, req.params, ctx) end), + {:ok, encoded} <- A2A.JSON.encode(result) do + {:reply, Response.success(req.id, encoded)} + else + {:error, %Error{} = error} -> {:reply, Response.error(req.id, error)} + end + else + {:reply, Response.error(req.id, Error.push_notification_not_supported())} + end + end + + defp dispatch(%Request{method: "tasks/pushNotificationConfig/get"} = req, handler, ctx) do + if function_exported?(handler, :handle_get_push_config, 4) do + task_id = req.params["taskId"] + config_id = req.params["id"] + + case safe_call(fn -> + handler.handle_get_push_config(task_id, config_id, req.params, ctx) + end) do + {:ok, config} -> + {:ok, encoded} = A2A.JSON.encode(config) + {:reply, Response.success(req.id, encoded)} + + {:error, %Error{} = error} -> + {:reply, Response.error(req.id, error)} + end + else + {:reply, Response.error(req.id, Error.push_notification_not_supported())} + end + end + + defp dispatch(%Request{method: "tasks/pushNotificationConfig/list"} = req, handler, ctx) do + if function_exported?(handler, :handle_list_push_configs, 3) do + task_id = req.params["taskId"] + + case safe_call(fn -> handler.handle_list_push_configs(task_id, req.params, ctx) end) do + {:ok, configs} -> + encoded_configs = Enum.map(configs, fn c -> A2A.JSON.encode!(c) end) + {:reply, Response.success(req.id, %{"configs" => encoded_configs})} + + {:error, %Error{} = error} -> + {:reply, Response.error(req.id, error)} + end + else + {:reply, Response.error(req.id, Error.push_notification_not_supported())} + end + end + + defp dispatch(%Request{method: "tasks/pushNotificationConfig/delete"} = req, handler, ctx) do + if function_exported?(handler, :handle_delete_push_config, 4) do + task_id = req.params["taskId"] + config_id = req.params["id"] + + try do + case handler.handle_delete_push_config(task_id, config_id, req.params, ctx) do + :ok -> + {:reply, Response.success(req.id, %{})} + + {:error, %Error{} = error} -> + {:reply, Response.error(req.id, error)} + end + rescue + e -> {:reply, Response.error(req.id, Error.internal_error(Exception.message(e)))} + end + else + {:reply, Response.error(req.id, Error.push_notification_not_supported())} + end end defp dispatch( @@ -185,6 +288,13 @@ defmodule A2A.JSONRPC do # -- helpers --------------------------------------------------------------- + defp decode_push_config(params) do + case A2A.JSON.decode(params, :push_notification_config) do + {:ok, _config} = ok -> ok + {:error, reason} -> {:error, Error.invalid_params(inspect(reason))} + end + end + defp decode_message(params) do case A2A.JSON.decode(params["message"], :message) do {:ok, _message} = ok -> ok diff --git a/lib/a2a/plug.ex b/lib/a2a/plug.ex index 2a48474..181c69d 100644 --- a/lib/a2a/plug.ex +++ b/lib/a2a/plug.ex @@ -309,6 +309,84 @@ if Code.ensure_loaded?(Plug) do end end + @impl A2A.JSONRPC + def handle_set_push_config(config, _params, %{agent: agent}) do + task_store = get_task_store(agent) + + if task_store do + {store_mod, store_ref} = task_store + + config = + if config.id do + config + else + %{config | id: A2A.ID.generate("pcfg")} + end + + case store_mod.set_push_config(store_ref, config) do + {:ok, _} = ok -> ok + {:error, reason} -> {:error, Error.internal_error(inspect(reason))} + end + else + {:error, Error.push_notification_not_supported()} + end + end + + @impl A2A.JSONRPC + def handle_get_push_config(task_id, config_id, _params, %{agent: agent}) do + task_store = get_task_store(agent) + + if task_store do + {store_mod, store_ref} = task_store + + case store_mod.get_push_config(store_ref, task_id, config_id) do + {:ok, _} = ok -> ok + {:error, :not_found} -> {:error, Error.task_not_found("Push config not found")} + end + else + {:error, Error.push_notification_not_supported()} + end + end + + @impl A2A.JSONRPC + def handle_list_push_configs(task_id, _params, %{agent: agent}) do + task_store = get_task_store(agent) + + if task_store do + {store_mod, store_ref} = task_store + store_mod.list_push_configs(store_ref, task_id) + else + {:error, Error.push_notification_not_supported()} + end + end + + @impl A2A.JSONRPC + def handle_delete_push_config(task_id, config_id, _params, %{agent: agent}) do + task_store = get_task_store(agent) + + if task_store do + {store_mod, store_ref} = task_store + + case store_mod.delete_push_config(store_ref, task_id, config_id) do + :ok -> :ok + {:error, :not_found} -> {:error, Error.task_not_found("Push config not found")} + end + else + {:error, Error.push_notification_not_supported()} + end + end + + defp get_task_store(agent) do + try do + case GenServer.call(agent, :get_task_store) do + nil -> nil + store -> store + end + catch + :exit, _ -> nil + end + end + # -- Helpers --------------------------------------------------------------- defp build_call_opts(params, plug_opts) do diff --git a/lib/a2a/push_notification_config.ex b/lib/a2a/push_notification_config.ex new file mode 100644 index 0000000..836aba4 --- /dev/null +++ b/lib/a2a/push_notification_config.ex @@ -0,0 +1,13 @@ +defmodule A2A.PushNotificationConfig do + @moduledoc "A push notification configuration associated with a task." + + @type t :: %__MODULE__{ + id: String.t() | nil, + task_id: String.t() | nil, + url: String.t(), + token: String.t() | nil, + authentication: A2A.AuthenticationInfo.t() | nil + } + + defstruct [:id, :task_id, :url, :token, :authentication] +end diff --git a/lib/a2a/push_notification_sender.ex b/lib/a2a/push_notification_sender.ex new file mode 100644 index 0000000..4e9d51c --- /dev/null +++ b/lib/a2a/push_notification_sender.ex @@ -0,0 +1,34 @@ +defmodule A2A.PushNotificationSender do + @moduledoc "Behaviour for sending push notifications." + + @type payload :: map() + @callback send_notification(A2A.PushNotificationConfig.t(), payload()) :: :ok | {:error, term()} +end + +defmodule A2A.PushNotificationSender.HTTP do + @moduledoc "Default HTTP push notification sender." + @behaviour A2A.PushNotificationSender + + @impl true + def send_notification(config, payload) do + headers = build_headers(config) + body = Jason.encode!(payload) + + case Req.post(config.url, body: body, headers: headers) do + {:ok, %Req.Response{status: status}} when status in 200..299 -> :ok + {:ok, %Req.Response{status: status}} -> {:error, {:unexpected_status, status}} + {:error, reason} -> {:error, reason} + end + end + + defp build_headers(%{authentication: %{scheme: scheme, credentials: creds}}) + when is_binary(scheme) do + [{"content-type", "application/json"}, {"authorization", "#{scheme} #{creds || ""}"}] + end + + defp build_headers(%{token: token}) when is_binary(token) do + [{"content-type", "application/json"}, {"authorization", "Bearer #{token}"}] + end + + defp build_headers(_), do: [{"content-type", "application/json"}] +end diff --git a/lib/a2a/task_store.ex b/lib/a2a/task_store.ex index e00663b..1e20032 100644 --- a/lib/a2a/task_store.ex +++ b/lib/a2a/task_store.ex @@ -61,5 +61,21 @@ defmodule A2A.TaskStore do """ @callback list_all(ref(), opts :: keyword()) :: {:ok, map()} - @optional_callbacks list_all: 2 + @callback set_push_config(ref(), A2A.PushNotificationConfig.t()) :: + {:ok, A2A.PushNotificationConfig.t()} | {:error, term()} + + @callback get_push_config(ref(), task_id :: String.t(), config_id :: String.t()) :: + {:ok, A2A.PushNotificationConfig.t()} | {:error, :not_found} + + @callback list_push_configs(ref(), task_id :: String.t()) :: + {:ok, [A2A.PushNotificationConfig.t()]} + + @callback delete_push_config(ref(), task_id :: String.t(), config_id :: String.t()) :: + :ok | {:error, :not_found} + + @optional_callbacks list_all: 2, + set_push_config: 2, + get_push_config: 3, + list_push_configs: 2, + delete_push_config: 3 end diff --git a/lib/a2a/task_store/ets.ex b/lib/a2a/task_store/ets.ex index 0637650..232a652 100644 --- a/lib/a2a/task_store/ets.ex +++ b/lib/a2a/task_store/ets.ex @@ -70,6 +70,48 @@ defmodule A2A.TaskStore.ETS do |> A2A.Task.Filter.apply(opts) end + # --- Push notification config callbacks --- + + @impl A2A.TaskStore + def set_push_config(table, %A2A.PushNotificationConfig{} = config) do + key = {:push_config, config.task_id, config.id} + :ets.insert(table, {key, config}) + {:ok, config} + end + + @impl A2A.TaskStore + def get_push_config(table, task_id, config_id) do + key = {:push_config, task_id, config_id} + + case :ets.lookup(table, key) do + [{^key, config}] -> {:ok, config} + [] -> {:error, :not_found} + end + end + + @impl A2A.TaskStore + def list_push_configs(table, task_id) do + configs = + :ets.match_object(table, {{:push_config, task_id, :_}, :_}) + |> Enum.map(fn {_key, config} -> config end) + + {:ok, configs} + end + + @impl A2A.TaskStore + def delete_push_config(table, task_id, config_id) do + key = {:push_config, task_id, config_id} + + case :ets.lookup(table, key) do + [{^key, _}] -> + :ets.delete(table, key) + :ok + + [] -> + {:error, :not_found} + end + end + # --- GenServer callbacks --- @impl GenServer diff --git a/test/a2a/json_push_config_test.exs b/test/a2a/json_push_config_test.exs new file mode 100644 index 0000000..c0c5027 --- /dev/null +++ b/test/a2a/json_push_config_test.exs @@ -0,0 +1,72 @@ +defmodule A2A.JSON.PushConfigTest do + use ExUnit.Case, async: true + + alias A2A.PushNotificationConfig + alias A2A.AuthenticationInfo + + describe "encode push notification config" do + test "encodes full config" do + config = %PushNotificationConfig{ + id: "cfg-1", + task_id: "tsk-abc", + url: "https://example.com/hook", + token: "my-token", + authentication: %AuthenticationInfo{scheme: "Bearer", credentials: "secret"} + } + + {:ok, map} = A2A.JSON.encode(config) + assert map["id"] == "cfg-1" + assert map["taskId"] == "tsk-abc" + assert map["authentication"]["scheme"] == "Bearer" + end + + test "encodes minimal config" do + {:ok, map} = A2A.JSON.encode(%PushNotificationConfig{url: "https://x.com/h"}) + assert map["url"] == "https://x.com/h" + refute Map.has_key?(map, "id") + end + end + + describe "decode push notification config" do + test "decodes full config" do + map = %{ + "id" => "cfg-1", + "taskId" => "tsk-abc", + "url" => "https://example.com/hook", + "token" => "tok", + "authentication" => %{"scheme" => "Bearer", "credentials" => "secret"} + } + + {:ok, config} = A2A.JSON.decode(map, :push_notification_config) + assert config.id == "cfg-1" + assert config.authentication.scheme == "Bearer" + end + + test "round-trips through encode/decode" do + config = %PushNotificationConfig{ + id: "cfg-rt", + task_id: "tsk-rt", + url: "https://example.com/hook", + token: "tok", + authentication: %AuthenticationInfo{scheme: "Bearer", credentials: "cred"} + } + + {:ok, encoded} = A2A.JSON.encode(config) + {:ok, decoded} = A2A.JSON.decode(encoded, :push_notification_config) + assert decoded.id == config.id + assert decoded.authentication.scheme == config.authentication.scheme + end + end + + describe "encode AuthenticationInfo" do + test "encodes full auth info" do + {:ok, map} = A2A.JSON.encode(%AuthenticationInfo{scheme: "Bearer", credentials: "tok"}) + assert map["scheme"] == "Bearer" + end + + test "encodes auth without credentials" do + {:ok, map} = A2A.JSON.encode(%AuthenticationInfo{scheme: "Basic"}) + refute Map.has_key?(map, "credentials") + end + end +end diff --git a/test/a2a/jsonrpc_push_test.exs b/test/a2a/jsonrpc_push_test.exs new file mode 100644 index 0000000..e0a7bcd --- /dev/null +++ b/test/a2a/jsonrpc_push_test.exs @@ -0,0 +1,218 @@ +defmodule A2A.JSONRPC.PushTest do + use ExUnit.Case, async: true + + alias A2A.JSONRPC + + @handler A2A.Test.PushHandler + @no_push_handler A2A.Test.Handler + + defp rpc(method, params \\ %{}, id \\ 1) do + %{"jsonrpc" => "2.0", "id" => id, "method" => method, "params" => params} + end + + setup do + store = A2A.Test.PushHandler.init_store() + %{ctx: %{store: store}} + end + + describe "handler without push callbacks" do + test "CreateTaskPushNotificationConfig returns -32003" do + {:reply, resp} = + JSONRPC.handle( + rpc("CreateTaskPushNotificationConfig", %{"url" => "https://x.com/h"}), + @no_push_handler + ) + + assert resp["error"]["code"] == -32_003 + end + + test "GetTaskPushNotificationConfig returns -32003" do + {:reply, resp} = + JSONRPC.handle( + rpc("GetTaskPushNotificationConfig", %{"taskId" => "t1", "id" => "c1"}), + @no_push_handler + ) + + assert resp["error"]["code"] == -32_003 + end + + test "ListTaskPushNotificationConfigs returns -32003" do + {:reply, resp} = + JSONRPC.handle( + rpc("ListTaskPushNotificationConfigs", %{"taskId" => "t1"}), + @no_push_handler + ) + + assert resp["error"]["code"] == -32_003 + end + + test "DeleteTaskPushNotificationConfig returns -32003" do + {:reply, resp} = + JSONRPC.handle( + rpc("DeleteTaskPushNotificationConfig", %{"taskId" => "t1", "id" => "c1"}), + @no_push_handler + ) + + assert resp["error"]["code"] == -32_003 + end + + test "legacy slash-style set returns -32003" do + {:reply, resp} = + JSONRPC.handle(rpc("tasks/pushNotificationConfig/set"), @no_push_handler) + + assert resp["error"]["code"] == -32_003 + end + + test "legacy slash-style get returns -32003" do + {:reply, resp} = + JSONRPC.handle(rpc("tasks/pushNotificationConfig/get"), @no_push_handler) + + assert resp["error"]["code"] == -32_003 + end + + test "legacy slash-style list returns -32003" do + {:reply, resp} = + JSONRPC.handle(rpc("tasks/pushNotificationConfig/list"), @no_push_handler) + + assert resp["error"]["code"] == -32_003 + end + + test "legacy slash-style delete returns -32003" do + {:reply, resp} = + JSONRPC.handle(rpc("tasks/pushNotificationConfig/delete"), @no_push_handler) + + assert resp["error"]["code"] == -32_003 + end + end + + describe "CreateTaskPushNotificationConfig" do + test "creates config and returns it", %{ctx: ctx} do + params = %{ + "taskId" => "tsk-1", + "url" => "https://example.com/hook", + "token" => "my-token", + "authentication" => %{"scheme" => "Bearer", "credentials" => "secret"} + } + + {:reply, resp} = + JSONRPC.handle(rpc("CreateTaskPushNotificationConfig", params), @handler, ctx) + + assert resp["result"]["url"] == "https://example.com/hook" + assert resp["result"]["taskId"] == "tsk-1" + assert resp["result"]["id"] + end + + test "preserves provided ID", %{ctx: ctx} do + params = %{"id" => "my-id", "taskId" => "tsk-1", "url" => "https://x.com/h"} + + {:reply, resp} = + JSONRPC.handle(rpc("CreateTaskPushNotificationConfig", params), @handler, ctx) + + assert resp["result"]["id"] == "my-id" + end + + test "via legacy method name", %{ctx: ctx} do + params = %{"taskId" => "tsk-1", "url" => "https://x.com/h"} + + {:reply, resp} = + JSONRPC.handle(rpc("tasks/pushNotificationConfig/set", params), @handler, ctx) + + assert resp["result"]["url"] == "https://x.com/h" + end + end + + describe "GetTaskPushNotificationConfig" do + test "retrieves a stored config", %{ctx: ctx} do + create = %{"id" => "cfg-1", "taskId" => "tsk-1", "url" => "https://x.com/h"} + + {:reply, _} = + JSONRPC.handle(rpc("CreateTaskPushNotificationConfig", create), @handler, ctx) + + {:reply, resp} = + JSONRPC.handle( + rpc("GetTaskPushNotificationConfig", %{"taskId" => "tsk-1", "id" => "cfg-1"}), + @handler, + ctx + ) + + assert resp["result"]["id"] == "cfg-1" + end + + test "returns error for missing config", %{ctx: ctx} do + {:reply, resp} = + JSONRPC.handle( + rpc("GetTaskPushNotificationConfig", %{"taskId" => "x", "id" => "y"}), + @handler, + ctx + ) + + assert resp["error"]["code"] == -32_001 + end + end + + describe "ListTaskPushNotificationConfigs" do + test "lists configs for a task", %{ctx: ctx} do + for id <- ["cfg-1", "cfg-2"] do + params = %{"id" => id, "taskId" => "tsk-list", "url" => "https://x.com/#{id}"} + JSONRPC.handle(rpc("CreateTaskPushNotificationConfig", params), @handler, ctx) + end + + {:reply, resp} = + JSONRPC.handle( + rpc("ListTaskPushNotificationConfigs", %{"taskId" => "tsk-list"}), + @handler, + ctx + ) + + configs = resp["result"]["configs"] + assert length(configs) == 2 + end + + test "returns empty list for unknown task", %{ctx: ctx} do + {:reply, resp} = + JSONRPC.handle( + rpc("ListTaskPushNotificationConfigs", %{"taskId" => "tsk-none"}), + @handler, + ctx + ) + + assert resp["result"]["configs"] == [] + end + end + + describe "DeleteTaskPushNotificationConfig" do + test "deletes a config", %{ctx: ctx} do + create = %{"id" => "cfg-del", "taskId" => "tsk-del", "url" => "https://x.com/h"} + JSONRPC.handle(rpc("CreateTaskPushNotificationConfig", create), @handler, ctx) + + {:reply, resp} = + JSONRPC.handle( + rpc("DeleteTaskPushNotificationConfig", %{"taskId" => "tsk-del", "id" => "cfg-del"}), + @handler, + ctx + ) + + assert resp["result"] == %{} + + {:reply, get_resp} = + JSONRPC.handle( + rpc("GetTaskPushNotificationConfig", %{"taskId" => "tsk-del", "id" => "cfg-del"}), + @handler, + ctx + ) + + assert get_resp["error"]["code"] == -32_001 + end + + test "returns error for missing config", %{ctx: ctx} do + {:reply, resp} = + JSONRPC.handle( + rpc("DeleteTaskPushNotificationConfig", %{"taskId" => "x", "id" => "y"}), + @handler, + ctx + ) + + assert resp["error"]["code"] == -32_001 + end + end +end diff --git a/test/a2a/push_notification_config_test.exs b/test/a2a/push_notification_config_test.exs new file mode 100644 index 0000000..7546d6a --- /dev/null +++ b/test/a2a/push_notification_config_test.exs @@ -0,0 +1,40 @@ +defmodule A2A.PushNotificationConfigTest do + use ExUnit.Case, async: true + + alias A2A.PushNotificationConfig + alias A2A.AuthenticationInfo + + describe "PushNotificationConfig struct" do + test "creates with all fields" do + config = %PushNotificationConfig{ + id: "cfg-123", + task_id: "tsk-abc", + url: "https://example.com/webhook", + token: "my-token", + authentication: %AuthenticationInfo{scheme: "Bearer", credentials: "secret"} + } + + assert config.id == "cfg-123" + assert config.url == "https://example.com/webhook" + assert config.authentication.scheme == "Bearer" + end + + test "creates with minimal fields" do + config = %PushNotificationConfig{url: "https://example.com/hook"} + assert config.url == "https://example.com/hook" + assert config.id == nil + end + end + + describe "AuthenticationInfo struct" do + test "requires scheme" do + auth = %AuthenticationInfo{scheme: "Bearer", credentials: "tok"} + assert auth.scheme == "Bearer" + end + + test "credentials is optional" do + auth = %AuthenticationInfo{scheme: "Basic"} + assert auth.credentials == nil + end + end +end diff --git a/test/a2a/task_store/push_config_test.exs b/test/a2a/task_store/push_config_test.exs new file mode 100644 index 0000000..a812875 --- /dev/null +++ b/test/a2a/task_store/push_config_test.exs @@ -0,0 +1,92 @@ +defmodule A2A.TaskStore.PushConfigTest do + use ExUnit.Case, async: true + + alias A2A.TaskStore.ETS + alias A2A.PushNotificationConfig + alias A2A.AuthenticationInfo + + setup do + name = :"push_config_test_#{System.unique_integer([:positive])}" + {:ok, _pid} = ETS.start_link(name: name) + %{store: name} + end + + defp make_config(task_id, id, url \\ "https://example.com/hook") do + %PushNotificationConfig{ + id: id, + task_id: task_id, + url: url, + token: "tok-#{id}", + authentication: %AuthenticationInfo{scheme: "Bearer", credentials: "cred-#{id}"} + } + end + + describe "set_push_config/2" do + test "stores a config", %{store: store} do + config = make_config("tsk-1", "cfg-1") + assert {:ok, ^config} = ETS.set_push_config(store, config) + end + + test "overwrites existing config", %{store: store} do + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-1", "https://first.com")) + + {:ok, result} = + ETS.set_push_config(store, make_config("tsk-1", "cfg-1", "https://second.com")) + + assert result.url == "https://second.com" + end + end + + describe "get_push_config/3" do + test "retrieves a stored config", %{store: store} do + config = make_config("tsk-1", "cfg-1") + {:ok, _} = ETS.set_push_config(store, config) + assert {:ok, ^config} = ETS.get_push_config(store, "tsk-1", "cfg-1") + end + + test "returns error for missing config", %{store: store} do + assert {:error, :not_found} = ETS.get_push_config(store, "tsk-1", "cfg-missing") + end + + test "configs are isolated by task_id", %{store: store} do + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-1")) + {:ok, _} = ETS.set_push_config(store, make_config("tsk-2", "cfg-1")) + {:ok, result} = ETS.get_push_config(store, "tsk-1", "cfg-1") + assert result.task_id == "tsk-1" + end + end + + describe "list_push_configs/2" do + test "lists configs for a task", %{store: store} do + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-1")) + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-2")) + {:ok, _} = ETS.set_push_config(store, make_config("tsk-2", "cfg-3")) + {:ok, configs} = ETS.list_push_configs(store, "tsk-1") + assert length(configs) == 2 + end + + test "returns empty list for unknown task", %{store: store} do + assert {:ok, []} = ETS.list_push_configs(store, "tsk-nonexistent") + end + end + + describe "delete_push_config/3" do + test "deletes an existing config", %{store: store} do + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-1")) + assert :ok = ETS.delete_push_config(store, "tsk-1", "cfg-1") + assert {:error, :not_found} = ETS.get_push_config(store, "tsk-1", "cfg-1") + end + + test "returns error for missing config", %{store: store} do + assert {:error, :not_found} = ETS.delete_push_config(store, "tsk-1", "cfg-missing") + end + + test "does not affect other configs", %{store: store} do + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-1")) + {:ok, _} = ETS.set_push_config(store, make_config("tsk-1", "cfg-2")) + :ok = ETS.delete_push_config(store, "tsk-1", "cfg-1") + assert {:error, :not_found} = ETS.get_push_config(store, "tsk-1", "cfg-1") + assert {:ok, _} = ETS.get_push_config(store, "tsk-1", "cfg-2") + end + end +end diff --git a/test/support/push_handler.ex b/test/support/push_handler.ex new file mode 100644 index 0000000..be54da5 --- /dev/null +++ b/test/support/push_handler.ex @@ -0,0 +1,71 @@ +defmodule A2A.Test.PushHandler do + @moduledoc false + @behaviour A2A.JSONRPC + + alias A2A.JSONRPC.Error + + def init_store do + name = :"push_handler_#{System.unique_integer([:positive])}" + :ets.new(name, [:named_table, :public, :set]) + name + end + + @impl true + def handle_send(message, _params, _context) do + task = %A2A.Task{ + id: A2A.ID.generate("tsk"), + status: A2A.Task.Status.new(:completed), + history: [message] + } + + {:ok, task} + end + + @impl true + def handle_get(task_id, _params, _context) do + {:ok, %A2A.Task{id: task_id, status: A2A.Task.Status.new(:working)}} + end + + @impl true + def handle_cancel(task_id, _params, _context) do + {:ok, %A2A.Task{id: task_id, status: A2A.Task.Status.new(:canceled)}} + end + + @impl true + def handle_set_push_config(config, _params, %{store: store}) do + config = + if config.id, do: config, else: %{config | id: A2A.ID.generate("pcfg")} + + :ets.insert(store, {{config.task_id, config.id}, config}) + {:ok, config} + end + + @impl true + def handle_get_push_config(task_id, config_id, _params, %{store: store}) do + case :ets.lookup(store, {task_id, config_id}) do + [{_, config}] -> {:ok, config} + [] -> {:error, Error.task_not_found("Push config not found")} + end + end + + @impl true + def handle_list_push_configs(task_id, _params, %{store: store}) do + configs = + :ets.match_object(store, {{task_id, :_}, :_}) + |> Enum.map(fn {_, config} -> config end) + + {:ok, configs} + end + + @impl true + def handle_delete_push_config(task_id, config_id, _params, %{store: store}) do + case :ets.lookup(store, {task_id, config_id}) do + [{_, _}] -> + :ets.delete(store, {task_id, config_id}) + :ok + + [] -> + {:error, Error.task_not_found("Push config not found")} + end + end +end