lib/phoenix_integration/assertions.ex

defmodule PhoenixIntegration.Assertions do
  @moduledoc """
  Functions to assert/refute the response content of a conn without interrupting the
  chain of actions in an integration test.

  Each function takes a conn and a set of conditions to test. Each condition is tested
  and, if they all pass, the function returns the passed-in conn unchanged. If any
  condition fails, the function raises an appropriate error.

  This is intended to be used in a (possibly long) chain of piped functions that
  exercises a set of functionality in your application.

  ### Example
      test "Basic page flow", %{conn: conn} do
        # get the root index page
        get( conn, page_path(conn, :index) )
        # click/follow through the various about pages
        |> follow_link( "About Us" )
        |> assert_response( status: 200, path: about_path(conn, :index) )
        |> follow_link( "Contact" )
        |> assert_response( content_type: "text/html" )
        |> follow_link( "Privacy" )
        |> assert_response( html: "Privacy Policy" )
        |> follow_link( "Home" )
        |> assert_response( status: 200, path: page_path(conn, :index) )
      end
  """

  #  import IEx
  defmodule ResponseError do
    @moduledoc false
    defexception message: "#{IO.ANSI.red()}The conn's response was not formed as expected\n"
  end

  @doc """
  Asserts a set of conditions against the response fields of a conn. Returns the conn on success
  so that it can be used in the next integration call.

  ### Parameters
     * `conn` should be a conn returned from a previous request
      should point to the path being redirected to.
    * `conditions` a list of conditions to test against. Conditions can include:
      * `:status` checks that `conn.status` equals the given numeric value
      * `:content_type` the conn's content-type header should contain the given text. Typical
        values are `"text/html"` or `"application/json"`
      * `:body` conn.resp_body should contain the given text. Does not check the content_type.
      * `:html` checks that content_type is html, then looks for the given text in the body.
      * `:json` checks that content_type is json, then checks that the json data equals the given map.
      * `:path` the route rendered into the conn must equal the given path (or uri).
      * `:uri` same as `:path`
      * `:redirect` checks that `conn.status` is 302 and that the path in the "location" redirect
        header equals the given path.
      * `:to` same as `:redirect`
      * `:assigns` checks that conn.assigns contains the given values, which could be in the form of `%{key => value}`
        or `[{key, value}]`
      * `:value` checks that the value returned by a callback (in the form `fn(conn)`) is truthy

  Conditions can be used multiple times within a single call to `assert_response`. This can be useful
  to look for multiple text strings in the body.

  Example

      # test a rendered page
      assert_response( conn,
        status:   200,
        path:     page_path(conn, :index),
        html:     "Some Content",
        html:     "More Content",
        assigns:  %{current_user_id: user.id}
      )

      # test a redirection
      assert_response( conn, to: page_path(conn, :index) )

      # test a callback value
      assert_response( conn, value: fn(conn) ->
        Guardian.Plug.current_resource(conn)
      end)
  """
  def assert_response(conn = %Plug.Conn{}, conditions) do
    Enum.each(conditions, fn {condition, value} ->
      case condition do
        :status -> assert_status(conn, value)
        :content_type -> assert_content_type(conn, value)
        :body -> assert_body(conn, value)
        :html -> assert_body_html(conn, value)
        :json -> assert_body_json(conn, value)
        :uri -> assert_uri(conn, value)
        :path -> assert_uri(conn, value, :path)
        :redirect -> assert_redirect(conn, value)
        :to -> assert_redirect(conn, value, :to)
        :assigns -> assert_assigns(conn, value)
        :value -> assert_value(conn, value)
      end
    end)

    conn
  end

  @doc """
  Refutes a set of conditions for the response fields in a conn. Returns the conn on success
  so that it can be used in the next integration call.

  ### Parameters
     * `conn` should be a conn returned from a previous request
      should point to the path being redirected to.
    * `conditions` a list of conditions to test against. Conditions can include:
      * `:status` checks that `conn.status` is not the given numeric value
      * `:content_type` the conn's content-type header should not contain the given text. Typical
        values are `"text/html"` or `"applicaiton/json"`
      * `:body` conn.resp_body should not contain the given text. Does not check the content_type.
      * `:html` checks if content_type is html. If it is, it then checks that the given text is not in the body.
      * `:json` checks if content_type is json, then checks that the json data does not equal the given map.
      * `:path` the route rendered into the conn must not equal the given path (or uri).
      * `:uri` same as `:path`
      * `:redirect` checks if `conn.status` is 302. If it is, then checks that the path in the "location" redirect
        header is not the given path.
      * `:to` same as `:redirect`
      * `:assigns` checks that conn.assigns does not contain the given values, which could be in the form of `%{key: value}`
        or `[{:key, value}]`
      * `:value` checks that the value returned by a callback (in the form `fn(conn)`) is false or nil

  `refute_response` is often used in conjuntion with `assert_response` to form a complete condition check.

  Example

      # test a rendered page
      follow_path( conn, page_path(conn, :index) )
      |> assert_response(
          status: 200,
          path:   page_path(conn, :index)
          html:   "Good Content"
        )
      |> refute_response( body: "Invalid Content" )
  """
  def refute_response(conn = %Plug.Conn{}, conditions) do
    Enum.each(conditions, fn {condition, value} ->
      case condition do
        :status -> refute_status(conn, value)
        :content_type -> refute_content_type(conn, value)
        :body -> refute_body(conn, value)
        :html -> refute_body_html(conn, value)
        :json -> refute_body_json(conn, value)
        :uri -> refute_uri(conn, value)
        :path -> refute_uri(conn, value, :path)
        :redirect -> refute_redirect(conn, value)
        :to -> refute_redirect(conn, value, :to)
        :assigns -> refute_assigns(conn, value)
        :value -> refute_value(conn, value)
      end
    end)

    conn
  end

  # ----------------------------------------------------------------------------
  defp assert_value(conn, callback, err_type \\ :value)

  defp assert_value(conn, callback, err_type) when is_function(callback, 1) do
    value = callback.(conn)

    if value do
      conn
    else
      # raise an appropriate error
      msg =
        error_msg_type(conn, err_type) <>
          error_msg_expected("callback response to be truthy") <> error_msg_found(inspect(value))

      raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_value(conn, callback, err_type \\ :value)

  defp refute_value(conn, callback, err_type) when is_function(callback, 1) do
    value = callback.(conn)

    unless value do
      conn
    else
      # raise an appropriate error
      msg =
        error_msg_type(conn, err_type) <>
          error_msg_expected("callback response to be nil or false") <>
          error_msg_found(inspect(value))

      raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_assigns(conn, expected, err_type \\ :assigns)

  defp assert_assigns(conn, expected, err_type) when is_map(expected) do
    Enum.each(expected, fn {key, value} ->
      if conn.assigns[key] != value do
        # raise an appropriate error
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected("conn.assigns to contain: " <> inspect(expected)) <>
            error_msg_found(inspect(conn.assigns))

        raise %ResponseError{message: msg}
      end
    end)
  end

  defp assert_assigns(conn, expected, err_type) when is_list(expected),
    do: assert_assigns(conn, Enum.into(expected, %{}), err_type)

  # ----------------------------------------------------------------------------
  defp refute_assigns(conn, expected, err_type \\ :assigns)

  defp refute_assigns(conn, expected, err_type) when is_map(expected) do
    Enum.each(expected, fn {key, value} ->
      if conn.assigns[key] == value do
        # raise an appropriate error
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected("conn.assigns to NOT contain: " <> inspect(expected)) <>
            error_msg_found(inspect(conn.assigns))

        raise %ResponseError{message: msg}
      end
    end)
  end

  defp refute_assigns(conn, expected, err_type) when is_list(expected),
    do: assert_assigns(conn, Enum.into(expected, %{}), err_type)

  # ----------------------------------------------------------------------------
  defp assert_uri(conn, expected, err_type \\ :uri) do
    # parse the expected uri
    uri = URI.parse(expected)

    # prepare the path and query data
    {uri_path, conn_path} =
      case uri.path do
        nil -> {nil, nil}
        _path -> {uri.path, conn.request_path}
      end

    {uri_query, conn_query} =
      case uri.query do
        nil ->
          {nil, nil}

        _query ->
          # decode the queries to get order independence
          {URI.decode_query(uri.query), URI.decode_query(conn.query_string)}
      end

    # The main test
    pass =
      cond do
        uri_path && uri_query -> uri_path == conn_path && uri_query == conn_query
        uri_path -> uri_path == conn_path
        uri_query -> uri_query == conn_query
      end

    # raise or not as appropriate
    if pass do
      conn
    else
      # raise an appropriate error
      msg =
        error_msg_type(conn, err_type) <>
          error_msg_expected(expected) <> error_msg_found(conn_request_path(conn))

      raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_uri(conn, expected, err_type \\ :uri) do
    # parse the expected uri
    uri = URI.parse(expected)

    # prepare the path and query data
    {uri_path, conn_path} =
      case uri.path do
        nil -> {nil, nil}
        _path -> {uri.path, conn.request_path}
      end

    {uri_query, conn_query} =
      case uri.query do
        nil ->
          {nil, nil}

        _query ->
          # decode the queries to get order independence
          {URI.decode_query(uri.query), URI.decode_query(conn.query_string)}
      end

    # The main test
    pass =
      cond do
        uri_path && uri_query -> uri_path != conn_path || uri_query != conn_query
        uri_path -> uri_path != conn_path
        uri_query -> uri_query != conn_query
      end

    # raise or not as appropriate
    if pass do
      conn
    else
      # raise an appropriate error
      msg =
        error_msg_type(conn, err_type) <>
          error_msg_expected("path to NOT be:" <> conn_request_path(conn)) <>
          error_msg_found(conn_request_path(conn))

      raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_redirect(conn, expected, err_type \\ :redirect) do
    assert_status(conn, 302)

    case Plug.Conn.get_resp_header(conn, "location") do
      [^expected] ->
        conn

      [to] ->
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected(to_string(expected)) <> error_msg_found(to_string(to))

        raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_redirect(conn, expected, err_type \\ :redirect) do
    case conn.status do
      302 ->
        case Plug.Conn.get_resp_header(conn, "location") do
          [^expected] ->
            msg =
              error_msg_type(conn, err_type) <>
                error_msg_expected("to NOT redirect to: " <> to_string(expected)) <>
                error_msg_found("redirect to: " <> to_string(expected))

            raise %ResponseError{message: msg}

          [_to] ->
            conn
        end

      _other ->
        conn
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_body_html(conn, expected, err_type \\ :html) do
    assert_content_type(conn, "text/html", err_type)
    |> assert_body(expected, err_type)
  end

  # ----------------------------------------------------------------------------
  defp refute_body_html(conn, expected, err_type \\ :html) do
    # slightly different than asserting body_html
    # good if not html content
    case Plug.Conn.get_resp_header(conn, "content-type") do
      [] ->
        conn

      [header] ->
        cond do
          header =~ "text/html" ->
            refute_body(conn, expected, err_type)

          true ->
            conn
        end
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_body_json(conn, expected, err_type \\ :json) do
    assert_content_type(conn, "application/json", err_type)

    case Jason.decode!(conn.resp_body) do
      ^expected ->
        conn

      data ->
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected(inspect(expected)) <> error_msg_found(inspect(data))

        raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_body_json(conn, expected, err_type \\ :json) do
    # similar to refute body html, ok if content isn't json
    case Plug.Conn.get_resp_header(conn, "content-type") do
      [] ->
        conn

      [header] ->
        cond do
          header =~ "json" ->
            case Jason.decode!(conn.resp_body) do
              ^expected ->
                msg =
                  error_msg_type(conn, err_type) <>
                    error_msg_expected("to NOT find " <> inspect(expected)) <>
                    error_msg_found(inspect(expected))

                raise %ResponseError{message: msg}

              _data ->
                conn
            end

          true ->
            conn
        end
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_body(conn, expected, err_type \\ :body) do
    if conn.resp_body =~ expected do
      conn
    else
      msg =
        error_msg_type(conn, err_type) <>
          error_msg_expected("to find \"#{inspect(expected)}\"") <>
          error_msg_found("Not in the response body\n") <> IO.ANSI.yellow() <> conn.resp_body

      raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_body(conn, expected, err_type \\ :body) do
    if conn.resp_body =~ expected do
      msg =
        error_msg_type(conn, err_type) <>
          error_msg_expected("NOT to find \"#{inspect(expected)}\"") <>
          error_msg_found("in the response body\n") <> IO.ANSI.yellow() <> conn.resp_body

      raise %ResponseError{message: msg}
    else
      conn
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_status(conn, status, err_type \\ :status) do
    case conn.status do
      ^status ->
        conn

      other ->
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected(to_string(status)) <> error_msg_found(to_string(other))

        raise %ResponseError{message: msg}
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_status(conn, status, err_type \\ :status) do
    case conn.status do
      ^status ->
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected("NOT " <> to_string(status)) <> error_msg_found(to_string(status))

        raise %ResponseError{message: msg}

      _other ->
        conn
    end
  end

  # ----------------------------------------------------------------------------
  defp assert_content_type(conn, expected_type, err_type \\ :content_type) do
    case Plug.Conn.get_resp_header(conn, "content-type") do
      [] ->
        # no content type header was found
        msg =
          error_msg_type(conn, err_type) <>
            error_msg_expected("content-type header of \"#{expected_type}\"") <>
            error_msg_found("No content-type header was found")

        raise %ResponseError{message: msg}

      [header] ->
        cond do
          header =~ expected_type ->
            # success case
            conn

          true ->
            # there was a content type header, but the wrong one
            msg =
              error_msg_type(conn, err_type) <>
                error_msg_expected("content-type including \"#{expected_type}\"") <>
                error_msg_found("\"#{header}\"")

            raise %ResponseError{message: msg}
        end
    end
  end

  # ----------------------------------------------------------------------------
  defp refute_content_type(conn, expected_type, err_type \\ :content_type) do
    case Plug.Conn.get_resp_header(conn, "content-type") do
      [] ->
        conn

      [header] ->
        cond do
          header =~ expected_type ->
            # the refuted content_type header was found
            msg =
              error_msg_type(conn, err_type) <>
                error_msg_expected("content-type to NOT be \"#{expected_type}\"") <>
                error_msg_found("\"#{header}\"")

            raise %ResponseError{message: msg}

          true ->
            conn
        end
    end
  end

  # ----------------------------------------------------------------------------
  defp error_msg_type(conn, type) do
    "#{IO.ANSI.red()}The conn's response was not formed as expected\n" <>
      "#{IO.ANSI.green()}Error verifying #{IO.ANSI.cyan()}:#{type}\n" <>
      "#{IO.ANSI.green()}Request path: #{IO.ANSI.yellow()}#{conn_request_path(conn)}\n" <>
      "#{IO.ANSI.green()}Request method: #{IO.ANSI.yellow()}#{conn.method}\n" <>
      "#{IO.ANSI.green()}Request params: #{IO.ANSI.yellow()}#{inspect(conn.params)}\n"
  end

  # ----------------------------------------------------------------------------
  defp error_msg_expected(msg) do
    "#{IO.ANSI.green()}Expected: #{IO.ANSI.red()}#{msg}\n"
  end

  # ----------------------------------------------------------------------------
  defp error_msg_found(msg) do
    "#{IO.ANSI.green()}Found: #{IO.ANSI.red()}#{msg}\n"
  end

  # ----------------------------------------------------------------------------
  defp conn_request_path(conn) do
    conn.request_path <>
      case conn.query_string do
        nil -> ""
        "" -> ""
        query -> "?" <> query
      end
  end
end