lib/github/testing.ex

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

  > #### Note {:.info}
  >
  > The default 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 mocks and calls need to extend past the test process boundary, use `shared: true`
  > on the call to `use GitHub.Testing` or in individual calls to `mock_gh` and `assert_gh_called`.
  > See **Async and Sharing** for more information.

  ## 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.

  ## Async and Sharing

  By default, this library supports async tests with mocks and call tracking within a single
  process. If a test causes or observes other processes to call client functions, then additional
  configuration is necessary.

  First, multi-process mocking and call tracking is not supported with async tests. Ensure that
  `async: true` is **not** present in the call to `use ExUnit.Case` or similar `use` calls (such
  as `use MyApp.DataCase`). To enable shared storage of mocks and calls, add `shared: true` to
  the call to `use GitHub.Testing` or individual calls to `mock_gh` and `assert_gh_called`:

      defmodule MyApp.MyTest do
        use ExUnit.Case
        use GitHub.Testing, shared: true

        # or...

        test "something" do
          my_async_function()
          assert_gh_called &GitHub.Repos.get/2, shared: true
        end
      end

  Mocks and calls will automatically be cleared from the shared storage after each test.

  Shared storage uses an ETS table owned by an unsupervised GenServer. To ensure proper management
  of this process, you may optionally add `GitHub.Testing.Store.ETS` to your application's
  supervisor in test environments.
  """
  require ExUnit.Assertions

  alias GitHub.Operation
  alias GitHub.Testing.Call
  alias GitHub.Testing.Mock
  alias GitHub.Testing.Store

  @doc false
  defmacro __using__(opts) 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,
          to_gh_params: 1
        ]

      setup do
        if unquote(opts)[:shared] do
          GitHub.Testing.Store.set(GitHub.Testing.Store.ETS)

          ExUnit.Callbacks.on_exit(fn ->
            GitHub.Testing.Store.clear_calls(shared: true)
            GitHub.Testing.Store.clear_mocks(shared: true)
          end)
        else
          GitHub.Testing.Store.set(GitHub.Testing.Store.Process)
        end
      end
    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_gh(GitHub.PullRequest)
      %GitHub.PullRequest{}

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

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

      iex> GitHub.Testing.generate_gh(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

  Data generated by this function will appear as if it were decoded by a plugin (for example
  `GitHub.Plugin.TypedDecoder`). Including such a decoder in the stack is not necessary when the
  client uses this data.

  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, :id, :integer), do: System.unique_integer([:positive])
  def generate(_schema, :login, {:string, :generic}), do: Faker.Internet.user_name()
  def generate(GitHub.Repository.Permissions, :pull, :boolean), do: true

  def generate(GitHub.Repository, :created_at, {:union, [{:string, :date_time}, :null]}),
    do: generate(GitHub.Repository, :created_at, {:string, :date_time})

  def generate(GitHub.Repository, :fork, :boolean), do: false
  def generate(GitHub.Repository, :parent, _), do: nil
  def generate(GitHub.Repository, :source, _), do: nil
  def generate(GitHub.Repository, :template_repository, _), do: nil

  def generate(GitHub.User, :name, {:union, [{:string, :generic}, :null]}),
    do: Faker.Person.name()

  def generate(GitHub.User, :type, {:string, :generic}),
    do: Enum.random(["User", "Bot", "Organization"])

  # Primitive types
  def generate(_schema, _key, :boolean), do: Enum.random([true, false])
  def generate(_schema, _key, :integer), do: Enum.random(1..10)

  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, :binary}), do: Faker.String.base64()

  def generate(_schema, _key, {:string, :date}) do
    today = Date.utc_today()
    last_week = Date.add(today, -7)

    Faker.Date.between(last_week, today)
  end

  def generate(_schema, _key, {:string, :date_time}) do
    now = DateTime.utc_now()
    yesterday = DateTime.add(now, -1 * 24 * 60 * 60, :second)

    Faker.DateTime.between(yesterday, now)
  end

  def generate(_schema, _key, {:string, :email}), do: Faker.Internet.email()
  def generate(_schema, _key, {:string, :hostname}), do: Faker.Internet.domain_name()
  def generate(_schema, _key, {:string, :ipv4}), do: Faker.Internet.ip_v4_address()
  def generate(_schema, _key, {:string, :ipv6}), do: Faker.Internet.ip_v6_address()

  def generate(schema, key, {:string, :generic}) do
    string_key = Atom.to_string(key)

    cond do
      String.ends_with?(string_key, "_at") ->
        generate(schema, key, {:string, :date_time}) |> DateTime.to_iso8601()

      String.ends_with?(string_key, "_email") or string_key == "email" ->
        generate(schema, key, {:string, :email})

      String.ends_with?(string_key, "_ref") or string_key == "ref" ->
        Faker.Internet.slug()

      String.ends_with?(string_key, "_sha") or string_key == "sha" ->
        Faker.random_bytes(20) |> Base.encode16(case: :lower)

      String.ends_with?(string_key, "_url") or string_key == "url" ->
        generate(schema, key, {:string, :uri})

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

  def generate(_schema, _key, {:string, :time}) do
    Time.utc_now()
    |> Time.add(:rand.uniform(60 * 60 * 24), :second)
  end

  def generate(_schema, _key, {:string, :uri}), do: Faker.Internet.url()
  def generate(_schema, _key, {:string, :uuid}), do: Faker.UUID.v4()
  def generate(_schema, _key, :null), do: nil

  # Unnatural types
  def generate(_schema, _key, :any), do: nil
  def generate(_schema, _key, :map), do: %{}

  # Compound types
  def generate(schema, key, [type]), do: [generate(schema, key, type)]
  def generate(_schema, _key, {:const, literal}), do: literal
  def generate(_schema, _key, {:enum, literals}), do: Enum.random(literals)

  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, {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)
    |> Map.put(:__info__, %{generated: true})
  end

  @doc """
  Transform a library struct into its string-keyed data equivalent

  This is useful for testing scenarios when a piece of generated data has not been decoded.
  """
  @spec to_gh_params(map) :: map
  @spec to_gh_params([map]) :: [map]
  def to_gh_params(value)
  def to_gh_params(values) when is_list(values), do: Enum.map(values, &to_gh_params/1)
  def to_gh_params(%Date{} = dt), do: Date.to_iso8601(dt)
  def to_gh_params(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
  def to_gh_params(%Time{} = dt), do: Time.to_iso8601(dt)
  def to_gh_params(%_{} = value), do: Map.from_struct(value) |> to_gh_params()

  def to_gh_params(%{} = value),
    do: Map.new(value, fn {key, value} -> {to_string(key), to_gh_params(value)} end)

  def to_gh_params(value), do: value

  #
  # Mocks
  #

  @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.

  ## Options

    * `cache`: For return values expressed as a function, whether the mock's result should be
      cached for future calls with the same arguments. Set to `false` if you want to guarantee
      that the mock function always runs (for example, if it contains assertions). Defaults to
      `true`.

  ## 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)

    Store.get_mocks(module, function, options)
    |> Mock.choose(args)
    |> case do
      %{args: ^args, return: mock_return} ->
        evaluate_mock(mock_return, args, options)

      %{return: mock_return, cache: false} ->
        evaluate_mock(mock_return, args, options)

      %{return: mock_return, cache: true} ->
        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.return()
  def put_mock(module, function, args, return, opts \\ []) do
    cache = opts[:cache] != false
    implicit = opts[:implicit] == true

    # Not yet supported
    limit = opts[:limit] || :infinity

    new_mock = %Mock{
      args: args,
      cache: cache,
      function: function,
      implicit: implicit,
      limit: limit,
      module: module,
      return: return
    }

    Store.put_mock(new_mock, opts)
    return
  end

  #
  # Calls
  #

  @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 put_call(Operation.t()) :: :ok
  def put_call(operation) do
    {module, function, args} = Operation.get_caller(operation)
    opts = Operation.get_options(operation)

    Store.put_call(%Call{args: args, function: function, module: module, opts: opts}, opts)
  end

  @doc false
  @spec assert_call_count(module, atom, Mock.args(), keyword) :: any
  def assert_call_count(module, function, args, opts \\ []) do
    call_count =
      Store.get_calls(module, function, opts)
      |> Enum.count(fn %Call{args: a} -> 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