lib/sham.ex

defmodule Sham do
  @derive {Inspect, only: [:port]}
  defstruct pid: nil, port: nil

  @type t :: %__MODULE__{
          port: pos_integer()
        }

  @moduledoc """
  Sham is a mock HTTP(S) server useful for testing HTTP(S) clients.
  """

  @doc """
  Starts a new Sham instance.

  ## Options

  * `:ssl` - Whether to start the server in SSL mode. Defaults to `false`.
  * `:keyfile` - The path to an SSL keyfile. Defaults to an internal self-signed key.
  * `:certfile` - The path to an SSL certfile. Defaults to an internal self-signed certificate.

  ## Examples

      sham = Sham.start()
      Sham.expect(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)

      sham = Sham.start(ssl: true)
      Sham.expect(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)

  """
  @type sham_opts :: [ssl: boolean(), keyfile: String.t(), certfile: String.t()]
  @spec start(sham_opts()) :: Sham.t() | {:error, term()}
  def start(opts \\ []) do
    case DynamicSupervisor.start_child(Sham.Supervisor, {Sham.Instance, opts}) do
      {:ok, pid} ->
        {:ok, port} = GenServer.call(pid, :setup)

        ExUnit.Callbacks.on_exit({Sham.Instance, pid}, fn ->
          case GenServer.call(pid, :on_exit) do
            :ok ->
              :ok

            {:error, error} ->
              raise ExUnit.AssertionError, error

            {:exception, {exception, stacktrace}} ->
              reraise exception, stacktrace

            {:error, expected, actual} ->
              raise ExUnit.AssertionError,
                    "TestServer expected #{inspect(expected)} but received #{inspect(actual)}"
          end
        end)

        %Sham{pid: pid, port: port}

      other ->
        other
    end
  end

  @doc """
  Expect at least one call to the Sham instance using any method and at any path.

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.expect(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}
      iex> ShamTest.post("http://localhost:#\{sham.port}/other", "foo=bar")
      {:ok, 200, "Hello"}

      sham = Sham.start()
      Sham.expect(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      ** (ExUnit.AssertionError) No HTTP request was received by Sham

  If no request is sent to the sham port, an assertion error will be raised.
  """
  @spec expect(Sham.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: Sham.t()
  def expect(%{pid: pid} = sham, callback)
      when is_pid(pid) and is_function(callback, 1) do
    :ok = GenServer.call(pid, {:expect, nil, nil, callback})
    sham
  end

  @doc """
  Expect at least one call to the Sham instance using the given method and path.

  - `method` - The HTTP method to expect. Should be one of `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"`, `"HEAD"`, or `"OPTIONS"` or (Anything Plug supports).
  - `path` - The path to expect

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.expect(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}

      # Sending a request that does not match an expectation will result in an assertion error
      ShamTest.post("http://localhost:#\{sham.port}/other", "foo=bar")
      {:ok, 500, "Unexpected request to Sham: POST /other"}
      ** (ExUnit.AssertionError) Unexpected request to Sham: POST /other

  If no request is sent to the sham port using the given method and path, an assertion error will be raised.
  """
  @spec expect(
          Sham.t(),
          method :: String.t(),
          path :: String.t(),
          (Plug.Conn.t() -> Plug.Conn.t())
        ) :: Sham.t()
  def expect(%{pid: pid} = sham, method, path, callback)
      when is_pid(pid) and is_function(callback, 1) do
    :ok = GenServer.call(pid, {:expect, method, path, callback})
    sham
  end

  @doc """
  Expect exactly one call to the Sham instance using any method and at any path.

  You can stack multiple calls to `expect_once/4` to expect multiple requests.

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.expect_once(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}

      iex> sham = Sham.start()
      iex> Sham.expect_once(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}
      iex> Sham.expect_once(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "World") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "World"}

      # Sending a more than one request will result in an assertion error
      ShamTest.post("http://localhost:#\{sham.port}/other", "foo=bar")
      {:ok, 500, "Exceeded expected requests to Sham: POST /other"}
      ** (ExUnit.AssertionError) Exceeded expected requests to Sham: POST /other

  If a request is sent to the sham port using a different method or path, an assertion error will be raised.
  """
  @spec expect_once(Sham.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: Sham.t()
  def expect_once(%{pid: pid} = sham, callback)
      when is_pid(pid) and is_function(callback, 1) do
    :ok = GenServer.call(pid, {:expect_once, nil, nil, callback})
    sham
  end

  @doc """
  Expect exactly one call to the Sham instance using the given method and path.

  You can stack multiple calls to `expect_once/4` to expect multiple requests.

  - `method` - The HTTP method to expect. Should be one of `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"`, `"HEAD"`, or `"OPTIONS"` or (Anything Plug supports).
  - `path` - The path to expect

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.expect_once(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}

      iex> sham = Sham.start()
      iex> Sham.expect_once(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}
      iex> Sham.expect_once(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "World") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "World"}

      # Sending a more than one request with the same method and path will result in an assertion error
      ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 500, "Exceeded expected requests to Sham: GET /hello"}
      ** (ExUnit.AssertionError) Exceeded expected requests to Sham: GET /hello

  If no request is sent to the sham port using the given method and path, an assertion error will be raised.
  """
  @spec expect_once(
          Sham.t(),
          method :: String.t(),
          path :: String.t(),
          (Plug.Conn.t() -> Plug.Conn.t())
        ) :: Sham.t()
  def expect_once(%{pid: pid} = sham, method, path, callback)
      when is_pid(pid) and is_function(callback, 1) do
    :ok = GenServer.call(pid, {:expect_once, method, path, callback})
    sham
  end

  @doc """
  Expect no requests to the Sham instance using any method or path.

  ## Examples

      sham = Sham.start()
      Sham.expect_none(sham)
      # Sending a request will result in an assertion error
      ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 500, "A request was received by Sham when none were expected: GET /hello"}
      ** (ExUnit.AssertionError) A request was received by Sham when none were expected: GET /hello
  """
  @spec expect_none(Sham.t()) :: Sham.t()
  def expect_none(%{pid: pid} = sham) do
    :ok = GenServer.call(pid, {:expect_none, nil, nil})
    sham
  end

  @doc """
  Expect no requests to the Sham instance for a given method and path.

  - `method` - The HTTP method to expect. Should be one of `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"`, `"HEAD"`, or `"OPTIONS"` or (Anything Plug supports).
  - `path` - The path to expect
  """
  @spec expect_none(Sham.t(), method :: String.t(), path :: String.t()) :: Sham.t()
  def expect_none(%{pid: pid} = sham, method, path) do
    :ok = GenServer.call(pid, {:expect_none, method, path})
    sham
  end

  @doc """
  Provide a callback to handle any requests to the Sham instance without assertions.

  No exceptions will be raised if no requests are sent to the sham port.

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.stub(sham, fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}
      iex> ShamTest.post("http://localhost:#\{sham.port}/other", "foo=bar")
      {:ok, 200, "Hello"}
  """
  @spec stub(Sham.t(), (Plug.Conn.t() -> Plug.Conn.t())) :: Sham.t()
  def stub(%{pid: pid} = sham, callback)
      when is_pid(pid) and is_function(callback, 1) do
    :ok = GenServer.call(pid, {:stub, nil, nil, callback})
    sham
  end

  @doc """
  Provide a callback to handle any requests to the Sham instance using the given method and path without assertions.

  No exceptions will be raised if no requests are sent to the sham port using the given method and path.

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.stub(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.get("http://localhost:#\{sham.port}/hello")
      {:ok, 200, "Hello"}

      # Sending a request with a different method and path will result in an assertion error
      ShamTest.post("http://localhost:#\{sham.port}/other", "foo=bar")
      {:ok, 500, "Unexpected request to Sham: POST /other"}
      ** (ExUnit.AssertionError) Unexpected request to Sham: POST /other
  """
  @spec stub(
          Sham.t(),
          method :: String.t(),
          path :: String.t(),
          (Plug.Conn.t() -> Plug.Conn.t())
        ) :: Sham.t()
  def stub(%{pid: pid} = sham, method, path, callback)
      when is_pid(pid) and is_function(callback, 1) do
    :ok = GenServer.call(pid, {:stub, method, path, callback})
    sham
  end

  @doc """
  Forces the Sham instance to pass regardless of whether a valid request was sent to the sham port or not.

  ## Examples

      iex> sham = Sham.start()
      iex> Sham.expect(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> ShamTest.post("http://localhost:#\{sham.port}/other", "foo=bar")
      {:ok, 500, "Unexpected request to Sham: POST /other"}
      iex> Sham.pass(sham)

      iex> sham = Sham.start()
      iex> Sham.expect(sham, "GET", "/hello", fn conn -> Plug.Conn.send_resp(conn, 200, "Hello") end)
      iex> Sham.pass(sham)

  No assertions will be raised.
  """
  @spec pass(Sham.t()) :: Sham.t()
  def pass(%{pid: pid} = sham) do
    :ok = GenServer.call(pid, :pass)
    sham
  end
end