dexcord/test/support/fake_rest.ex
Luna f3ddb9defb feat(api/cache): add channel-message history, thread creation, snowflakes, cached threads
- 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.
2026-07-04 13:12:54 -03:00

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