Snodo.Test.Assertions (snodo v0.4.1)

Copy Markdown View Source

ExUnit assertions for testing an MCP server in process.

Import the module in a test case and run requests with a direct client from client!/2 or client_as/3, or with Snodo.Test.dispatch/2:

defmodule MyServerTest do
  use ExUnit.Case, async: true

  import Snodo.Test.Assertions

  test "greets" do
    client = client!(MyServer.runtime())

    result = assert_tool_ok(Snodo.Client.call_tool(client, "greet", %{"name" => "Ada"}))
    assert [%{"text" => "Hello, Ada!"}] = result["content"]
  end
end

Every assertion returns the value it matched, so a test can go on to match its fields. A failed assertion raises ExUnit.AssertionError with the protocol error's code, message, and data, or the isError result's content, in its message. An argument of the wrong type, such as a kind other than :form, :url, :sampling, or :roots, raises ArgumentError.

Responses

The assertions accept a response in any of these forms:

  • what Snodo.Client returns: {:ok, result}, {:input_required, result}, or {:error, %Snodo.Error{}};
  • what Snodo.Test.dispatch/2 returns: {:ok, response} with the JSON-RPC response map;
  • a JSON-RPC response map on its own.

A JSON-RPC response is decoded the way Snodo.Client decodes one, so both paths reach the same assertion.

ExUnit

ExUnit ships with Elixir, so it is on the code path of every Mix project, but snodo does not list it as an application and it is not started outside a test run. Phoenix.ConnTest and Oban.Testing call ExUnit from library code the same way. This module refers to ExUnit only to raise ExUnit.AssertionError when an assertion fails, so it compiles in every environment and nothing in it runs unless a test calls it.

Summary

Types

An answer to one input request: the response map the request expects, or a function that receives the request's params and returns that map.

Answers keyed by input request kind (:form, :url, :sampling, or :roots) or by input request ID. An ID key wins over a kind key.

A response from Snodo.Client, from Snodo.Test.dispatch/2, or a JSON-RPC response map.

Functions

Builds the options that answer an input_required result on the next request.

Asserts that a response is an input_required result.

Asserts that a list includes the entry named name.

Asserts that a response is a JSON-RPC error, such as an authorization refusal or a protocol error.

Asserts that a tools/call succeeded as a response but the tool reported an error, with "isError" => true.

Asserts that a tools/call succeeded and the tool did not report an error.

Builds a direct client for runtime with Snodo.Client.direct/2, and fails the test when the runtime refuses it.

Builds a direct client whose requests run as principal.

Asserts that a list does not include the entry named name, as when an authorization policy hides a component from a principal.

Types

answer()

@type answer() :: map() | (map() -> map())

An answer to one input request: the response map the request expects, or a function that receives the request's params and returns that map.

answers()

@type answers() ::
  %{optional(Snodo.Client.input_kind() | String.t()) => answer()}
  | keyword(answer())

Answers keyed by input request kind (:form, :url, :sampling, or :roots) or by input request ID. An ID key wins over a kind key.

response()

@type response() :: Snodo.Client.response() | {:ok, map()} | {:stream, term()} | map()

A response from Snodo.Client, from Snodo.Test.dispatch/2, or a JSON-RPC response map.

Functions

answer_input(result, answers)

@spec answer_input({:input_required, map()} | map(), answers()) :: keyword()

Builds the options that answer an input_required result on the next request.

Takes the result, or the {:input_required, result} tuple, and answers keyed by input request ID or by kind. Returns input_responses: with an answer for every input request and, when the result carries one, request_state:, ready to pass to Snodo.Client.call_tool/4 or Snodo.Client.request/4. Fails when an input request has no answer.

result = assert_input_required(Snodo.Client.call_tool(client, "deploy"), :form)

Snodo.Client.call_tool(client, "deploy", %{},
  answer_input(result, form: %{"action" => "accept", "content" => %{"confirm" => true}})
)
|> assert_tool_ok()

assert_input_required(response, kind \\ nil)

@spec assert_input_required(response(), Snodo.Client.input_kind() | nil) :: map()

Asserts that a response is an input_required result.

Returns the result map. When kind is given (:form, :url, :sampling, or :roots), at least one of its input requests must be of that kind.

assert_listed(list, name)

@spec assert_listed(response() | Snodo.Client.Page.t() | [map()], String.t()) :: map()

Asserts that a list includes the entry named name.

name matches an entry's "name", "uri", or "uriTemplate". The list is a response from Snodo.Client.list_tools/1 and the other list functions, a Snodo.Client.Page, a response to a list request from Snodo.Client.request/4 or Snodo.Test.dispatch/2, or a plain list. Returns the entry.

assert_refused(response, code \\ nil)

@spec assert_refused(response(), integer() | nil) :: Snodo.Error.t()

Asserts that a response is a JSON-RPC error, such as an authorization refusal or a protocol error.

Returns the Snodo.Error. When code is given, the error must carry it.

A transport error (kind: :transport) fails the assertion: the client reports one when the server's response was malformed or the connection failed, and neither is the server refusing the request.

assert_tool_error(response, text \\ nil)

@spec assert_tool_error(response(), String.t() | Regex.t() | nil) :: map()

Asserts that a tools/call succeeded as a response but the tool reported an error, with "isError" => true.

Returns the result map. When text is given, the text of the result's content must contain it (a string) or match it (a regex).

assert_tool_ok(response)

@spec assert_tool_ok(response()) :: map()

Asserts that a tools/call succeeded and the tool did not report an error.

Returns the result map. Fails with the protocol error for an error response, with the input requests for an input_required result, and with the content for a result with "isError" => true.

client!(runtime, opts \\ [])

@spec client!(Snodo.Server.Runtime.t(), keyword()) :: Snodo.Client.t()

Builds a direct client for runtime with Snodo.Client.direct/2, and fails the test when the runtime refuses it.

Takes the options of Snodo.Client.direct/2, and:

  • :answers - canned answers to input requests, keyed by kind (:form, :url, :sampling, or :roots). Each answer is the response map, or a function of the request's params that returns it. They become :input_handlers, so a call answers the server's input requests and returns the final result. An answer replaces an :input_handlers entry of the same kind.
accepted = %{"action" => "accept", "content" => %{"name" => "Ada"}}
client = client!(MyServer.runtime(), answers: %{form: accepted})

assert_tool_ok(Snodo.Client.call_tool(client, "greet_interactively"))

client_as(runtime, principal, opts \\ [])

@spec client_as(Snodo.Server.Runtime.t(), term(), keyword()) :: Snodo.Client.t()

Builds a direct client whose requests run as principal.

Handlers and authorization policies read principal as context.auth, as they would after a transport authenticated the request. Other options are those of client!/2.

reader = client_as(MyServer.runtime(), %{"role" => "reader"})
assert_refused(Snodo.Client.call_tool(reader, "publish", %{}), -32_003)

refute_listed(list, name)

@spec refute_listed(response() | Snodo.Client.Page.t() | [map()], String.t()) :: [
  map()
]

Asserts that a list does not include the entry named name, as when an authorization policy hides a component from a principal.

Takes the lists assert_listed/2 takes. Returns the list's entries.