4.2 KiB · text History 4b6fe3b
defmodule OhMyExOhIrcd.IRCTestHelper do
  @moduledoc """
  Test helpers for spinning up isolated IRC listeners and driving
  TCP clients against them.

  Each test gets its own listener on an ephemeral port (`port: 0`) via
  `ExUnit.Callbacks.start_supervised!/1`, so listener config (auth flag,
  ping timers) can be customized per-test and tests don't share port
  state.
  """

  import ExUnit.Callbacks, only: [start_supervised!: 1]

  @default_options %{
    server_name: "test.irc",
    network_name: "TestNet",
    allow_anonymous: true,
    motd: ["Test MOTD line one.", "Test MOTD line two."],
    ping_interval_ms: 120_000,
    ping_timeout_ms: 60_000
  }

  @doc """
  Start an isolated IRC listener on an ephemeral port. Returns the bound
  port. Listener is torn down at test exit via `start_supervised!`.

  ## Options

  Anything in the handler options map (`:allow_anonymous`, `:motd`,
  `:ping_interval_ms`, `:ping_timeout_ms`, `:server_name`,
  `:network_name`). Unspecified keys fall back to test defaults.
  """
  @spec start_listener(map()) :: pos_integer()
  def start_listener(opts \\ %{}) do
    handler_options = Map.merge(@default_options, opts)

    spec =
      ThousandIsland.child_spec(
        port: 0,
        handler_module: OhMyExOhIrcd.IRC.Connection,
        read_timeout: 30_000,
        handler_options: handler_options
      )

    pid = start_supervised!(spec)
    {:ok, {_ip, port}} = ThousandIsland.listener_info(pid)
    port
  end

  @doc "Open a line-mode TCP connection to the given port."
  def connect(port) do
    {:ok, sock} =
      :gen_tcp.connect(~c"127.0.0.1", port, [:binary, active: false, packet: :line], 1_000)

    sock
  end

  @doc "Send raw IRC lines (no need to include CRLF — we append it)."
  def send_line(sock, line) when is_binary(line) do
    :ok = :gen_tcp.send(sock, line <> "\r\n")
  end

  @doc """
  Read up to `count` lines from the socket within `timeout_ms`. Each
  returned element is the line *without* trailing CRLF.
  """
  def recv_lines(sock, count, timeout_ms \\ 500) do
    do_recv_lines(sock, count, timeout_ms, [])
  end

  defp do_recv_lines(_sock, 0, _t, acc), do: Enum.reverse(acc)

  defp do_recv_lines(sock, n, t, acc) do
    case :gen_tcp.recv(sock, 0, t) do
      {:ok, line} -> do_recv_lines(sock, n - 1, t, [String.trim_trailing(line) | acc])
      {:error, :timeout} -> Enum.reverse(acc)
      {:error, :closed} -> Enum.reverse(acc)
    end
  end

  @doc """
  Drain everything readable until the socket goes quiet for `timeout_ms`.
  """
  def drain(sock, timeout_ms \\ 200) do
    Stream.repeatedly(fn -> :gen_tcp.recv(sock, 0, timeout_ms) end)
    |> Enum.take_while(&match?({:ok, _}, &1))
    |> Enum.map(fn {:ok, line} -> String.trim_trailing(line) end)
  end

  @doc """
  Drive the registration handshake (NICK + USER), then drain the welcome
  numerics. Returns the socket.
  """
  def register(port, nick, opts \\ []) do
    sock = connect(port)
    realname = Keyword.get(opts, :realname, nick)
    user = Keyword.get(opts, :user, nick)

    send_line(sock, "NICK #{nick}")
    send_line(sock, "USER #{user} 0 * :#{realname}")
    # Drain welcome through end-of-MOTD (376 or 422).
    drain(sock, 250)
    sock
  end

  @doc "Close a connection cleanly."
  def quit(sock, reason \\ "bye") do
    send_line(sock, "QUIT :#{reason}")
    :gen_tcp.close(sock)
  end

  @doc """
  Generate a unique nick suitable for cross-test isolation (the Horde
  registry is global).
  """
  def unique_nick(prefix \\ "u") do
    "#{prefix}#{System.unique_integer([:positive])}"
  end

  @doc """
  Generate a unique channel name.
  """
  def unique_channel(prefix \\ "test") do
    "##{prefix}#{System.unique_integer([:positive])}"
  end

  @doc """
  Drive the full SASL-PLAIN + NICK/USER handshake and return the socket
  once registration completes. Drains all welcome / SASL numerics.
  """
  def register_sasl(port, nick, email, password) do
    sock = connect(port)
    payload = Base.encode64(<<0>> <> email <> <<0>> <> password)
    send_line(sock, "CAP LS")
    send_line(sock, "CAP REQ :sasl")
    send_line(sock, "AUTHENTICATE PLAIN")
    send_line(sock, "AUTHENTICATE #{payload}")
    send_line(sock, "CAP END")
    send_line(sock, "NICK #{nick}")
    send_line(sock, "USER #{nick} 0 * :#{nick}")
    drain(sock, 400)
    sock
  end
end