defmodule Plug.Test do
@moduledoc """
Conveniences for testing plugs.
This module can be used in your test cases, like this:
use ExUnit.Case, async: true
use Plug.Test
Using this module will:
* import all the functions from this module
* import all the functions from the `Plug.Conn` module
By default, Plug tests checks for invalid header keys, e.g. header keys which
include uppercase letters, and raises a `Plug.Conn.InvalidHeaderError` when
it finds one. To disable it, set `:validate_header_keys_during_test` to
false on the app config.
config :plug, :validate_header_keys_during_test, false
"""
@doc false
defmacro __using__(_) do
quote do
import Plug.Test
import Plug.Conn
end
end
alias Plug.Conn
@typep params :: binary | list | map | nil
@doc """
Creates a test connection.
The request `method` and `path` are required arguments. `method` may be any
value that implements `to_string/1` and it will be properly converted and
normalized (e.g., `:get` or `"post"`).
The `path` is commonly the request path with optional query string but it may
also be a complete URI. When a URI is given, the host and schema will be used
as part of the request too.
The `params_or_body` field must be one of:
* `nil` - meaning there is no body;
* a binary - containing a request body. For such cases, `:headers`
must be given as option with a content-type;
* a map or list - containing the parameters which will automatically
set the content-type to multipart. The map or list may contain
other lists or maps and all entries will be normalized to string
keys;
## Examples
conn(:get, "/foo?bar=10")
conn(:get, "/foo", %{bar: 10})
conn(:post, "/")
conn("patch", "/", "") |> put_req_header("content-type", "application/json")
"""
@spec conn(String.Chars.t(), binary, params) :: Conn.t()
def conn(method, path, params_or_body \\ nil) do
Plug.Adapters.Test.Conn.conn(%Plug.Conn{}, method, path, params_or_body)
end
@doc """
Returns the sent response.
This function is useful when the code being invoked crashes and
there is a need to verify a particular response was sent, even with
the crash. It returns a tuple with `{status, headers, body}`.
"""
def sent_resp(%Conn{adapter: {Plug.Adapters.Test.Conn, %{ref: ref}}}) do
case receive_resp(ref) do
:no_resp ->
raise "no sent response available for the given connection. " <>
"Maybe the application did not send anything?"
response ->
case receive_resp(ref) do
:no_resp ->
send(self(), {ref, response})
response
_otherwise ->
raise "a response for the given connection has been sent more than once"
end
end
end
defp receive_resp(ref) do
receive do
{^ref, response} -> response
after
0 -> :no_resp
end
end
@doc """
Returns the informational requests that have been sent.
This function depends on gathering the messages sent by the test adapter when
informational messages, such as an early hint, are sent. Calling this
function will clear the informational request messages from the inbox for the
process. To assert on multiple informs, the result of the function should be
stored in a variable.
## Examples
conn = conn(:get, "/foo", "bar=10")
informs = Plug.Test.sent_informs(conn)
assert {"/static/application.css", [{"accept", "text/css"}]} in informs
assert {"/static/application.js", [{"accept", "application/javascript"}]} in informs
"""
def sent_informs(%Conn{adapter: {Plug.Adapters.Test.Conn, %{ref: ref}}}) do
Enum.reverse(receive_informs(ref, []))
end
defp receive_informs(ref, informs) do
receive do
{^ref, :inform, response} ->
receive_informs(ref, [response | informs])
after
0 -> informs
end
end
@doc """
Returns the assets that have been pushed.
This function depends on gathering the messages sent by the test adapter
when assets are pushed. Calling this function will clear the pushed message
from the inbox for the process. To assert on multiple pushes, the result
of the function should be stored in a variable.
## Examples
conn = conn(:get, "/foo?bar=10")
pushes = Plug.Test.sent_pushes(conn)
assert {"/static/application.css", [{"accept", "text/css"}]} in pushes
assert {"/static/application.js", [{"accept", "application/javascript"}]} in pushes
"""
def sent_pushes(%Conn{adapter: {Plug.Adapters.Test.Conn, %{ref: ref}}}) do
Enum.reverse(receive_pushes(ref, []))
end
defp receive_pushes(ref, pushes) do
receive do
{^ref, :push, response} ->
receive_pushes(ref, [response | pushes])
after
0 -> pushes
end
end
@doc """
Puts the HTTP protocol.
"""
def put_http_protocol(conn, http_protocol) do
update_in(conn.adapter, fn {adapter, payload} ->
{adapter, Map.put(payload, :http_protocol, http_protocol)}
end)
end
@doc """
Puts the peer data.
"""
def put_peer_data(conn, peer_data) do
update_in(conn.adapter, fn {adapter, payload} ->
{adapter, Map.put(payload, :peer_data, peer_data)}
end)
end
@doc """
Puts a request cookie.
"""
@spec put_req_cookie(Conn.t(), binary, binary) :: Conn.t()
def put_req_cookie(conn, key, value) when is_binary(key) and is_binary(value) do
conn = delete_req_cookie(conn, key)
%{conn | req_headers: [{"cookie", "#{key}=#{value}"} | conn.req_headers]}
end
@doc """
Deletes a request cookie.
"""
@spec delete_req_cookie(Conn.t(), binary) :: Conn.t()
def delete_req_cookie(%Conn{req_cookies: %Plug.Conn.Unfetched{}} = conn, key)
when is_binary(key) do
key = "#{key}="
size = byte_size(key)
fun = &match?({"cookie", value} when binary_part(value, 0, size) == key, &1)
%{conn | req_headers: Enum.reject(conn.req_headers, fun)}
end
def delete_req_cookie(_conn, key) when is_binary(key) do
raise ArgumentError, message: "cannot put/delete request cookies after cookies were fetched"
end
@doc """
Moves cookies from a connection into a new connection for subsequent requests.
This function copies the cookie information in `old_conn` into `new_conn`,
emulating multiple requests done by clients where cookies are always passed
forward, and returns the new version of `new_conn`.
"""
@spec recycle_cookies(Conn.t(), Conn.t()) :: Conn.t()
def recycle_cookies(new_conn, old_conn) do
req_cookies = Plug.Conn.fetch_cookies(old_conn).req_cookies
resp_cookies =
Enum.reduce(old_conn.resp_cookies, req_cookies, fn {key, opts}, acc ->
if value = Map.get(opts, :value) do
Map.put(acc, key, value)
else
Map.delete(acc, key)
end
end)
Enum.reduce(resp_cookies, new_conn, fn {key, value}, acc ->
put_req_cookie(acc, key, value)
end)
end
@doc """
Initializes the session with the given contents.
If the session has already been initialized, the new contents will be merged
with the previous ones.
"""
@spec init_test_session(Conn.t(), %{optional(String.t() | atom) => any}) :: Conn.t()
def init_test_session(conn, session) do
conn =
if conn.private[:plug_session_fetch] do
Conn.fetch_session(conn)
else
conn
|> Conn.put_private(:plug_session, %{})
|> Conn.put_private(:plug_session_fetch, :done)
end
Enum.reduce(session, conn, fn {key, value}, conn ->
Conn.put_session(conn, key, value)
end)
end
end