defmodule Dexcord.FakeRest do @moduledoc false # A scriptable fake Discord REST server for integration tests. # # A `Bandit` server on an ephemeral port runs `Dexcord.FakeRest.Plug`, which # asks this GenServer what to return for each incoming request. Tests stub # per-route responses (status + headers + body) and subscribe to receive a # `{:rest_hit, info}` message for every request, where `info` carries the # method, path, request headers and raw body actually seen on the wire. # # Per-route responses form a FIFO queue; the last stub is sticky, so a single # stub serves repeated calls while a queue of several plays a scripted sequence # (e.g. a 429 then a 200). use GenServer @type response :: %{ status: non_neg_integer(), headers: [{String.t(), String.t()}], body: iodata() } # --- Public API --------------------------------------------------------- @doc false def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) @doc "The bound TCP port." def port, do: GenServer.call(__MODULE__, :port) @doc "The base URL to point `:api_base_url` at." def base_url, do: "http://127.0.0.1:#{port()}" @doc "Subscribes `pid` to `{:rest_hit, info}` notifications." def subscribe(pid \\ self()), do: GenServer.call(__MODULE__, {:subscribe, pid}) @doc "Enqueues a response for `method path`. The last stub for a route is sticky." def stub(method, path, response) do GenServer.call(__MODULE__, {:stub, normalize(method), path, response}) end @doc "Builds a response map with sane defaults." @spec resp(non_neg_integer(), keyword()) :: response() def resp(status, opts \\ []) do %{ status: status, headers: Keyword.get(opts, :headers, []), body: Keyword.get(opts, :body, "") } end @doc false # Called by the plug (in the Bandit connection process). def handle_request(info), do: GenServer.call(__MODULE__, {:handle, info}) defp normalize(m) when is_atom(m), do: m |> to_string() |> String.upcase() defp normalize(m) when is_binary(m), do: String.upcase(m) # --- GenServer ---------------------------------------------------------- @impl true def init(_opts) do {:ok, bandit} = Bandit.start_link( plug: Dexcord.FakeRest.Plug, scheme: :http, ip: {127, 0, 0, 1}, port: 0, startup_log: false ) {:ok, {_addr, port}} = ThousandIsland.listener_info(bandit) {:ok, %{bandit: bandit, port: port, subscriber: nil, queues: %{}}} end @impl true def handle_call(:port, _from, state), do: {:reply, state.port, state} def handle_call({:subscribe, pid}, _from, state), do: {:reply, :ok, %{state | subscriber: pid}} def handle_call({:stub, method, path, response}, _from, state) do queue = Map.get(state.queues, {method, path}, []) queues = Map.put(state.queues, {method, path}, queue ++ [response]) {:reply, :ok, %{state | queues: queues}} end def handle_call({:handle, info}, _from, state) do if state.subscriber, do: send(state.subscriber, {:rest_hit, info}) key = {info.method, info.path} {response, queues} = pop_response(state.queues, key) {:reply, response, %{state | queues: queues}} end # Sticky-last: a queue with more than one entry pops its head; a single entry # stays put and serves every subsequent call for that route. defp pop_response(queues, key) do case Map.get(queues, key, []) do [] -> {default_response(), queues} [only] -> {only, queues} [head | rest] -> {head, Map.put(queues, key, rest)} end end defp default_response, do: %{status: 200, headers: [], body: "{}"} end defmodule Dexcord.FakeRest.Plug do @moduledoc false @behaviour Plug import Plug.Conn @impl true def init(opts), do: opts @impl true def call(conn, _opts) do {:ok, body, conn} = read_body(conn) info = %{ method: conn.method, path: conn.request_path, query_string: conn.query_string, headers: conn.req_headers, body: body } response = Dexcord.FakeRest.handle_request(info) conn |> put_headers(response.headers) |> send_resp(response.status, response.body) end defp put_headers(conn, headers) do Enum.reduce(headers, conn, fn {k, v}, acc -> put_resp_header(acc, to_string(k), to_string(v)) end) end end