diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6e46d..8c840f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ All public functions in `TestServer` have been moved to `TestServer.HTTP`. The HTTP server adapters in `TestServer.HTTPServer.*` have been moved to `TestServer.HTTP.Server.*`. `add/2` has been renamed to `handle/2`, and `websocket_info/2` has been renamed to `websocket_send/2` and now takes options as second argument. -TestServer now has SSH support with `TestServer.SSH`. +TestServer now has SSH support with `TestServer.SSH` and TCP support with `TestServer.TCP`. - Fixed bug where `:match` functions that raised errors always matched in `TestServer.HTTP.handle/2` and `TestServer.HTTP.websocket_handle/2` - Fixed UTF-8 response body handling for `TestServer.HTTP.Server.Httpd` diff --git a/README.md b/README.md index 21d17b2..0013c8b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Features: - HTTP/2 - WebSocket - SSH +- TCP - Built-in TLS with self-signed certificates - Plug route matching @@ -20,6 +21,7 @@ Features: - [`TestServer.HTTP`](lib/test_server/http/README.md) - HTTP/1, HTTP/2, and WebSocket. - [`TestServer.SSH`](lib/test_server/ssh/README.md) - SSH exec and shell. +- [`TestServer.TCP`](lib/test_server/tcp/README.md) - TCP socket endpoints. diff --git a/lib/test_server/tcp.ex b/lib/test_server/tcp.ex new file mode 100644 index 0000000..01e136e --- /dev/null +++ b/lib/test_server/tcp.ex @@ -0,0 +1,389 @@ +defmodule TestServer.TCP do + @external_resource "lib/test_server/tcp/README.md" + @moduledoc "lib/test_server/tcp/README.md" + |> File.read!() + |> String.split("") + |> Enum.fetch!(1) + + import Kernel, except: [send: 2] + + alias TestServer.TCP.{Instance, Server} + + @type connection :: {TestServer.instance(), connection_ref()} + @type connection_ref :: reference() + @type data :: binary() + @type state :: term() + @type handler_fun :: (data(), state() -> + {:reply, iodata(), state()} + | {:ok, state()}) + @type match_fun :: (data(), state() -> boolean()) + @type send_fun :: (state() -> + {:reply, iodata(), state()} + | {:ok, state()}) + + @doc """ + Start a test server TCP instance. + + The instance will be terminated when the test case finishes. + + ## Options + + * `:port` - integer of port number, defaults to random port + that can be opened; + + * `:ipfamily` - The IP address type to use, either `:inet` or + `:inet6`. Defaults to `:inet`; + + * `:listen_options` - options passed to `:gen_tcp.listen/2`. Defaults to + `[:binary, active: false, reuseaddr: true]`. `active: false` is always + used by the server; + + * `:suppress_warning` - Suppresses IO warnings on expectation failures + related to this instance. Defaults to `false`; + + ## Examples + + {:ok, _instance} = TestServer.TCP.start( + listen_options: [:binary, packet: :line] + ) + + :ok = + TestServer.TCP.handle( + match: fn data, _state -> data == "PING\\n" end, + to: fn _data, state -> {:reply, "PONG\\n", state} end + ) + + {:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false, + packet: :line + ]) + + :ok = :gen_tcp.send(socket, "PING\\n") + assert {:ok, "PONG\\n"} = :gen_tcp.recv(socket, 0) + """ + @spec start(keyword()) :: {:ok, TestServer.instance()} + def start(options \\ []) do + TestServer.start_instance(__MODULE__, options, &verify!/1) + end + + defp verify!(instance) do + verify_handlers!(instance) + verify_connections!(instance) + end + + defp verify_handlers!(instance) do + instance + |> Instance.handlers() + |> Enum.reject(& &1.suspended) + |> case do + [] -> + :ok + + active_handlers -> + raise """ + #{TestServer.format_instance(__MODULE__, instance)} did not receive data for these handlers before the test ended: + + #{Instance.format_handlers(active_handlers)} + """ + end + end + + defp verify_connections!(instance) do + instance + |> Instance.connections() + |> Enum.filter(&(not is_nil(&1.ref) and is_nil(&1.pid))) + |> case do + [] -> + :ok + + unused_connections -> + raise """ + #{TestServer.format_instance(__MODULE__, instance)} has connections that were not used: + + #{Instance.format_connections(unused_connections)} + """ + end + end + + @doc """ + Shuts down the current test server TCP instance. + + ## Examples + + {:ok, _instance} = TestServer.TCP.start() + {host, port} = TestServer.TCP.address() + :ok = TestServer.TCP.stop() + + assert :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false]) == + {:error, :econnrefused} + """ + @spec stop() :: :ok | {:error, term()} + def stop, do: stop(TestServer.fetch_instance!(__MODULE__)) + + @doc """ + Shuts down a test server TCP instance. + """ + @spec stop(TestServer.instance()) :: :ok | {:error, term()} + def stop(instance) do + TestServer.ensure_instance_alive!(__MODULE__, instance) + + :ok = Server.stop(Instance.get_options(instance), Instance.connections(instance)) + + TestServer.stop_instance(__MODULE__, instance) + end + + @spec address() :: {binary(), non_neg_integer()} + def address, do: address([]) + + @doc """ + Returns the address for current test server. + + ## Options + + * `:host` - binary host value, it'll be added to inet for IP `127.0.0.1` + and `::1`, defaults to `"localhost"`; + + ## Examples + + {:ok, _instance} = TestServer.TCP.start(port: 4040) + + assert TestServer.TCP.address() == {"localhost", 4040} + assert TestServer.TCP.address(host: "myserver.test") == {"myserver.test", 4040} + """ + @spec address(keyword() | TestServer.instance()) :: {binary(), non_neg_integer()} + def address(options) when is_list(options), + do: address(TestServer.fetch_instance!(__MODULE__), options) + + def address(instance) when is_pid(instance), do: address(instance, []) + + @doc """ + Returns the address for a test server instance. + + See `address/1` for options. + """ + @spec address(TestServer.instance(), keyword()) :: {binary(), non_neg_integer()} + def address(instance, options) when is_pid(instance) and is_list(options) do + TestServer.ensure_instance_alive!(__MODULE__, instance) + + host = TestServer.get_host(options) + port = instance |> Instance.get_options() |> Keyword.fetch!(:port) + + {host, port} + end + + @spec connect() :: {:ok, connection()} + def connect, do: connect([]) + + @doc """ + Registers a connection on the current test server TCP instance. + + Connections are bound to incoming TCP sockets FIFO in registration order: + the first registered connection ref is bound to the first accepted socket, + the second to the second, and so on. If a socket is accepted while no + connection ref is waiting, the framework still accepts it as an anonymous + connection that only matches global handlers (handlers registered without + a connection ref). + + ## Examples + + {:ok, conn} = TestServer.TCP.connect() + + :ok = + TestServer.TCP.handle(conn, + to: fn _data, state -> {:reply, "scoped", state} end + ) + + {:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false + ]) + + :ok = :gen_tcp.send(socket, "ping") + assert {:ok, "scoped"} = :gen_tcp.recv(socket, 0) + """ + @spec connect(keyword()) :: {:ok, connection()} + def connect(options) when is_list(options) do + {:ok, instance} = TestServer.autostart_instance(__MODULE__) + + connect(instance, options) + end + + def connect(instance) when is_pid(instance), do: connect(instance, []) + + @doc """ + Registers a connection on a test server TCP instance. + + See `connect/1` for behaviour. + """ + @spec connect(TestServer.instance(), keyword()) :: {:ok, connection()} + def connect(instance, options) when is_pid(instance) and is_list(options) do + TestServer.ensure_instance_alive!(__MODULE__, instance) + + [_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__) + + {:ok, connection} = Instance.register(instance, {:connect, {options, stacktrace}}) + + {:ok, {instance, connection.ref}} + end + + @spec handle() :: :ok + def handle, do: handle([]) + + @doc """ + Adds a data handler to the current test server TCP instance. + + Handlers are matched FIFO (first in, first out). Any data not matched by a + handler, or any handlers not consumed by data, will raise an error in the + test case. + + A handler registered without a connection (`handle/0` or `handle/1` with + options) is *global* — it matches data on any connection. A handler + registered with a connection (`handle/2` with a `t:connection/0`) is + scoped — it only matches data on that connection. + + The `:to` callback returns `{:reply, data, state}` or `{:ok, state}`. For + reply tuples, the framework calls `:gen_tcp.send/2` for you. + + The `:match` and `:to` callbacks run inside the test server instance + process, so they must not call back into the `TestServer.TCP` API for the + same instance. + + ## Options + + * `:match` - a `t:match_fun/0` function that returns a boolean. Defaults + to matching anything; + + * `:to` - a `t:handler_fun/0` function called when the handler + matches. Defaults to echoing the received data. + + ## Examples + + :ok = + TestServer.TCP.handle( + match: fn data, _state -> data == "PING" end, + to: fn _data, state -> {:reply, "PONG", state} end + ) + + :ok = TestServer.TCP.handle() + + {:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false + ]) + + assert :ok = :gen_tcp.send(socket, "PING") + assert {:ok, "PONG"} = :gen_tcp.recv(socket, 0) + assert :ok = :gen_tcp.send(socket, "echo") + assert {:ok, "echo"} = :gen_tcp.recv(socket, 0) + """ + @spec handle(keyword() | TestServer.instance() | connection()) :: :ok + def handle(options) when is_list(options) do + {:ok, instance} = TestServer.autostart_instance(__MODULE__) + + handle(instance, options) + end + + def handle(instance) when is_pid(instance), do: handle(instance, []) + + def handle({instance, _connection_ref} = connection) when is_pid(instance), + do: handle(connection, []) + + @doc """ + Adds a data handler to a test server TCP instance or connection. + + See `handle/1` for options. When the first argument is a `t:connection/0` + the handler is scoped to that connection; otherwise it is global. + """ + @spec handle(TestServer.instance() | connection(), keyword()) :: :ok + def handle(instance, options) when is_pid(instance) and is_list(options) do + register_handler(instance, nil, options) + end + + def handle({instance, connection_ref}, options) + when is_pid(instance) and is_reference(connection_ref) and is_list(options) do + register_handler(instance, connection_ref, options) + end + + defp register_handler(instance, connection_ref, options) do + TestServer.ensure_instance_alive!(__MODULE__, instance) + + [_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__) + + options = Keyword.put_new(options, :to, &default_handler/2) + + {:ok, _handler} = + Instance.register(instance, {:handle, {connection_ref, options, stacktrace}}) + + :ok + end + + defp default_handler(data, state), do: {:reply, data, state} + + @spec send(connection()) :: :ok + def send({instance, _connection_ref} = connection) when is_pid(instance), + do: send(connection, []) + + @doc """ + Sends data from the current test server TCP instance to a specific + connection. + + The `:to` callback receives the connection state and returns + `{:reply, data, state}` or `{:ok, state}`. For reply tuples, the framework + calls `:gen_tcp.send/2` for you. Raises if the client has disconnected. + + The `:to` callback runs inside the test server instance process, so it must + not call back into the `TestServer.TCP` API for the same instance. + + ## Options + + * `:to` - a `t:send_fun/0` function called with the connection state. + Defaults to sending `"ping"`. + + ## Examples + + {:ok, conn} = TestServer.TCP.connect() + + {:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false + ]) + + :ok = TestServer.TCP.send(conn, to: fn state -> {:reply, "hello", state} end) + assert {:ok, "hello"} = :gen_tcp.recv(socket, 0) + """ + @spec send(connection(), keyword()) :: :ok + def send({instance, connection_ref}, options) + when is_pid(instance) and is_reference(connection_ref) and is_list(options) do + TestServer.ensure_instance_alive!(__MODULE__, instance) + + [_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__) + + options = Keyword.put_new(options, :to, &default_send/1) + + instance + |> Instance.dispatch({:send, {connection_ref, options, stacktrace}}) + |> handle_send_response(instance) + end + + defp default_send(state), do: {:reply, "ping", state} + + defp handle_send_response(:ok, _instance), do: :ok + + defp handle_send_response({:error, :not_connected}, instance) do + raise "#{TestServer.format_instance(__MODULE__, instance)} cannot send to the connection, because no client has connected to it yet" + end + + defp handle_send_response({:error, {exception, stacktrace}}, instance) do + Instance.report_error(instance, {exception, stacktrace}) + :ok + end + + defp handle_send_response({:error, reason}, instance) do + raise "#{TestServer.format_instance(__MODULE__, instance)} could not send data to the connection, because: #{inspect(reason)}" + end +end diff --git a/lib/test_server/tcp/README.md b/lib/test_server/tcp/README.md new file mode 100644 index 0000000..90310eb --- /dev/null +++ b/lib/test_server/tcp/README.md @@ -0,0 +1,221 @@ +# TCP + + + +Mock TCP stream endpoints with FIFO data handlers. + +## Usage + +### Data handlers + +Add FIFO data handlers with `TestServer.TCP.handle/1`: + +```elixir +test "TCP client" do + :ok = + TestServer.TCP.handle( + match: fn data, _state -> data == "PING\n" end, + to: fn _data, state -> {:reply, "PONG\n", state} end + ) + + :ok = TestServer.TCP.handle() + + {:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false, + packet: :line + ]) + + :ok = :gen_tcp.send(socket, "PING\n") + assert {:ok, "PONG\n"} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "echo\n") + assert {:ok, "echo\n"} = :gen_tcp.recv(socket, 0) +end +``` + +The server autostarts when `handle/1` is called. Start it explicitly when you +need custom socket options: + +```elixir +TestServer.TCP.start(listen_options: [:binary, packet: :line]) +``` + +TCP is a stream protocol, so data delivered to handlers follows the configured +`:gen_tcp` packet options. Use `:listen_options` to set framing such as +`packet: :line`, `packet: 4`, or raw stream mode. + +### Targeting a specific connection + +Register a connection with `TestServer.TCP.connect/0` to get a connection ref +you can scope handlers and sends to. Connections are bound to incoming +sockets FIFO: the first registered ref takes the first accepted socket, the +second ref the second socket, and so on. Sockets that arrive while no ref is +waiting are accepted as anonymous connections that only match handlers +registered without a ref. + +```elixir +{:ok, conn1} = TestServer.TCP.connect() +{:ok, conn2} = TestServer.TCP.connect() + +:ok = TestServer.TCP.handle(conn1, to: fn _data, state -> {:reply, "one", state} end) +:ok = TestServer.TCP.handle(conn2, to: fn _data, state -> {:reply, "two", state} end) + +{:ok, socket1} = :gen_tcp.connect(~c"localhost", port, [:binary, active: false]) +{:ok, socket2} = :gen_tcp.connect(~c"localhost", port, [:binary, active: false]) + +:ok = :gen_tcp.send(socket1, "ping") +assert {:ok, "one"} = :gen_tcp.recv(socket1, 0) +:ok = :gen_tcp.send(socket2, "ping") +assert {:ok, "two"} = :gen_tcp.recv(socket2, 0) +``` + +### Matching and replies + +By default, `TestServer.TCP.handle/1` echoes received data: + +```elixir +:ok = TestServer.TCP.handle() +``` + +Use `:match` to select data and `:to` to customize the response: + +```elixir +TestServer.TCP.handle( + match: fn data, _state -> data == "HELLO" end, + to: fn _data, state -> {:reply, "READY", state} end +) +``` + +The two-arity `:to` callback can return: + +```elixir +{:reply, data, state} +{:ok, state} +``` + +The `:match` and `:to` callbacks run inside the test server instance process, +so they must not call back into the `TestServer.TCP` API for the same +instance. + +### Sending data + +Push data from the test server to a specific connection with +`TestServer.TCP.send/2`: + +```elixir +{:ok, conn} = TestServer.TCP.connect() + +{:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false + ]) + +:ok = + TestServer.TCP.send(conn, + to: fn state -> {:reply, "hello", state} end + ) + +assert {:ok, "hello"} = :gen_tcp.recv(socket, 0) +``` + +### Example: a minimal SMTP exchange + +The pieces above compose into a realistic protocol mock. SMTP servers greet +first with a `220` banner, then answer each client command in turn, so this +combines a server-initiated `send/2` with a FIFO sequence of scoped handlers: + +```elixir +test "SMTP client" do + {:ok, conn} = TestServer.TCP.connect() + + # One handler per client command, matched FIFO on the same connection. + :ok = + TestServer.TCP.handle(conn, + match: fn data, _state -> String.starts_with?(data, "EHLO") end, + to: fn _data, state -> {:reply, "250 test.server\r\n", state} end + ) + + :ok = + TestServer.TCP.handle(conn, + match: fn data, _state -> String.starts_with?(data, "MAIL FROM") end, + to: fn _data, state -> {:reply, "250 OK\r\n", state} end + ) + + :ok = + TestServer.TCP.handle(conn, + match: fn data, _state -> String.starts_with?(data, "RCPT TO") end, + to: fn _data, state -> {:reply, "250 OK\r\n", state} end + ) + + :ok = + TestServer.TCP.handle(conn, + match: fn data, _state -> String.starts_with?(data, "DATA") end, + to: fn _data, state -> {:reply, "354 End data with .\r\n", state} end + ) + + # The message body and its `.` terminator arrive as one chunk under raw + # framing, so a single handler matches the trailing `\r\n.\r\n`. + :ok = + TestServer.TCP.handle(conn, + match: fn data, _state -> String.ends_with?(data, "\r\n.\r\n") end, + to: fn _data, state -> {:reply, "250 OK: queued\r\n", state} end + ) + + :ok = + TestServer.TCP.handle(conn, + match: fn data, _state -> String.starts_with?(data, "QUIT") end, + to: fn _data, state -> {:reply, "221 Bye\r\n", state} end + ) + + {:ok, socket} = + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ + :binary, + active: false + ]) + + # SMTP servers greet first. + :ok = + TestServer.TCP.send(conn, + to: fn state -> {:reply, "220 test.server ESMTP\r\n", state} end + ) + + assert {:ok, "220 test.server ESMTP\r\n"} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "EHLO client\r\n") + assert {:ok, "250 test.server\r\n"} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "MAIL FROM:\r\n") + assert {:ok, "250 OK\r\n"} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "RCPT TO:\r\n") + assert {:ok, "250 OK\r\n"} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "DATA\r\n") + assert {:ok, "354" <> _} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "Subject: Hi\r\n\r\nHello there!\r\n.\r\n") + assert {:ok, "250 OK: queued\r\n"} = :gen_tcp.recv(socket, 0) + + :ok = :gen_tcp.send(socket, "QUIT\r\n") + assert {:ok, "221 Bye\r\n"} = :gen_tcp.recv(socket, 0) +end +``` + +### IPv6 + +Use the `:ipfamily` option to test with IPv6: + +```elixir +{:ok, _instance} = TestServer.TCP.start(ipfamily: :inet6) +:ok = TestServer.TCP.handle() + +assert {"localhost", port} = TestServer.TCP.address() + +{:ok, socket} = + :gen_tcp.connect(~c"localhost", port, [:binary, active: false, :inet6]) +``` + + diff --git a/lib/test_server/tcp/instance.ex b/lib/test_server/tcp/instance.ex new file mode 100644 index 0000000..d737637 --- /dev/null +++ b/lib/test_server/tcp/instance.ex @@ -0,0 +1,355 @@ +defmodule TestServer.TCP.Instance do + @moduledoc false + + use GenServer + + alias TestServer.TCP.Server + + def start_link(options) do + GenServer.start_link(__MODULE__, options) + end + + def stop(instance) do + GenServer.stop(instance) + end + + @spec register(TestServer.instance(), {:connect, {keyword(), TestServer.stacktrace()}}) :: + {:ok, %{ref: TestServer.TCP.connection_ref()}} + def register(instance, {:connect, {options, stacktrace}}) do + GenServer.call(instance, {:register, {:connect, {options, stacktrace}}}) + end + + @spec register( + TestServer.instance(), + {:handle, {TestServer.TCP.connection_ref() | nil, keyword(), TestServer.stacktrace()}} + ) :: {:ok, map()} + def register(instance, {:handle, {connection_ref, options, stacktrace}}) do + options[:to] && ensure_function!(options[:to]) + options[:match] && ensure_function!(options[:match]) + + GenServer.call(instance, {:register, {:handle, {connection_ref, options, stacktrace}}}) + end + + defp ensure_function!(fun) when is_function(fun), do: :ok + defp ensure_function!(fun), do: raise(BadFunctionError, term: fun) + + @spec dispatch(TestServer.instance(), {:handle, pid(), binary()}) :: + {:reply, TestServer.TCP.data()} + | :ok + | {:error, :not_found} + | {:error, {term(), TestServer.stacktrace()}} + def dispatch(instance, {:handle, connection_pid, data}) do + GenServer.call(instance, {:dispatch, {:handle, {connection_pid, data}}}) + end + + @spec dispatch( + TestServer.instance(), + {:send, {TestServer.TCP.connection_ref(), keyword(), TestServer.stacktrace()}} + ) :: :ok | {:error, term()} + def dispatch(instance, {:send, {connection_ref, options, stacktrace}}) do + options[:to] && ensure_function!(options[:to]) + + GenServer.call(instance, {:dispatch, {:send, {connection_ref, options, stacktrace}}}) + end + + @spec register_connection(TestServer.instance(), pid(), port()) :: :ok + def register_connection(instance, connection_pid, socket) do + GenServer.call(instance, {:register, {:connection, {connection_pid, socket}}}) + end + + @spec unregister_connection(TestServer.instance(), pid()) :: :ok + def unregister_connection(instance, connection_pid) do + GenServer.cast(instance, {:unregister, {:connection, connection_pid}}) + end + + @spec handlers(TestServer.instance()) :: [map()] + def handlers(instance) do + GenServer.call(instance, :handlers) + end + + @spec connections(TestServer.instance()) :: [map()] + def connections(instance) do + GenServer.call(instance, :connections) + end + + @spec get_options(TestServer.instance()) :: keyword() + def get_options(instance) do + GenServer.call(instance, :options) + end + + @spec format_handlers([map()]) :: binary() + def format_handlers(handlers) do + handlers + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {handler, index} -> + """ + ##{index + 1}: #{inspect(handler.to)} + #{Enum.map_join(handler.stacktrace, "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + end + + @spec format_connections([map()]) :: binary() + def format_connections(connections) do + connections + |> Enum.with_index() + |> Enum.map_join("\n\n", fn {connection, index} -> + """ + ##{index + 1}: #{inspect(connection.ref)} + #{Enum.map_join(connection.stacktrace, "\n ", &Exception.format_stacktrace_entry/1)} + """ + end) + end + + @spec report_error(TestServer.instance(), {struct(), TestServer.stacktrace()}) :: :ok + def report_error(instance, {exception, stacktrace}) do + options = get_options(instance) + caller = Keyword.fetch!(options, :caller) + + unless Keyword.get(options, :suppress_warning, false), + do: IO.warn(Exception.format(:error, exception, stacktrace)) + + ExUnit.OnExitHandler.add(caller, make_ref(), fn -> + reraise exception, stacktrace + end) + + :ok + end + + @impl true + def init(options) do + {:ok, options} = Server.start(self(), options) + + {:ok, %{options: options, handlers: [], connections: []}} + end + + @impl true + def handle_call({:register, {:connect, {_options, stacktrace}}}, _from, state) do + connection = %{ + ref: make_ref(), + stacktrace: stacktrace, + pid: nil, + socket: nil, + state: %{} + } + + {:reply, {:ok, connection}, %{state | connections: state.connections ++ [connection]}} + end + + def handle_call( + {:register, {:handle, {connection_ref, options, stacktrace}}}, + _from, + state + ) do + handler = %{ + ref: make_ref(), + connection_ref: connection_ref, + match: Keyword.get(options, :match), + to: Keyword.fetch!(options, :to), + stacktrace: stacktrace, + suspended: false, + received: [] + } + + {:reply, {:ok, handler}, %{state | handlers: state.handlers ++ [handler]}} + end + + def handle_call({:register, {:connection, {connection_pid, socket}}}, _from, state) do + {_connection, connections} = bind_connection(state.connections, connection_pid, socket) + + {:reply, :ok, %{state | connections: connections}} + end + + def handle_call({:dispatch, {:handle, {connection_pid, data}}}, _from, state) do + case Enum.find(state.connections, &(&1.pid == connection_pid)) do + nil -> + {:reply, {:error, :not_found}, state} + + connection -> + {res, state} = run_handlers(data, connection, state) + {res, state} = update_connection_response(res, connection_pid, state) + + {:reply, res, state} + end + end + + def handle_call( + {:dispatch, {:send, {connection_ref, options, stacktrace}}}, + _from, + state + ) do + to = Keyword.fetch!(options, :to) + + case Enum.find(state.connections, &(&1.ref == connection_ref)) do + %{socket: socket} = connection when not is_nil(socket) -> + {result, state} = + connection + |> run_send_handler(to, stacktrace) + |> update_send_response(connection, state) + + {:reply, result, state} + + _no_connected_client -> + {:reply, {:error, :not_connected}, state} + end + end + + def handle_call(option, _from, state) when option in [:connections, :handlers, :options] do + {:reply, Map.fetch!(state, option), state} + end + + @impl true + def handle_cast({:unregister, {:connection, connection_pid}}, state) do + connections = Enum.reject(state.connections, &(&1.pid == connection_pid)) + + {:noreply, %{state | connections: connections}} + end + + defp bind_connection(connections, connection_pid, socket) do + case Enum.find_index(connections, &(not is_nil(&1.ref) and is_nil(&1.pid))) do + nil -> + connection = %{ + ref: nil, + stacktrace: [], + pid: connection_pid, + socket: socket, + state: %{} + } + + {connection, connections ++ [connection]} + + index -> + connection = %{ + Enum.at(connections, index) + | pid: connection_pid, + socket: socket + } + + {connection, List.replace_at(connections, index, connection)} + end + end + + defp run_handlers(data, connection, state) do + state.handlers + |> fetch_match_index([data, connection.state], fn + %{suspended: true} -> false + %{connection_ref: nil} -> true + %{connection_ref: ref} -> ref == connection.ref + end) + |> case do + {:error, :not_found} -> + {{:error, :not_found}, state} + + {:error, {error, stacktrace}} -> + {{:error, {error, stacktrace}}, state} + + {:ok, index} -> + %{to: handler, stacktrace: stacktrace} = Enum.at(state.handlers, index) + + result = try_run_handler(handler, data, connection.state, stacktrace) + + handlers = + List.update_at(state.handlers, index, fn handler -> + %{handler | suspended: true, received: handler.received ++ [data]} + end) + + {result, %{state | handlers: handlers}} + end + end + + defp fetch_match_index(items, args, callback) do + items + |> Enum.find_index(fn %{match: match} = item -> + callback.(item) && (is_nil(match) || apply(match, args)) + end) + |> case do + nil -> {:error, :not_found} + index -> {:ok, index} + end + rescue + error -> {:error, {error, __STACKTRACE__}} + end + + defp try_run_handler(handler, data, connection_state, stacktrace) do + data + |> run_handler(handler, connection_state) + |> validate_response!(stacktrace) + rescue + error -> {:error, {error, __STACKTRACE__}} + end + + defp run_handler(data, handler, connection_state) when is_function(handler, 2) do + handler.(data, connection_state) + end + + defp run_send_handler(connection, handler, stacktrace) when is_function(handler, 1) do + connection.state + |> handler.() + |> validate_response!(stacktrace) + rescue + error -> {:error, {error, __STACKTRACE__}} + end + + defp validate_response!(response, stacktrace) do + case response do + {:reply, data, state} -> + {:reply, data, state} + + {:ok, state} -> + {:ok, state} + + _other -> + raise """ + Invalid callback response, got: #{inspect(response)}. + + Expected one of the following: + + - {:reply, data, state}, where data is iodata + - {:ok, state} + + #{Enum.map_join(stacktrace, "\n ", &Exception.format_stacktrace_entry/1)} + """ + end + end + + defp update_connection_response({:reply, data, connection_state}, connection_pid, state) do + {{:reply, data}, update_connection_state(state, connection_pid, connection_state)} + end + + defp update_connection_response({:ok, connection_state}, connection_pid, state) do + {:ok, update_connection_state(state, connection_pid, connection_state)} + end + + defp update_connection_response(response, _connection_pid, state) do + {response, state} + end + + defp update_send_response({:reply, data, connection_state}, connection, state) do + send(connection.pid, {:write, data}) + {:ok, update_connection_state(state, connection.pid, connection_state)} + end + + defp update_send_response({:ok, connection_state}, connection, state) do + {:ok, update_connection_state(state, connection.pid, connection_state)} + end + + defp update_send_response({:error, {exception, stacktrace}}, connection, state) do + send(connection.pid, {:write, Exception.format(:error, exception, stacktrace)}) + {{:error, {exception, stacktrace}}, state} + end + + defp update_connection_state(state, connection_pid, connection_state) do + case Enum.find_index(state.connections, &(&1.pid == connection_pid)) do + nil -> + state + + index -> + connections = + List.update_at(state.connections, index, fn connection -> + %{connection | state: connection_state} + end) + + %{state | connections: connections} + end + end +end diff --git a/lib/test_server/tcp/server.ex b/lib/test_server/tcp/server.ex new file mode 100644 index 0000000..1e95cd5 --- /dev/null +++ b/lib/test_server/tcp/server.ex @@ -0,0 +1,202 @@ +defmodule TestServer.TCP.Server do + @moduledoc false + + alias TestServer.TCP.Instance + + @doc false + @spec start(TestServer.instance(), keyword()) :: {:ok, keyword()} + def start(instance, options) do + port = fetch_port!(options) + listen_options = listen_options(options) + + case :gen_tcp.listen(port, listen_options) do + {:ok, listen_socket} -> + {:ok, acceptor_pid} = Task.start_link(fn -> accept_loop(instance, listen_socket) end) + {:ok, port} = :inet.port(listen_socket) + + options = + options + |> Keyword.put(:port, port) + |> Keyword.put(:listen_options, listen_options) + |> Keyword.put(:listen_socket, listen_socket) + |> Keyword.put(:acceptor_pid, acceptor_pid) + + {:ok, options} + + {:error, reason} -> + raise "Could not listen to port #{inspect(port)}, because: #{inspect(reason)}" + end + end + + defp fetch_port!(options) do + port = Keyword.get(options, :port, 0) + + case is_integer(port) and port >= 0 and port <= 65_535 do + true -> port + false -> raise "Invalid port, got: #{inspect(port)}" + end + end + + defp listen_options(options) do + options + |> Keyword.get(:listen_options, [:binary, active: false, reuseaddr: true]) + |> List.wrap() + |> Enum.reject(&match?({:active, _value}, &1)) + |> Kernel.++(active: false) + |> put_ipfamily(Keyword.get(options, :ipfamily, :inet)) + end + + defp put_ipfamily(listen_options, :inet), do: listen_options + defp put_ipfamily(listen_options, ipfamily), do: [ipfamily | listen_options] + + defp accept_loop(instance, listen_socket) do + case :gen_tcp.accept(listen_socket) do + {:ok, socket} -> + pid = + spawn_link(fn -> + receive do + {:socket, socket} -> connection_loop(instance, socket) + end + end) + + :ok = :gen_tcp.controlling_process(socket, pid) + :ok = Instance.register_connection(instance, pid, socket) + Kernel.send(pid, {:socket, socket}) + + accept_loop(instance, listen_socket) + + {:error, :closed} -> + :ok + + {:error, reason} -> + raise "Could not accept TCP connection, because: #{inspect(reason)}" + end + end + + defp connection_loop(instance, socket) do + case :inet.setopts(socket, active: :once) do + :ok -> + await_connection_message(instance, socket) + + {:error, _reason} -> + Instance.unregister_connection(instance, self()) + end + end + + defp await_connection_message(instance, socket) do + receive do + {:tcp, ^socket, data} -> + instance + |> Instance.dispatch({:handle, self(), data}) + |> respond(instance, socket, data) + + {:tcp_closed, ^socket} -> + Instance.unregister_connection(instance, self()) + + {:tcp_error, ^socket, reason} -> + exception = RuntimeError.exception("TCP receive failed, because: #{inspect(reason)}") + send_error(socket, {exception, []}, instance) + :gen_tcp.close(socket) + Instance.unregister_connection(instance, self()) + + {:write, data} -> + respond({:reply, data}, instance, socket, nil) + end + end + + defp respond({:reply, data}, instance, socket, _data) do + case :gen_tcp.send(socket, data) do + :ok -> + connection_loop(instance, socket) + + {:error, reason} -> + exception = RuntimeError.exception("TCP reply failed, because: #{inspect(reason)}") + send_error(socket, {exception, []}, instance) + :gen_tcp.close(socket) + Instance.unregister_connection(instance, self()) + end + end + + defp respond(:ok, instance, socket, _data) do + connection_loop(instance, socket) + end + + defp respond({:error, :not_found}, instance, socket, data) do + message = + "#{TestServer.format_instance(TestServer.TCP, instance)} received unexpected TCP data" + |> append_formatted_data(data) + |> append_formatted_handlers(instance) + + send_error(socket, {RuntimeError.exception(message), []}, instance) + :gen_tcp.close(socket) + Instance.unregister_connection(instance, self()) + end + + defp respond({:error, {exception, stacktrace}}, instance, socket, _data) do + send_error(socket, {exception, stacktrace}, instance) + :gen_tcp.close(socket) + Instance.unregister_connection(instance, self()) + end + + defp send_error(socket, {exception, stacktrace}, instance) do + Instance.report_error(instance, {exception, stacktrace}) + + message = Exception.format(:error, exception, stacktrace) + :gen_tcp.send(socket, message) + end + + defp append_formatted_data(message, data) do + """ + #{message}: + + #{inspect(data)} + """ + end + + defp append_formatted_handlers(message, instance) do + handlers = Enum.split_with(Instance.handlers(instance), &(not &1.suspended)) + + """ + #{message} + + #{format_handlers(handlers)} + """ + end + + defp format_handlers({[], suspended_handlers}) do + message = "No active handlers." + + case suspended_handlers do + [] -> + message + + suspended_handlers -> + """ + #{message} The following handlers have been processed: + + #{Instance.format_handlers(suspended_handlers)} + """ + end + end + + defp format_handlers({active_handlers, _suspended_handlers}) do + """ + Active handlers: + + #{Instance.format_handlers(active_handlers)} + """ + end + + @doc false + @spec stop(keyword(), [map()]) :: :ok + def stop(options, connections) do + options + |> Keyword.fetch!(:listen_socket) + |> :gen_tcp.close() + + Enum.each(connections, fn + %{socket: nil} -> :ok + %{socket: socket} -> :gen_tcp.close(socket) + end) + end +end diff --git a/mix.exs b/mix.exs index c9eefc0..d48bf99 100644 --- a/mix.exs +++ b/mix.exs @@ -86,6 +86,9 @@ defmodule TestServer.MixProject do ], SSH: [ TestServer.SSH + ], + TCP: [ + TestServer.TCP ] ] ] diff --git a/test/test_server/tcp_test.exs b/test/test_server/tcp_test.exs new file mode 100644 index 0000000..8b4c881 --- /dev/null +++ b/test/test_server/tcp_test.exs @@ -0,0 +1,708 @@ +defmodule TestServer.TCPTest do + use ExUnit.Case + doctest TestServer.TCP + + import ExUnit.CaptureIO + import ExUnit.CaptureLog + + describe "start/1" do + test "with invalid port" do + assert_raise RuntimeError, ~r/Invalid port, got: :invalid/, fn -> + TestServer.TCP.start(port: :invalid) + end + + assert_raise RuntimeError, ~r/Invalid port, got: 65536/, fn -> + TestServer.TCP.start(port: 65_536) + end + + assert_raise RuntimeError, ~r/Could not listen to port 4545, because: :eaddrinuse/, fn -> + TestServer.TCP.start(port: 4545) + TestServer.TCP.start(port: 4545) + end + end + + test "starts with multiple ports" do + {:ok, instance_1} = TestServer.TCP.start() + {:ok, instance_2} = TestServer.TCP.start() + + refute instance_1 == instance_2 + + {_, port_1} = TestServer.TCP.address(instance_1) + {_, port_2} = TestServer.TCP.address(instance_2) + + refute port_1 == port_2 + end + + test "starts in IPv6-only mode" do + {:ok, _instance} = TestServer.TCP.start(ipfamily: :inet6) + + assert {:ok, socket} = tcp_connect([:inet6]) + assert {:ok, {ip, _port}} = :inet.sockname(socket) + assert tuple_size(ip) == 8 + end + + test "with packet listen option" do + {:ok, _instance} = TestServer.TCP.start(listen_options: [:binary, packet: :line]) + :ok = TestServer.TCP.handle() + + assert {:ok, socket} = tcp_connect(packet: :line) + assert :ok = :gen_tcp.send(socket, "ping\n") + assert {:ok, "ping\n"} = :gen_tcp.recv(socket, 0) + end + end + + describe "stop/1" do + test "when not running" do + assert_raise RuntimeError, "No current TestServer.TCP.Instance running", fn -> + TestServer.TCP.stop() + end + + assert_raise RuntimeError, + ~r/TestServer\.TCP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + {:ok, instance} = TestServer.TCP.start() + + assert :ok = TestServer.TCP.stop() + + TestServer.TCP.stop(instance) + end + end + + test "stops" do + assert {:ok, pid} = TestServer.TCP.start() + {host, port} = TestServer.TCP.address() + + assert :ok = TestServer.TCP.stop() + refute Process.alive?(pid) + + assert {:error, :econnrefused} = + :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false]) + end + + test "closes open client connections" do + :ok = TestServer.TCP.handle() + {host, port} = TestServer.TCP.address() + + {:ok, socket} = :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false]) + :ok = :gen_tcp.send(socket, "ping") + assert {:ok, "ping"} = :gen_tcp.recv(socket, 0) + + assert :ok = TestServer.TCP.stop() + assert {:error, :closed} = :gen_tcp.recv(socket, 0) + end + + test "with multiple instances" do + {:ok, instance_1} = TestServer.TCP.start() + {:ok, _instance_2} = TestServer.TCP.start() + + assert_raise RuntimeError, + ~r/Multiple instances running, please pass instance to `TestServer\.TCP\.stop\/0`/, + fn -> + TestServer.TCP.stop() + end + + assert :ok = TestServer.TCP.stop(instance_1) + assert :ok = TestServer.TCP.stop() + end + end + + describe "address/2" do + test "when instance not running" do + assert_raise RuntimeError, "No current TestServer.TCP.Instance running", fn -> + TestServer.TCP.address() + end + + assert_raise RuntimeError, + ~r/TestServer\.TCP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + {:ok, instance} = TestServer.TCP.start() + + assert :ok = TestServer.TCP.stop() + + TestServer.TCP.address(instance) + end + end + + test "with invalid `:host`" do + TestServer.TCP.start() + + assert_raise RuntimeError, ~r/Invalid host, got: :invalid/, fn -> + TestServer.TCP.address(host: :invalid) + end + end + + test "produces address" do + TestServer.TCP.start() + + assert {"localhost", port} = TestServer.TCP.address() + assert is_integer(port) + end + + test "with `:host`" do + TestServer.TCP.start() + + assert {"custom-host", _port} = TestServer.TCP.address(host: "custom-host") + end + + test "with `:host` in IPv6-only mode" do + {:ok, _instance} = TestServer.TCP.start(ipfamily: :inet6) + + assert {:ok, _socket} = tcp_connect([:inet6]) + end + + test "with multiple instances" do + {:ok, instance_1} = TestServer.TCP.start() + {:ok, instance_2} = TestServer.TCP.start() + + assert_raise RuntimeError, + ~r/Multiple instances running, please pass instance to `TestServer\.TCP\.address\/1`/, + fn -> + TestServer.TCP.address() + end + + refute TestServer.TCP.address(instance_1) == TestServer.TCP.address(instance_2) + end + end + + describe "handle/2" do + test "when instance not running" do + {:ok, instance} = TestServer.TCP.start() + :ok = TestServer.TCP.stop() + + assert_raise RuntimeError, + ~r/TestServer\.TCP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + TestServer.TCP.handle(instance) + end + end + + test "with invalid options" do + {:ok, _instance} = TestServer.TCP.start() + + assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> + TestServer.TCP.handle(to: :invalid) + end + + assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> + TestServer.TCP.handle(match: :invalid) + end + + TestServer.TCP.stop() + end + + test "with multiple instances" do + {:ok, instance_1} = TestServer.TCP.start() + {:ok, _instance_2} = TestServer.TCP.start() + + assert_raise RuntimeError, + ~r/Multiple instances running, please pass instance to `TestServer\.TCP\.handle\/1`/, + fn -> + TestServer.TCP.handle() + end + + assert :ok = TestServer.TCP.handle(instance_1, to: fn _data, state -> {:ok, state} end) + + TestServer.TCP.stop(instance_1) + end + + test "with no data received" do + defmodule NoDataReceivedTest do + use ExUnit.Case + + test "fails" do + :ok = TestServer.TCP.handle() + + assert {:ok, _socket} = unquote(__MODULE__).tcp_connect() + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ + "did not receive data for these handlers before the test ended" + end + + test "with default `:to` function" do + :ok = TestServer.TCP.handle() + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, "ping"} = :gen_tcp.recv(socket, 0) + end + + test "with `:match` function filtering multiple handlers" do + :ok = + TestServer.TCP.handle( + match: fn data, _state -> data == "first" end, + to: fn _data, state -> {:reply, "pong", state} end + ) + + :ok = + TestServer.TCP.handle(match: fn data, _state -> data == "second" end) + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "second") + assert {:ok, "second"} = :gen_tcp.recv(socket, 0) + assert :ok = :gen_tcp.send(socket, "first") + assert {:ok, "pong"} = :gen_tcp.recv(socket, 0) + end + + test "with state carried across handlers" do + {:ok, _instance} = TestServer.TCP.start() + + :ok = + TestServer.TCP.handle( + to: fn _data, state -> + count = Map.get(state, :count, 0) + 1 + + {:reply, Integer.to_string(count), Map.put(state, :count, count)} + end + ) + + :ok = + TestServer.TCP.handle( + to: fn _data, state -> + count = Map.get(state, :count, 0) + 1 + + {:reply, Integer.to_string(count), Map.put(state, :count, count)} + end + ) + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "next") + assert {:ok, "1"} = :gen_tcp.recv(socket, 0) + assert :ok = :gen_tcp.send(socket, "next") + assert {:ok, "2"} = :gen_tcp.recv(socket, 0) + end + + test "with `:to` function returning `{:ok, state}` response" do + :ok = + TestServer.TCP.handle(to: fn _data, state -> {:ok, state} end) + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:error, :timeout} = :gen_tcp.recv(socket, 0, 100) + end + + test "when receiving unexpected data" do + defmodule UnexpectedDataTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "received unexpected TCP data" + assert data =~ "No active handlers" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "received unexpected TCP data" + end + + test "when receiving unexpected data after processed handlers" do + defmodule UnexpectedDataAfterProcessedTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + :ok = TestServer.TCP.handle() + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "first") + assert {:ok, "first"} = :gen_tcp.recv(socket, 0) + assert :ok = :gen_tcp.send(socket, "second") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "received unexpected TCP data" + assert data =~ "The following handlers have been processed:" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "received unexpected TCP data" + assert io =~ "The following handlers have been processed:" + end + + test "when receiving unexpected data while a handler is waiting" do + defmodule UnexpectedDataActiveHandlerTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + :ok = TestServer.TCP.handle(match: fn _data, _state -> false end) + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "received unexpected TCP data" + assert data =~ "Active handlers:" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "received unexpected TCP data" + assert io =~ "Active handlers:" + end + + test "when `:to` function raises exception" do + defmodule HandleTo2ArityFunctionRaiseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + :ok = TestServer.TCP.handle(to: fn _data, _state -> raise "boom" end) + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "(RuntimeError) boom" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "(RuntimeError) boom" + assert io =~ "anonymous fn/2 in TestServer.TCPTest.HandleTo2ArityFunctionRaiseTest" + end + + test "when `:to` function returns an invalid response" do + defmodule HandleTo2ArityFunctionInvalidResponseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + :ok = TestServer.TCP.handle(to: fn _data, _state -> :invalid end) + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "(RuntimeError) Invalid callback response, got: :invalid." + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "(RuntimeError) Invalid callback response, got: :invalid." + end + + test "with `:to` function returning iodata reply data" do + {:ok, _instance} = TestServer.TCP.start() + :ok = TestServer.TCP.handle(to: fn _data, state -> {:reply, ["PO", [?N], "G"], state} end) + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, "PONG"} = :gen_tcp.recv(socket, 0) + end + + test "when `:match` function raises exception" do + defmodule MatchFunctionRaiseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + + :ok = TestServer.TCP.handle(match: fn _data, _state -> raise "boom" end) + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "(RuntimeError) boom" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "(RuntimeError) boom" + assert io =~ "anonymous fn/2 in TestServer.TCPTest.MatchFunctionRaiseTest" + end + + test "when sending the reply fails" do + defmodule ReplySendFailsTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + + :ok = TestServer.TCP.handle(to: fn _data, state -> {:reply, :not_iodata, state} end) + + assert {:ok, socket} = unquote(__MODULE__).tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "TCP reply failed, because:" + end + end + + assert io = capture_io(fn -> capture_log(fn -> ExUnit.run() end) end) + assert io =~ "TCP reply failed, because:" + end + end + + describe "connect/2" do + test "when instance not running" do + {:ok, instance} = TestServer.TCP.start() + assert :ok = TestServer.TCP.stop() + + assert_raise RuntimeError, + ~r/TestServer\.TCP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + TestServer.TCP.connect(instance, []) + end + end + + test "autostarts an instance" do + assert {:ok, {instance, ref}} = TestServer.TCP.connect() + assert is_pid(instance) + assert is_reference(ref) + + TestServer.TCP.stop(instance) + end + + test "with multiple instances" do + {:ok, instance_1} = TestServer.TCP.start() + {:ok, _instance_2} = TestServer.TCP.start() + + assert {:ok, {^instance_1, _ref}} = TestServer.TCP.connect(instance_1, []) + + TestServer.TCP.stop(instance_1) + end + + test "returns distinct refs" do + {:ok, instance} = TestServer.TCP.start() + + {:ok, {_, ref_1}} = TestServer.TCP.connect() + {:ok, {_, ref_2}} = TestServer.TCP.connect() + + refute ref_1 == ref_2 + + TestServer.TCP.stop(instance) + end + + test "with multiple clients, pairs them to connections in order" do + {:ok, _instance} = TestServer.TCP.start() + + {:ok, conn_1} = TestServer.TCP.connect() + {:ok, conn_2} = TestServer.TCP.connect() + + :ok = TestServer.TCP.handle(conn_1, to: fn _data, s -> {:reply, "one", s} end) + :ok = TestServer.TCP.handle(conn_2, to: fn _data, s -> {:reply, "two", s} end) + + assert {:ok, socket_1} = tcp_connect() + assert :ok = :gen_tcp.send(socket_1, "ping") + assert {:ok, "one"} = :gen_tcp.recv(socket_1, 0) + + assert {:ok, socket_2} = tcp_connect() + assert :ok = :gen_tcp.send(socket_2, "ping") + assert {:ok, "two"} = :gen_tcp.recv(socket_2, 0) + end + + test "with a handler set on one connection" do + {:ok, _instance} = TestServer.TCP.start() + + {:ok, conn_1} = TestServer.TCP.connect() + + :ok = TestServer.TCP.handle(conn_1, to: fn _data, s -> {:reply, "scoped", s} end) + :ok = TestServer.TCP.handle(to: fn _data, s -> {:reply, "global", s} end) + + assert {:ok, socket_1} = tcp_connect() + assert :ok = :gen_tcp.send(socket_1, "ping") + assert {:ok, "scoped"} = :gen_tcp.recv(socket_1, 0) + + assert {:ok, socket_2} = tcp_connect() + assert :ok = :gen_tcp.send(socket_2, "ping") + assert {:ok, "global"} = :gen_tcp.recv(socket_2, 0) + end + + test "with a client that connects without a registered connection" do + {:ok, _instance} = TestServer.TCP.start() + + :ok = TestServer.TCP.handle(to: fn data, s -> {:reply, data, s} end) + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "ping") + assert {:ok, "ping"} = :gen_tcp.recv(socket, 0) + end + + test "with no socket received" do + defmodule NoSocketReceivedTest do + use ExUnit.Case + + test "fails" do + {:ok, _conn} = TestServer.TCP.connect() + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ + "has connections that were not used" + end + end + + describe "send/2" do + test "when instance not running" do + {:ok, conn} = TestServer.TCP.connect() + assert :ok = TestServer.TCP.stop() + + assert_raise RuntimeError, + ~r/TestServer\.TCP\.Instance \#PID\<[0-9.]+\> is not running/, + fn -> + TestServer.TCP.send(conn) + end + end + + test "with no client connected yet" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn} = TestServer.TCP.connect() + + assert_raise RuntimeError, + ~r/no client has connected/, + fn -> + TestServer.TCP.send(conn) + end + + connect_bound(conn) + end + + test "with default callback function" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn} = TestServer.TCP.connect() + socket = connect_bound(conn) + + assert :ok = TestServer.TCP.send(conn) + assert {:ok, "ping"} = :gen_tcp.recv(socket, 0) + end + + test "with callback function" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn} = TestServer.TCP.connect() + socket = connect_bound(conn) + + assert :ok = + TestServer.TCP.send(conn, + to: fn state -> + {:reply, "pong", state} + end + ) + + assert {:ok, "pong"} = :gen_tcp.recv(socket, 0) + end + + test "with callback function returning `{:ok, state}` response" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn} = TestServer.TCP.connect() + socket = connect_bound(conn) + + assert :ok = TestServer.TCP.send(conn, to: fn state -> {:ok, state} end) + assert {:error, :timeout} = :gen_tcp.recv(socket, 0, 100) + end + + test "with multiple clients, sends to only one" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn_1} = TestServer.TCP.connect() + {:ok, conn_2} = TestServer.TCP.connect() + + socket_1 = connect_bound(conn_1) + socket_2 = connect_bound(conn_2) + + assert :ok = + TestServer.TCP.send(conn_1, + to: fn state -> + {:reply, "pong", state} + end + ) + + assert {:ok, "pong"} = :gen_tcp.recv(socket_1, 0) + assert {:error, :timeout} = :gen_tcp.recv(socket_2, 0, 100) + end + + test "with state carried across sends and handlers" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn} = TestServer.TCP.connect() + + :ok = + TestServer.TCP.handle(conn, + to: fn _data, state -> + count = Map.get(state, :count, 0) + 1 + + {:reply, Integer.to_string(count), Map.put(state, :count, count)} + end + ) + + assert {:ok, socket} = tcp_connect() + assert :ok = :gen_tcp.send(socket, "next") + assert {:ok, "1"} = :gen_tcp.recv(socket, 0) + + assert :ok = + TestServer.TCP.send(conn, + to: fn state -> + count = Map.get(state, :count, 0) + 1 + + {:reply, Integer.to_string(count), Map.put(state, :count, count)} + end + ) + + assert {:ok, "2"} = :gen_tcp.recv(socket, 0) + end + + test "with invalid options" do + {:ok, _instance} = TestServer.TCP.start() + {:ok, conn} = TestServer.TCP.connect() + connect_bound(conn) + + assert_raise BadFunctionError, ~r/expected a function, got: :invalid/, fn -> + TestServer.TCP.send(conn, to: :invalid) + end + end + + test "with invalid callback response" do + defmodule SendInvalidResponseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + {:ok, conn} = TestServer.TCP.connect() + socket = unquote(__MODULE__).connect_bound(conn) + + assert :ok = TestServer.TCP.send(conn, to: fn _state -> :invalid end) + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "(RuntimeError) Invalid callback response, got: :invalid." + end + end + + assert capture_io(fn -> ExUnit.run() end) =~ + "(RuntimeError) Invalid callback response, got: :invalid." + end + + test "with callback function raising exception" do + defmodule SendFunctionRaiseTest do + use ExUnit.Case + + test "fails" do + {:ok, _instance} = TestServer.TCP.start(suppress_warning: true) + {:ok, conn} = TestServer.TCP.connect() + socket = unquote(__MODULE__).connect_bound(conn) + + assert :ok = TestServer.TCP.send(conn, to: fn _state -> raise "boom" end) + assert {:ok, data} = :gen_tcp.recv(socket, 0) + assert data =~ "(RuntimeError) boom" + end + end + + assert io = capture_io(fn -> ExUnit.run() end) + assert io =~ "(RuntimeError) boom" + assert io =~ "anonymous fn/1 in TestServer.TCPTest.SendFunctionRaiseTest" + end + end + + def tcp_connect(options \\ []) do + {host, port} = TestServer.TCP.address() + + :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: false] ++ options) + end + + def connect_bound(conn) do + :ok = TestServer.TCP.handle(conn, to: fn data, state -> {:reply, data, state} end) + + {:ok, socket} = tcp_connect() + :ok = :gen_tcp.send(socket, "__bind__") + {:ok, "__bind__"} = :gen_tcp.recv(socket, 0) + + socket + end +end