Skip to content

Commit ed38120

Browse files
committed
Add TestServer.TCP
1 parent 64ed8dd commit ed38120

7 files changed

Lines changed: 1435 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ Features:
1313
- HTTP/2
1414
- WebSocket
1515
- SSH
16+
- TCP
1617
- Built-in TLS with self-signed certificates
1718
- Plug route matching
1819

1920
## Protocols
2021

2122
- [`TestServer.HTTP`](lib/test_server/http/README.md) - HTTP/1, HTTP/2, and WebSocket.
2223
- [`TestServer.SSH`](lib/test_server/ssh/README.md) - SSH exec and shell.
24+
- [`TestServer.TCP`](lib/test_server/tcp/README.md) - Raw TCP.
2325

2426
<!-- MDOC !-->
2527

lib/test_server/tcp.ex

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
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

lib/test_server/tcp/README.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# TCP
2+
3+
<!-- MDOC !-->
4+
5+
Mock raw TCP endpoints with connection and data expectations.
6+
7+
## Usage
8+
9+
Add connection expectations with `TestServer.TCP.connect/1`, then add FIFO
10+
data handlers with `TestServer.TCP.handle/2`:
11+
12+
```elixir
13+
test "TCP client" do
14+
{:ok, connection} = TestServer.TCP.connect()
15+
16+
:ok =
17+
TestServer.TCP.handle(
18+
connection,
19+
match: fn data, _state -> data == "PING\n" end,
20+
to: fn _data, state -> {:reply, "PONG\n", state} end
21+
)
22+
23+
:ok = TestServer.TCP.handle(connection)
24+
25+
{:ok, socket} =
26+
:gen_tcp.connect(~c"localhost", elem(TestServer.TCP.address(), 1), [
27+
:binary,
28+
active: false,
29+
packet: :line
30+
])
31+
32+
:ok = :gen_tcp.send(socket, "PING\n")
33+
assert {:ok, "PONG\n"} = :gen_tcp.recv(socket, 0)
34+
35+
:ok = :gen_tcp.send(socket, "echo\n")
36+
assert {:ok, "echo\n"} = :gen_tcp.recv(socket, 0)
37+
end
38+
```
39+
40+
The server autostarts when `connect/1` is called. Start it explicitly when you
41+
need custom socket options:
42+
43+
```elixir
44+
TestServer.TCP.start(listen_options: [:binary, packet: :line])
45+
```
46+
47+
TCP is a stream protocol, so data delivered to handlers follows the configured
48+
`:gen_tcp` packet options. Use `:listen_options` to set framing such as
49+
`packet: :line`, `packet: 4`, or raw stream mode.
50+
51+
### Handlers
52+
53+
By default, `TestServer.TCP.handle/2` echoes received data:
54+
55+
```elixir
56+
{:ok, connection} = TestServer.TCP.connect()
57+
:ok = TestServer.TCP.handle(connection)
58+
```
59+
60+
Use `:match` to select data and `:to` to customize the response:
61+
62+
```elixir
63+
TestServer.TCP.handle(connection,
64+
match: fn data, _state -> data == "HELLO" end,
65+
to: fn _data, state -> {:reply, "READY", state} end
66+
)
67+
```
68+
69+
The two-arity `:to` callback can return:
70+
71+
```elixir
72+
{:reply, data, state}
73+
{:ok, state}
74+
{:close, state}
75+
```
76+
77+
For lower-level socket control, use a three-arity callback:
78+
79+
```elixir
80+
TestServer.TCP.handle(connection,
81+
to: fn _data, socket, state ->
82+
:ok = :gen_tcp.send(socket, "first")
83+
:ok = :gen_tcp.send(socket, "second")
84+
85+
{:ok, state}
86+
end
87+
)
88+
```
89+
90+
### IPv6
91+
92+
Use the `:ipfamily` option to test with IPv6:
93+
94+
```elixir
95+
{:ok, _instance} = TestServer.TCP.start(ipfamily: :inet6)
96+
{:ok, _connection} = TestServer.TCP.connect()
97+
98+
assert {"localhost", port} = TestServer.TCP.address()
99+
100+
{:ok, socket} =
101+
:gen_tcp.connect(~c"localhost", port, [:binary, active: false, :inet6])
102+
```
103+
104+
<!-- MDOC !-->

0 commit comments

Comments
 (0)