# `Snodo.Test.Assertions`
[🔗](https://github.com/joshrotenberg/snodo/blob/v0.4.1/lib/snodo/test/assertions.ex#L1)

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.

# `answer`

```elixir
@type answer() :: map() | (map() -&gt; 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`

```elixir
@type answers() ::
  %{optional(Snodo.Client.input_kind() | String.t()) =&gt; 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`

```elixir
@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.

# `answer_input`

```elixir
@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.

```elixir
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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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!`

```elixir
@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.

```elixir
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`

```elixir
@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`.

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

# `refute_listed`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
