|
| 1 | +defmodule TestServer.TCP do |
| 2 | + @external_resource "lib/test_server/tcp/README.md" |
| 3 | + @moduledoc "lib/test_server/tcp/README.md" |
| 4 | + |> File.read!() |
| 5 | + |> String.split("<!-- MDOC !-->") |
| 6 | + |> Enum.fetch!(1) |
| 7 | + |
| 8 | + alias TestServer.TCP.{Instance, Server} |
| 9 | + |
| 10 | + @type connection :: {pid(), connection_ref()} |
| 11 | + @type connection_ref :: reference() |
| 12 | + @type data :: binary() |
| 13 | + @type state :: term() |
| 14 | + @type handler_fun :: (data(), state() -> |
| 15 | + {:reply, iodata(), state()} |
| 16 | + | {:ok, state()} |
| 17 | + | {:close, state()}) |
| 18 | + @type raw_handler_fun :: (data(), port(), state() -> |
| 19 | + {:ok, state()} |
| 20 | + | {:close, state()}) |
| 21 | + @type match_fun :: (data(), state() -> boolean()) |
| 22 | + |
| 23 | + @doc """ |
| 24 | + Start a test server TCP instance. |
| 25 | +
|
| 26 | + The instance will be terminated when the test case finishes. |
| 27 | +
|
| 28 | + ## Options |
| 29 | +
|
| 30 | + * `:port` - integer of port number, defaults to random port that |
| 31 | + can be opened; |
| 32 | + * `:ipfamily` - The IP address type to use, either `:inet` or |
| 33 | + `:inet6`. Defaults to `:inet`; |
| 34 | + * `:listen_options` - options passed to `:gen_tcp.listen/2`. Defaults to |
| 35 | + `[:binary, active: false, reuseaddr: true]`. `active: false` is always |
| 36 | + used by the server; |
| 37 | + * `:recv_timeout` - timeout passed to `:gen_tcp.recv/3`. Defaults to |
| 38 | + `5_000`. |
| 39 | +
|
| 40 | + ## Examples |
| 41 | +
|
| 42 | + {:ok, _instance} = TestServer.TCP.start( |
| 43 | + listen_options: [:binary, packet: :line] |
| 44 | + ) |
| 45 | +
|
| 46 | + {:ok, connection} = TestServer.TCP.connect() |
| 47 | +
|
| 48 | + :ok = |
| 49 | + TestServer.TCP.handle(connection, |
| 50 | + match: fn data, _state -> data == "PING\\n" end, |
| 51 | + to: fn _data, state -> {:reply, "PONG\\n", state} end |
| 52 | + ) |
| 53 | +
|
| 54 | + {:ok, socket} = |
| 55 | + :gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [ |
| 56 | + :binary, |
| 57 | + active: false, |
| 58 | + packet: :line |
| 59 | + ]) |
| 60 | +
|
| 61 | + :ok = :gen_tcp.send(socket, "PING\\n") |
| 62 | + assert {:ok, "PONG\\n"} = :gen_tcp.recv(socket, 0) |
| 63 | + """ |
| 64 | + @spec start(keyword()) :: {:ok, pid()} |
| 65 | + def start(options \\ []) do |
| 66 | + TestServer.start_instance(__MODULE__, options, &verify!/1) |
| 67 | + end |
| 68 | + |
| 69 | + defp verify!(instance) do |
| 70 | + verify_handlers!(instance) |
| 71 | + verify_connections!(instance) |
| 72 | + end |
| 73 | + |
| 74 | + defp verify_handlers!(instance) do |
| 75 | + instance |
| 76 | + |> Instance.handlers() |
| 77 | + |> Enum.reject(& &1.suspended) |
| 78 | + |> case do |
| 79 | + [] -> |
| 80 | + :ok |
| 81 | + |
| 82 | + active_handlers -> |
| 83 | + raise """ |
| 84 | + #{TestServer.format_instance(__MODULE__, instance)} did not receive data for these handlers before the test ended: |
| 85 | +
|
| 86 | + #{Instance.format_handlers(active_handlers)} |
| 87 | + """ |
| 88 | + end |
| 89 | + end |
| 90 | + |
| 91 | + defp verify_connections!(instance) do |
| 92 | + instance |
| 93 | + |> Instance.connections() |
| 94 | + |> Enum.filter(&is_nil(&1.pid)) |
| 95 | + |> case do |
| 96 | + [] -> |
| 97 | + :ok |
| 98 | + |
| 99 | + unused_connections -> |
| 100 | + raise """ |
| 101 | + #{TestServer.format_instance(__MODULE__, instance)} has connections that were not used: |
| 102 | +
|
| 103 | + #{Instance.format_connections(unused_connections)} |
| 104 | + """ |
| 105 | + end |
| 106 | + end |
| 107 | + |
| 108 | + @doc """ |
| 109 | + Shuts down the current test server TCP instance. |
| 110 | + """ |
| 111 | + @spec stop() :: :ok | {:error, term()} |
| 112 | + def stop, do: stop(TestServer.fetch_instance!(__MODULE__)) |
| 113 | + |
| 114 | + @doc """ |
| 115 | + Shuts down a test server TCP instance. |
| 116 | + """ |
| 117 | + @spec stop(pid()) :: :ok | {:error, term()} |
| 118 | + def stop(instance) do |
| 119 | + TestServer.ensure_instance_alive!(__MODULE__, instance) |
| 120 | + |
| 121 | + Server.stop(Instance.get_options(instance)) |
| 122 | + |
| 123 | + TestServer.stop_instance(__MODULE__, instance) |
| 124 | + end |
| 125 | + |
| 126 | + @spec address() :: {binary(), non_neg_integer()} |
| 127 | + def address, do: address([]) |
| 128 | + |
| 129 | + @doc """ |
| 130 | + Returns the address for current test server. |
| 131 | +
|
| 132 | + ## Options |
| 133 | +
|
| 134 | + * `:host` - binary host value, it'll be added to inet for IP `127.0.0.1` |
| 135 | + and `::1`, defaults to `"localhost"`; |
| 136 | + """ |
| 137 | + @spec address(keyword() | pid()) :: {binary(), non_neg_integer()} |
| 138 | + def address(options) when is_list(options), |
| 139 | + do: address(TestServer.fetch_instance!(__MODULE__), options) |
| 140 | + |
| 141 | + def address(instance) when is_pid(instance), do: address(instance, []) |
| 142 | + |
| 143 | + @doc """ |
| 144 | + Returns the address for a test server instance. |
| 145 | +
|
| 146 | + See `address/1` for options. |
| 147 | + """ |
| 148 | + @spec address(pid(), keyword()) :: {binary(), non_neg_integer()} |
| 149 | + def address(instance, options) when is_pid(instance) and is_list(options) do |
| 150 | + TestServer.ensure_instance_alive!(__MODULE__, instance) |
| 151 | + |
| 152 | + host = TestServer.get_host(options) |
| 153 | + port = instance |> Instance.get_options() |> Keyword.fetch!(:port) |
| 154 | + |
| 155 | + {host, port} |
| 156 | + end |
| 157 | + |
| 158 | + @spec connect() :: {:ok, connection()} |
| 159 | + def connect, do: connect([]) |
| 160 | + |
| 161 | + @doc """ |
| 162 | + Adds a connection expectation to the current test server. |
| 163 | +
|
| 164 | + ## Options |
| 165 | +
|
| 166 | + * `:init_state` - initial state for handlers on the accepted TCP connection. |
| 167 | +
|
| 168 | + ## Examples |
| 169 | +
|
| 170 | + {:ok, connection} = TestServer.TCP.connect() |
| 171 | + :ok = TestServer.TCP.handle(connection) |
| 172 | + """ |
| 173 | + @spec connect(keyword()) :: {:ok, connection()} |
| 174 | + def connect(options) when is_list(options) do |
| 175 | + {:ok, instance} = TestServer.autostart_instance(__MODULE__) |
| 176 | + |
| 177 | + connect(instance, options) |
| 178 | + end |
| 179 | + |
| 180 | + @doc """ |
| 181 | + Adds a connection expectation to a test server instance. |
| 182 | +
|
| 183 | + See `connect/1` for options. |
| 184 | + """ |
| 185 | + @spec connect(pid(), keyword()) :: {:ok, connection()} |
| 186 | + def connect(instance, options) do |
| 187 | + TestServer.ensure_instance_alive!(__MODULE__, instance) |
| 188 | + |
| 189 | + [_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__) |
| 190 | + |
| 191 | + options = Keyword.put_new(options, :init_state, %{}) |
| 192 | + |
| 193 | + {:ok, connection} = Instance.register(instance, {:connection, {options, stacktrace}}) |
| 194 | + |
| 195 | + {:ok, {instance, connection.ref}} |
| 196 | + end |
| 197 | + |
| 198 | + @spec handle(connection()) :: :ok |
| 199 | + def handle(connection), do: handle(connection, []) |
| 200 | + |
| 201 | + @doc """ |
| 202 | + Adds a data handler to a test server TCP connection. |
| 203 | +
|
| 204 | + Handlers are matched FIFO (first in, first out). Any data not matched by a |
| 205 | + handler, or any handlers not consumed by data, will raise an error in the test |
| 206 | + case. |
| 207 | +
|
| 208 | + The `:to` callback can be either a two-arity `t:handler_fun/0` or a |
| 209 | + three-arity `t:raw_handler_fun/0`. A two-arity handler uses the default TCP |
| 210 | + handling for replies and closes. A three-arity handler receives the accepted |
| 211 | + socket and gives you direct control over socket responses. |
| 212 | +
|
| 213 | + ## Options |
| 214 | +
|
| 215 | + * `:match` - a `t:match_fun/0` function that returns a boolean. Defaults to |
| 216 | + matching anything; |
| 217 | + * `:to` - a `t:handler_fun/0` or `t:raw_handler_fun/0` function called |
| 218 | + when the handler matches. Defaults to echoing the received data. |
| 219 | + """ |
| 220 | + @spec handle(connection(), keyword()) :: :ok |
| 221 | + def handle({instance, connection_ref} = _connection, options) do |
| 222 | + TestServer.ensure_instance_alive!(__MODULE__, instance) |
| 223 | + |
| 224 | + [_first_module_entry | stacktrace] = TestServer.get_pruned_stacktrace(__MODULE__) |
| 225 | + |
| 226 | + {:ok, _handler} = |
| 227 | + Instance.register(instance, {:handle, {connection_ref, options, stacktrace}}) |
| 228 | + |
| 229 | + :ok |
| 230 | + end |
| 231 | +end |
0 commit comments