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