lib/test_server.ex

defmodule TestServer do
  @external_resource "README.md"
  @moduledoc "README.md"
             |> File.read!()
             |> String.split("<!-- MDOC !-->")
             |> Enum.fetch!(1)

  alias Plug.Conn
  alias TestServer.{Instance, InstanceManager}

  @type instance :: pid()
  @type route :: reference()
  @type stacktrace :: list()
  @type websocket_socket :: {instance(), route()}
  @type websocket_frame :: {atom(), any()}
  @type websocket_state :: any()
  @type websocket_reply ::
          {:reply, websocket_frame(), websocket_state()} | {:ok, websocket_state()}

  @doc """
  Start a test server 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;
    * `:scheme`           - an atom for the http scheme. Defaults to `:http`;
    * `:http_server`      - HTTP server configuration. Defaults to
      `{TestServer.HTTPServer.Bandit, []}`,
      `{TestServer.HTTPServer.Plug.Cowboy, []}`, or
      `{TestServer.HTTPServer.Httpd, []}` depending on which web server is
      available in the project dependencies;
    * `:tls`              - Passthru options for TLS configuration handled by
      the webserver;
    * `:ipfamily`         - The IP address type to use, either `:inet` or
      `:inet6`. Defaults to `:inet`;

  ## Examples

      TestServer.start(
        scheme: :https,
        ipfamily: :inet6,
        http_server: {TestServer.HTTPServer.Bandit, [ip: :any]}
      )

      TestServer.add("/",
        to: fn conn ->
          assert conn.remote_ip == {0, 0, 0, 0, 0, 65_535, 32_512, 1}

          Plug.Conn.resp(conn, 200, to_string(Plug.Conn.get_http_protocol(conn)))
        end
      )

      req_opts = [
        connect_options: [
          transport_opts: [cacerts: TestServer.x509_suite().cacerts],
          protocols: [:http2]
        ]
      ]

      assert {:ok, %Req.Response{status: 200, body: "HTTP/2"}} =
              Req.get(TestServer.url(), req_opts)
  """
  @spec start(keyword()) :: {:ok, pid()}
  def start(options \\ []) do
    case ExUnit.fetch_test_supervisor() do
      {:ok, sup} ->
        start_with_ex_unit(options, sup)

      :error ->
        raise ArgumentError, "can only be called in a test process"
    end
  end

  defp start_with_ex_unit(options, _sup) do
    [_first_module_entry | stacktrace] = get_stacktrace()

    case InstanceManager.start_instance(self(), stacktrace, options) do
      {:ok, instance} ->
        put_ex_unit_on_exit_callback(instance)

        {:ok, instance}

      {:error, error} ->
        raise_start_failure({:error, error})
    end
  end

  defp put_ex_unit_on_exit_callback(instance) do
    ExUnit.Callbacks.on_exit(fn ->
      case Process.alive?(instance) do
        true ->
          verify_routes!(instance)
          verify_websocket_handlers!(instance)
          stop(instance)

        false ->
          :ok
      end
    end)
  end

  defp raise_start_failure({:error, {{:EXIT, reason}, _spec}}) do
    raise_start_failure({:error, reason})
  end

  defp raise_start_failure({:error, error}) do
    raise """
    EXIT when starting #{inspect(__MODULE__.Instance)}:

    #{Exception.format_exit(error)}
    """
  end

  defp verify_routes!(instance) do
    instance
    |> Instance.routes()
    |> Enum.reject(& &1.suspended)
    |> case do
      [] ->
        :ok

      active_routes ->
        raise """
        #{Instance.format_instance(instance)} did not receive a request for these routes before the test ended:

        #{Instance.format_routes(active_routes)}
        """
    end
  end

  defp verify_websocket_handlers!(instance) do
    instance
    |> Instance.websocket_handlers()
    |> Enum.reject(& &1.suspended)
    |> case do
      [] ->
        :ok

      active_websocket_handlers ->
        raise """
        #{Instance.format_instance(instance)} did not receive a frame for these websocket handlers before the test ended:

        #{Instance.format_websocket_handlers(active_websocket_handlers)}
        """
    end
  end

  @doc """
  Shuts down the current test server.

  ## Examples

      TestServer.start()
      url = TestServer.url()
      TestServer.stop()

      assert {:error, %Req.TransportError{}} = Req.get(url, retry: false)
  """
  @spec stop() :: :ok | {:error, term()}
  def stop, do: stop(fetch_instance!())

  @doc """
  Shuts down a test server instance.
  """
  @spec stop(pid()) :: :ok | {:error, term()}
  def stop(instance) do
    instance_alive!(instance)

    InstanceManager.stop_instance(instance)
  end

  defp instance_alive!(instance) do
    case Process.alive?(instance) do
      true -> :ok
      false -> raise "#{Instance.format_instance(instance)} is not running"
    end
  end

  @doc """
  Gets current test server instance if running.

  ## Examples

      refute TestServer.get_instance()

      {:ok, instance} = TestServer.start()

      assert TestServer.get_instance() == instance
  """
  @spec get_instance() :: pid() | nil
  def get_instance do
    case fetch_instance(false) do
      {:ok, instance} -> instance
      :error -> nil
    end
  end

  @spec url() :: binary()
  def url, do: url("")

  @spec url(binary() | keyword() | pid()) :: binary()
  def url(uri) when is_binary(uri), do: url(uri, [])
  def url(opts) when is_list(opts), do: url("", opts)
  def url(instance) when is_pid(instance), do: url(instance, "", [])

  @doc """
  Produces a URL 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

      TestServer.start(port: 4444)

      assert TestServer.url() == "http://localhost:4444"
      assert TestServer.url("/test") == "http://localhost:4444/test"
      assert TestServer.url(host: "example.com") == "http://example.com:4444"
  """
  @spec url(binary(), keyword()) :: binary()
  def url(uri, opts) when is_binary(uri), do: url(fetch_instance!(), uri, opts)

  @spec url(pid(), binary()) :: binary()
  def url(instance, uri) when is_pid(instance), do: url(instance, uri, [])

  @doc """
  Produces a URL for a test server instance.

  See `url/2` for options.
  """
  @spec url(pid(), binary(), keyword()) :: binary()
  def url(instance, uri, opts) do
    instance_alive!(instance)

    unless is_nil(opts[:host]) or is_binary(opts[:host]),
      do: raise("Invalid host, got: #{inspect(opts[:host])}")

    domain = maybe_enable_host(opts[:host])
    options = Instance.get_options(instance)

    "#{Keyword.fetch!(options, :scheme)}://#{domain}:#{Keyword.fetch!(options, :port)}#{uri}"
  end

  defp fetch_instance! do
    case fetch_instance() do
      :error -> raise "No current #{inspect(Instance)} running"
      {:ok, instance} -> instance
    end
  end

  defp fetch_instance(function_accepts_instance_arg \\ true) do
    case InstanceManager.get_by_caller(self()) do
      nil ->
        :error

      [instance] ->
        {:ok, instance}

      [_instance | _rest] = instances ->
        [{m, f, a, _} | _stacktrace] = get_stacktrace()

        message =
          case function_accepts_instance_arg do
            true ->
              "Multiple #{inspect(Instance)}'s running, please pass instance to `#{inspect(m)}.#{f}/#{a}`."

            false ->
              "Multiple #{inspect(Instance)}'s running."
          end

        formatted_instances =
          instances
          |> Enum.map(&{&1, Instance.get_options(&1)})
          |> Enum.with_index()
          |> Enum.map_join("\n\n", fn {{instance, options}, index} ->
            """
            ##{index + 1}: #{Instance.format_instance(instance)}
                #{Enum.map_join(options[:stacktrace], "\n    ", &Exception.format_stacktrace_entry/1)}")}
            """
          end)

        raise """
        #{message}

        #{formatted_instances}
        """
    end
  end

  defp maybe_enable_host(nil), do: "localhost"

  defp maybe_enable_host(host) do
    :inet_db.set_lookup([:file, :dns])
    :inet_db.add_host({127, 0, 0, 1}, [String.to_charlist(host)])
    :inet_db.add_host({0, 0, 0, 0, 0, 0, 0, 1}, [String.to_charlist(host)])

    host
  end

  @spec add(binary()) :: :ok
  def add(uri), do: add(uri, [])

  @doc """
  Adds a route to the current test server.

  Matching routes are handled FIFO (first in, first out). Any requests to
  routes not added to the TestServer and any routes that isn't matched will
  raise an error in the test case.

  ## Options

    * `:via`       - matches the route against some specific HTTP method(s)
      specified as an atom, like `:get` or `:put`, or a list, like `[:get, :post]`.
    * `:match`     - an anonymous function that will be called to see if a
      route matches, defaults to matching with arguments of uri and `:via` option.
    * `:to`        - a Plug or anonymous function that will be called when the
      route matches, defaults to return the http scheme.

  ## Examples

      TestServer.add("/",
        match: fn conn ->
          conn.query_params["a"] == "1"
        end,
        to: fn conn ->
          Plug.Conn.resp(conn, 200, "a = 1")
        end)

      TestServer.add("/", to: &Plug.Conn.resp(&1, 200, "PONG"))
      TestServer.add("/")

      assert {:ok, %Req.Response{status: 200, body: "PONG"}} = Req.get(TestServer.url("/"))
      assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} = Req.post(TestServer.url("/"))
      assert {:ok, %Req.Response{status: 200, body: "a = 1"}} = Req.get(TestServer.url("/?a=1"))
  """
  @spec add(binary(), keyword()) :: :ok
  def add(uri, options) when is_binary(uri) do
    {:ok, instance} = autostart()

    add(instance, uri, options)
  end

  @spec add(pid(), binary()) :: :ok
  def add(instance, uri) when is_pid(instance) and is_binary(uri), do: add(instance, uri, [])

  @doc """
  Adds a route to a test server instance.

  See `add/2` for options.
  """
  @spec add(pid(), binary(), keyword()) :: :ok
  def add(instance, uri, options) when is_pid(instance) and is_binary(uri) and is_list(options) do
    options = Keyword.put_new(options, :to, &default_response_handler/1)

    {:ok, _route} = register_route(instance, uri, options)

    :ok
  end

  defp register_route(instance, uri, options) do
    instance_alive!(instance)

    [_register_route, _first_module_entry | stacktrace] = get_stacktrace()

    Instance.register(instance, {:plug_router_to, {uri, options, stacktrace}})
  end

  defp get_stacktrace do
    {:current_stacktrace, [{Process, :info, _, _} | stacktrace]} =
      Process.info(self(), :current_stacktrace)

    first_module_entry =
      stacktrace
      |> Enum.reverse()
      |> Enum.find(fn {mod, _, _, _} -> mod == __MODULE__ end)

    [first_module_entry] ++ prune_stacktrace(stacktrace)
  end

  # Remove TestServer
  defp prune_stacktrace([{__MODULE__, _, _, _} | t]), do: prune_stacktrace(t)

  # Assertions can pop-up in the middle of the stack
  defp prune_stacktrace([{ExUnit.Assertions, _, _, _} | t]), do: prune_stacktrace(t)

  # As soon as we see a Runner, it is time to ignore the stacktrace
  defp prune_stacktrace([{ExUnit.Runner, _, _, _} | _]), do: []

  # All other cases
  defp prune_stacktrace([h | t]), do: [h | prune_stacktrace(t)]
  defp prune_stacktrace([]), do: []

  defp autostart do
    case fetch_instance() do
      :error -> start()
      {:ok, instance} -> {:ok, instance}
    end
  end

  defp default_response_handler(conn) do
    Conn.resp(conn, 200, to_string(Conn.get_http_protocol(conn)))
  end

  @doc """
  Adds a plug to the current test server.

  This plug will be called for all requests before route is matched.

  ## Examples

      TestServer.plug(MyPlug)

      TestServer.plug(fn conn ->
        {:ok, body, _conn} = Plug.Conn.read_body(conn, [])

        %{conn | body_params: Jason.decode!(body)}
      end)
  """
  @spec plug(module() | function()) :: :ok
  def plug(plug) do
    {:ok, instance} = autostart()

    plug(instance, plug)
  end

  @doc """
  Adds a route to a test server instance.

  See `plug/1` for more.
  """
  @spec plug(pid(), module() | function()) :: :ok
  def plug(instance, plug) do
    [_first_module_entry | stacktrace] = get_stacktrace()

    {:ok, _plug} = Instance.register(instance, {:plug, {plug, stacktrace}})

    :ok
  end

  @doc """
  Fetches the generated x509 suite for the current test server.

  ## Examples

      TestServer.start(scheme: :https)
      TestServer.add("/")

      cacerts = TestServer.x509_suite().cacerts
      req_opts = [connect_options: [transport_opts: [cacerts: cacerts]]]

      assert {:ok, %Req.Response{status: 200, body: "HTTP/1.1"}} =
              Req.get(TestServer.url(), req_opts)
  """
  @spec x509_suite() :: term()
  def x509_suite, do: x509_suite(fetch_instance!())

  @doc """
  Fetches the generated x509 suite for a test server instance.

  See `x509_suite/0` for more.
  """
  @spec x509_suite(pid()) :: term()
  def x509_suite(instance) do
    instance_alive!(instance)

    options = Instance.get_options(instance)

    cond do
      not (options[:scheme] == :https) ->
        raise "#{Instance.format_instance(instance)} is not running with `[scheme: :https]` option"

      not Keyword.has_key?(options, :x509_suite) ->
        raise "#{Instance.format_instance(instance)} is running with custom SSL"

      true ->
        options[:x509_suite]
    end
  end

  @spec websocket_init(binary()) :: {:ok, websocket_socket()} | {:error, term()}
  def websocket_init(uri) when is_binary(uri), do: websocket_init(uri, [])

  @doc """
  Adds a websocket route to current test server.

  The `:to` option can be overridden the same way as for `add/2`, and will be
  called during the HTTP handshake. If the `conn.state` is `:unset` the
  websocket will be initiated otherwise response is returned as-is.

  ## Options

  Takes the same options as `add/2`, except `:to`.

  ## Examples

      {:ok, socket} = TestServer.websocket_init("/ws")
      TestServer.websocket_handle(socket)

      assert {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))
      assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"}

  `:via` and `:match` are called during the HTTP handshake:

      TestServer.websocket_init("/ws", via: :get, match: fn conn ->
        conn.params["token"] == "secret"
      end)

      assert {:ok, _client} = WebSocketClient.start_link(TestServer.url("/ws?token=secret"))

  `:to` option is also called during the HTTP handshake:

      TestServer.websocket_init("/ws",
        to: fn conn ->
          Plug.Conn.send_resp(conn, 403, "Forbidden")
        end
      )

      assert {:error, %WebSockex.RequestError{code: 403}} =
              WebSocketClient.start_link(TestServer.url("/ws"))
  """
  @spec websocket_init(binary(), keyword()) :: {:ok, websocket_socket()}
  def websocket_init(uri, options) when is_binary(uri) do
    {:ok, instance} = autostart()

    websocket_init(instance, uri, options)
  end

  @spec websocket_init(pid(), binary()) :: {:ok, websocket_socket()}
  def websocket_init(instance, uri) when is_pid(instance) and is_binary(uri) do
    websocket_init(instance, uri, [])
  end

  @doc """
  Adds a websocket route to a test server.

  See `websocket_init/2` for options.
  """
  @spec websocket_init(pid(), binary(), keyword()) :: {:ok, websocket_socket()}
  def websocket_init(instance, uri, options) do
    options =
      options
      |> Keyword.put(:websocket, true)
      |> Keyword.put_new(:to, & &1)

    {:ok, %{ref: ref}} = register_route(instance, uri, options)

    {:ok, {instance, ref}}
  end

  @spec websocket_handle(websocket_socket()) :: :ok | {:error, term()}
  def websocket_handle(socket), do: websocket_handle(socket, [])

  @doc """
  Adds a message handler to a websocket instance.

  Messages are matched FIFO (first in, first out). Any messages not expected by
  TestServer or any message expectations not receiving a message will raise an
  error in the test case.

  ## Options

    * `:match`     - an anonymous function that will be called to see if a
      message matches, defaults to matching anything.
    * `:to`        - an anonymous function that will be called when the message
      matches, defaults to returning received message.

  ## Examples

      {:ok, socket} = TestServer.websocket_init("/ws")

      TestServer.websocket_handle(
        socket,
        to: fn _frame, state ->
          {:reply, {:text, "pong"}, state}
        end,
        match: fn frame, _state ->
          frame == {:text, "ping"}
        end)

      TestServer.websocket_handle(socket)

      {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))

      assert WebSocketClient.send_message(client, "echo") == {:ok, "echo"}
      assert WebSocketClient.send_message(client, "ping") == {:ok, "pong"}
  """
  @spec websocket_handle(websocket_socket(), keyword()) :: :ok
  def websocket_handle({instance, _route_ref} = socket, options) do
    instance_alive!(instance)

    [_first_module_entry | stacktrace] = get_stacktrace()

    options = Keyword.put_new(options, :to, &default_websocket_handle/2)

    {:ok, _handler} = Instance.register(socket, {:websocket, {:handle, options, stacktrace}})

    :ok
  end

  defp default_websocket_handle(frame, state),
    do: {:reply, frame, state}

  @doc """
  Sends an message to a websocket instance.

  ## Examples

      {:ok, socket} = TestServer.websocket_init("/ws")
      {:ok, client} = WebSocketClient.start_link(TestServer.url("/ws"))

      assert TestServer.websocket_info(socket, fn state ->
        {:reply, {:text, "hello"}, state}
      end) == :ok

      assert WebSocketClient.receive_message(client) == {:ok, "hello"}
  """
  @spec websocket_info(websocket_socket(), function() | nil) :: :ok
  def websocket_info({instance, _route_ref} = socket, callback \\ nil)
      when is_function(callback) or is_nil(callback) do
    instance_alive!(instance)

    [_first_module_entry | stacktrace] = get_stacktrace()

    callback = callback || (&default_websocket_info/1)

    for pid <- Instance.active_websocket_connections(socket) do
      send(pid, {callback, stacktrace})
    end

    :ok
  end

  defp default_websocket_info(state), do: {:reply, {:text, "ping"}, state}
end