lib/github/testing.ex

defmodule GitHub.Testing do
  @moduledoc """
  Support for interacting with the client in a test environment

  > #### Note {:.info}
  >
  > The current method of tracking and mocking API calls uses the process dictionary for storage.
  > This means that tests can be run async (calls from one process will not affect calls from
  > another). However, it also means that calls from an async task will not be tracked by the test
  > process. If this affects you, please open an issue to discuss possible solutions.

  ## Usage

  The testing facility provided by this library has two parts: this module, which provides helpful
  functions for generating data and asserting that API calls are made, and
  `GitHub.Plugin.TestClient`, which provides a basic mock and support for the test assertions.

  In your test environment, you likely want to use the test client plugin as the only item in the
  client stack:

      # config/test.exs
      config :oapi_github, stack: [{GitHub.Plugin.TestClient, :request, []}]

  The stack can also be configured at runtime by passing the `:stack` option to any operation.

  > #### Warning {:.warning}
  >
  > Stack entries without options, like `{GitHub.Plugin.TestClient, :request}`, look like keyword
  > list items. If you have stacks configured in multiple Mix environments that all use this
  > 2-tuple format, Elixir will try to merge them as keyword lists. Adding an empty options
  > element to any stack item will prevent this behaviour.

  Then, in your test file, `use` this module and make use of the available assertions:

      defmodule MyApp.MyTest do
        use ExUnit.Case
        use GitHub.Testing

        test "something" do
          my_function()
          assert_gh_called &GitHub.Repos.get/2
        end
      end

  The `use` macro also imports `mock_gh/3` and `generate_gh/2` for use in tests.
  """
  require ExUnit.Assertions

  alias GitHub.Operation
  alias GitHub.Testing.Mock

  @doc false
  defmacro __using__(_) do
    quote do
      import GitHub.Testing,
        only: [
          assert_gh_called: 1,
          assert_gh_called: 2,
          generate_gh: 1,
          generate_gh: 2,
          generate_gh: 3,
          mock_gh: 2,
          mock_gh: 3
        ]
    end
  end

  # Convenience guard for generate_gh/3
  defguardp is_enum(value) when is_map(value) or is_list(value)

  #
  # Data Generation
  #

  @doc """
  Generate a struct for use in a test response

  The first argument is the module / schema you would like to generate. If there are multiple
  types available in the module, then the second argument can distinguish which to use (for
  example, `:full` for the type `GitHub.PullRequest.full()`). It is also possible to override
  the generated fields with custom data.

  This function uses randomness to help avoid collisions in tests. For more information, see
  `generate/3`.

  ## Examples

      iex> GitHub.Testing.generate(GitHub.PullRequest)
      %GitHub.PullRequest{}

      iex> GitHub.Testing.generate(GitHub.User, :private)
      %GitHub.User{}

      iex> GitHub.Testing.generate(GitHub.User, bio: "This is a custom bio")
      %GitHub.User{bio: "This is a custom bio"}

      iex> GitHub.Testing.generate(GitHub.User, :private, bio: "This is a custom bio")
      %GitHub.User{bio: "This is a custom bio"}

  """
  @spec generate_gh(module, atom, map | keyword) :: any
  def generate_gh(schema, type \\ :t, overrides \\ %{})

  def generate_gh(schema, overrides, _overrides) when is_enum(overrides) do
    overrides = if is_map(overrides), do: overrides, else: Enum.into(overrides, %{})

    generate(nil, nil, {schema, :t})
    |> override_fields(overrides)
  end

  def generate_gh(schema, type, overrides) when is_atom(type) and is_enum(overrides) do
    overrides = if is_map(overrides), do: overrides, else: Enum.into(overrides, %{})

    generate(nil, nil, {schema, type})
    |> override_fields(overrides)
  end

  def generate_gh(_schema, _type, _overrides) do
    raise ArgumentError, """
    Expected one of the following forms when calling generate_gh(...):

        generate_gh(SchemaModule)
        generate_gh(SchemaModule, :type)
        generate_gh(SchemaModule, :type, overrides)
        generate_gh(SchemaModule, overrides)

    Where overrides is an atom-keyed map or keyword list of fields to change in the result.
    """
  end

  @spec override_fields(struct | map, map) :: struct | map | no_return
  defp override_fields(%_{} = record, attrs), do: struct!(record, attrs)
  defp override_fields(%{} = record, attrs), do: Map.merge(record, attrs)

  @doc """
  Generate random data for use in a test response

  This function uses randomness to help avoid collisions in tests. It also supports special cases
  based on the context of the generated data (the schema and/or key). Examples can be found in the
  **Special Cases** section below.

  ## Special Cases

  * `:id` fields with type `:integer` will have unique positive integers rather than the limited
    range of integers returned by a typical `:integer` field.

  * `:string` fields with keys that end with `_at` will have ISO 8601 datetime strings from a
    random time within the past day.

  * `:string` fields with keys named `:url` or that end with `_url` will have random URL strings.

  Additional special cases can be added to make the generated data more typical of real responses.
  For example, returning a name-like string for user names, and limiting the allowed characters
  for usernames.

  > #### Note {:.info}
  >
  > If you see an opportunity to improve the generated data for a particular field, please include
  > the description of the field from
  > [GitHub's OpenAPI specification](https://github.com/github/rest-api-description)
  > in your pull request.

  ## Examples

      iex> GitHub.Testing.generate(nil, nil, {GitHub.Repository, :full})
      %GitHub.Repository{...}

      iex> GitHub.Testing.generate(GitHub.Repository, :id, :integer)
      226194162

  """
  @spec generate(module, atom, Operation.type()) :: any
  def generate(schema, key, type)

  # Special cases
  def generate(_schema, :email, :string), do: Faker.Internet.email()
  def generate(_schema, :id, :integer), do: System.unique_integer([:positive])
  def generate(_schema, :login, :string), do: Faker.Internet.user_name()
  def generate(_schema, :url, :string), do: Faker.Internet.url()

  def generate(GitHub.User, :name, :string), do: Faker.Person.name()
  def generate(GitHub.User, :type, :string), do: Enum.random(["User", "Bot", "Organization"])

  # Primitive types
  def generate(_schema, _key, :binary), do: Faker.String.base64()
  def generate(_schema, _key, :boolean), do: Enum.random([true, false])
  def generate(_schema, _key, :integer), do: Enum.random(1..10)
  def generate(_schema, _key, :map), do: %{}

  def generate(_schema, _key, :number) do
    Enum.random([
      fn -> Enum.random(1..10) end,
      fn -> Faker.random_uniform() * 10 end
    ]).()
  end

  def generate(_schema, key, :string) do
    string_key = Atom.to_string(key)

    cond do
      String.ends_with?(string_key, "_at") ->
        now = DateTime.utc_now()
        yesterday = DateTime.add(now, -1, :day)

        Faker.DateTime.between(yesterday, now)
        |> DateTime.to_iso8601()

      String.ends_with?(string_key, "_url") ->
        Faker.Internet.url()

      :else ->
        Faker.Lorem.characters(15..30) |> to_string()
    end
  end

  def generate(_schema, _key, :null), do: nil
  def generate(_schema, _key, :unknown), do: nil

  # Compound types
  def generate(schema, key, {:array, type}), do: [generate(schema, key, type)]

  def generate(schema, key, {:union, types}) do
    Enum.map(types, fn type ->
      fn -> generate(schema, key, type) end
    end)
    |> Enum.random()
    |> apply([])
  end

  def generate(schema, key, {:nullable, type}) do
    Enum.random([
      fn -> nil end,
      fn -> generate(schema, key, type) end
    ]).()
  end

  def generate(_schema, _key, {module, struct_type}) do
    apply(module, :__fields__, [struct_type])
    |> Enum.map(fn {key, field_type} -> {key, generate(module, key, field_type)} end)
    |> then(fn fields -> struct!(module, fields) end)
  end

  #
  # Mocks
  #

  @pd_mock_key :oapi_github_test_mock

  @doc """
  Mock a response for an API endpoint

  ## API Endpoint

  The API endpoint can be passed as a function call or using function capture syntax. If passed
  as a function call, the arguments must match exactly or use the special value `:_` to match any
  value. If passed using function capture syntax, only the arity will be matched. The options
  argument is not considered during these checks.

  ## Return Value

  As a return value for a mock, you can set a plain value or pass one of several function forms:

  * A zero-arity function will be evaluated a call time. Use this to perform lazy evaluation of
    the mock or encapsulate a generator.

  * A function with the same number of arguments as the original client operation (**not**
    including the final `opts` argument) will be called with the same arguments. Use this to
    perform assertions on the arguments or return a different value depending on the call.

  * A function with the same number of arguments as the original client operation (including the
    final `opts` argument) will be called with the same arguments and options. Use this to perform
    assertions on the arguments and options or return a different value depending on the call.

  In each case, the plain value — or the value returned from the function — should have one of the
  following forms:

      {:ok, data}
      {:ok, data, opts}
      {:error, error}
      {:error, error, opts}

  Where `data` is the response body and `error` is the error to return, and `opts` modifies the
  response. The available options are:

  * `code` (integer): Status code to include with the response.

  In addition, the following pre-defined error responses are available:

  * `{:error, :not_found}` will return an error matching GitHub's standard "Not Found" response.
  * `{:error, :rate_limited}` with return an error matching a GitHub API rate limited response.
  * `{:error, :unauthorized}` will return an error matching GitHub's unauthorized response.

  ## Examples

      mock_gh GitHub.Repos.get("owner", "repo"), {:ok, %GitHub.Repository{}}
      mock_gh GitHub.Repos.get("owner", "repo-2"), {:ok, %GitHub.Repository{}, code: 201}
      mock_gh GitHub.Repos.get("friend", "repo"), {:error, :not_found}
      mock_gh GitHub.Repos.get("friend", "repo-2"), {:error, %GitHub.Error{}, code: 403}

      mock_gh &GitHub.Repos.get/2, fn -> {:ok, %GitHub.Repository{}} end

      mock_gh &GitHub.Repos.get/2, fn owner, name ->
        assert String.starts_with?(name, "oapi_")
        {:ok, %GitHub.Repository{owner: owner, name: name}}
      end

      mock_gh &GitHub.Repos.get/2, fn owner, name, opts ->
        assert opts[:auth] == "gho_token"
        {:ok, %GitHub.Repository{owner: owner, name: name}}
      end

  """
  defmacro mock_gh(call, return_fn, opts \\ [])

  defmacro mock_gh({{:., _, [module, function]}, _, args}, return_fn, opts) do
    module = Macro.expand(module, __CALLER__)

    quote do
      GitHub.Testing.put_mock(
        unquote(module),
        unquote(function),
        unquote(args),
        unquote(return_fn),
        unquote(opts)
      )
    end
  end

  defmacro mock_gh(
             {:&, _, [{:/, _, [{{:., _, [module, function]}, _, _}, arity]}]},
             return_fn,
             opts
           ) do
    module = Macro.expand(module, __CALLER__)

    quote do
      GitHub.Testing.put_mock(
        unquote(module),
        unquote(function),
        unquote(arity),
        unquote(return_fn),
        unquote(opts)
      )
    end
  end

  # Not ready for public use

  @doc false
  @spec get_mock_result(Operation.t()) :: Mock.return()
  def get_mock_result(operation) do
    {module, function, args} = Operation.get_caller(operation)
    options = Operation.get_options(operation)

    Process.get(@pd_mock_key, %{})
    |> Map.get({module, function})
    |> Mock.choose(args)
    |> case do
      %{args: ^args, return: mock_return} ->
        evaluate_mock(mock_return, args, options)

      %{return: mock_return} ->
        return_value = evaluate_mock(mock_return, args, options)
        put_mock(module, function, args, return_value, implicit: true)

      nil ->
        mock_return_fn = default_response_fn(operation)
        put_mock(module, function, length(args), mock_return_fn, implicit: true)
        put_mock(module, function, args, mock_return_fn.(), implicit: true)
    end
  end

  @spec evaluate_mock(Mock.return_fun(), [any], keyword) :: Mock.return()
  defp evaluate_mock(fun, _args, _opts) when is_function(fun, 0), do: fun.()
  defp evaluate_mock(fun, args, _opts) when is_function(fun, length(args)), do: apply(fun, args)

  defp evaluate_mock(fun, args, opts) when is_function(fun, length(args) + 1),
    do: apply(fun, args ++ [opts])

  defp evaluate_mock(return, _args, _opts), do: return

  @spec default_response_fn(Operation.t()) :: Mock.return_fun()
  defp default_response_fn(%Operation{response_types: [{code, type} | _]}) do
    fn -> {:ok, GitHub.Testing.generate(nil, nil, type), code: code} end
  end

  @doc false
  @spec put_mock(module, atom, Mock.args(), Mock.return_fun(), keyword) :: Mock.t()
  def put_mock(module, function, args, return, opts \\ []) do
    implicit = opts[:implicit] == true
    limit = opts[:limit] || :infinity

    all_mocks = Process.get(@pd_mock_key, %{})
    new_mock = %Mock{args: args, implicit: implicit, limit: limit, return: return}
    new_mocks = [new_mock | Map.get(all_mocks, {module, function}, [])]
    updated_mocks = Map.put(all_mocks, {module, function}, new_mocks)

    Process.put(@pd_mock_key, updated_mocks)
    return
  end

  #
  # Calls
  #

  @pd_call_key :oapi_github_test_call

  @typep call :: {module :: module, function :: atom, args :: [any]}

  @doc """
  Assert the number of times an API endpoint was called

  The API endpoint can be passed as a function call or using function capture syntax. If passed
  as a function call, the arguments must match exactly or use the special value `:_` to match any
  value. If passed using function capture syntax, only the arity will be matched. The options
  argument is not considered during these checks.

  ## Examples

      assert_gh_called GitHub.Repos.get("owner", "repo")
      assert_gh_called GitHub.Repos.get("owner", :_), times: 2
      assert_gh_called GitHub.Repos.get(owner, "repo"), min: 2, max: 3
      assert_gh_called &GitHub.Repos.get/2, times: 0

  ## Options

    * `max`: Non-negative integer representing the maximum number of times a matching call should
      have occurred. If unspecified, there is no upper limit on the acceptable number of calls.
      If none of `times`, `min`, or `max` are specified, the default is to assert at least
      one matching call.

    * `min`: Non-negative integer representing the minimum number of times a matching call should
      have occurred. If unspecified, there is no lower limit on the acceptable number of calls.
      If none of `times`, `min`, or `max` are specified, the default is to assert at least
      one matching call.

    * `times`: Non-negative integer number of times a matching call should have occurred. Passing
      zero will assert the endpoint has not been called. This option has precedence over `min` and
      `max`. If none of `times`, `min`, or `max` are specified, the default is to assert at least
      one matching call.

  """
  defmacro assert_gh_called(call, opts \\ [])

  defmacro assert_gh_called({{:., _, [module, function]}, _, args}, opts) do
    module = Macro.expand(module, __CALLER__)

    quote do
      GitHub.Testing.assert_call_count(
        unquote(module),
        unquote(function),
        unquote(args),
        unquote(opts)
      )
    end
  end

  defmacro assert_gh_called(
             {:&, _, [{:/, _, [{{:., _, [module, function]}, _, _}, arity]}]},
             opts
           ) do
    module = Macro.expand(module, __CALLER__)

    quote do
      GitHub.Testing.assert_call_count(
        unquote(module),
        unquote(function),
        unquote(arity),
        unquote(opts)
      )
    end
  end

  defmacro assert_gh_called(_call, _opts) do
    raise ArgumentError, "Unknown form passed to `assert_gh_called`"
  end

  @spec count_restriction(keyword) :: {:== | :<= | :>=, integer} | {:.., integer, integer}
  defp count_restriction(%{times: times}) when is_integer(times) and times >= 0, do: {:==, times}

  defp count_restriction(%{times: times}) when is_integer(times) and times < 0 do
    raise ArgumentError, "Option `:times` must be a non-negative integer"
  end

  defp count_restriction(%{min: min, max: max}) when is_integer(min) and is_integer(max) do
    cond do
      min < 0 or max < 0 ->
        raise ArgumentError, "Options `:min` and `:max` must be non-negative integers"

      min > max ->
        raise ArgumentError, "Option `:min` must be less than or equal to `:max`"

      :else ->
        {:.., min, max}
    end
  end

  defp count_restriction(%{min: min}) when is_integer(min) and min >= 0, do: {:>=, min}
  defp count_restriction(%{max: max}) when is_integer(max) and max >= 0, do: {:<=, max}

  defp count_restriction(%{min: min}) when is_integer(min) do
    raise ArgumentError, "Option `:min` must be a non-negative integer"
  end

  defp count_restriction(%{max: max}) when is_integer(max) do
    raise ArgumentError, "Option `:max` must be a non-negative integer"
  end

  defp count_restriction(_opts), do: {:>=, 1}

  @spec args_match?(Mock.args(), [any]) :: boolean
  defp args_match?(arity, call_args) when is_integer(arity), do: length(call_args) == arity
  defp args_match?(args, call_args) when length(args) != length(call_args), do: false

  defp args_match?(args, call_args) do
    Enum.zip(args, call_args)
    |> Enum.all?(fn
      {:_, _} -> true
      {same_value, same_value} -> true
      _else -> false
    end)
  end

  # Not ready for public use

  @doc false
  @spec get_calls :: [call]
  def get_calls do
    Process.get(@pd_call_key, [])
  end

  @doc false
  @spec put_call(Operation.t()) :: :ok
  def put_call(operation) do
    new_call = Operation.get_caller(operation)

    all_calls = Process.get(@pd_call_key, [])
    updated_calls = [new_call | all_calls]

    Process.put(@pd_call_key, updated_calls)
    :ok
  end

  @doc false
  @spec assert_call_count(module, atom, Mock.args(), keyword) :: any
  def assert_call_count(module, function, args, opts \\ []) do
    call_count =
      get_calls()
      |> Enum.count(fn {m, f, a} ->
        module == m and function == f and args_match?(args, a)
      end)

    Keyword.take(opts, [:times, :min, :max])
    |> Enum.into(%{})
    |> count_restriction()
    |> case do
      {:==, times} -> ExUnit.Assertions.assert(call_count == times)
      {:>=, min} -> ExUnit.Assertions.assert(call_count >= min)
      {:<=, max} -> ExUnit.Assertions.assert(call_count <= max)
      {:.., min, max} -> ExUnit.Assertions.assert(call_count in min..max)
    end
  end
end