- Api.get_channel_messages/2,3 (opts + Nostrum-compatible locator), query baked into the path so the rate limiter buckets it correctly - Api.start_thread_with_message/3,4 (POST .../messages/:id/threads) - Dexcord.Snowflake: from_datetime/to_datetime/to_unix (replaces Nostrum.Snowflake) - Cache.threads/1: thread-type channels (10/11/12) for a guild - FakeRest now records query_string so query building is testable Added to support migrating Alamedya off Nostrum. 178 tests, 0 failures.
143 lines
4.3 KiB
Elixir
143 lines
4.3 KiB
Elixir
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
|