From 7a46d45df2502b5fb0f3ecfb59301ff6f1a476f9 Mon Sep 17 00:00:00 2001 From: martosaur Date: Sat, 11 Jan 2025 09:30:17 -0800 Subject: [PATCH] refactor: Redesign main modules to enable ad-hoc usage --- config/test.exs | 2 +- lib/disco_log.ex | 59 ++- lib/disco_log/client.ex | 104 ----- lib/disco_log/config.ex | 23 +- lib/disco_log/discord.ex | 144 +++--- lib/disco_log/discord/api.ex | 114 +++++ lib/disco_log/discord/api/client.ex | 33 ++ lib/disco_log/discord/client.ex | 171 -------- lib/disco_log/discord/config.ex | 47 -- lib/disco_log/discord/context.ex | 232 ---------- lib/disco_log/discord/prepare.ex | 99 +++++ lib/disco_log/logger_handler.ex | 103 ++--- lib/disco_log/presence.ex | 21 +- lib/disco_log/storage.ex | 25 +- lib/disco_log/supervisor.ex | 9 +- lib/disco_log/websocket_client.ex | 2 +- lib/mix/tasks/disco_log.cleanup.ex | 14 +- lib/mix/tasks/disco_log.create.ex | 82 +++- lib/mix/tasks/disco_log.drop.ex | 21 +- test/disco_log/application_test.exs | 8 +- test/disco_log/config_test.exs | 8 +- test/disco_log/discord/prepare_test.exs | 226 ++++++++++ test/disco_log/integrations/oban_test.exs | 36 +- test/disco_log/integrations/tesla_test.exs | 32 +- test/disco_log/logger_handler_test.exs | 488 +++++++++++++++------ test/disco_log/presence_test.exs | 23 +- test/disco_log/sasl_test.exs | 21 +- test/disco_log/storage_test.exs | 72 +-- test/support/case.ex | 53 +-- test/support/discord/api/stub.ex | 235 ++++++++++ test/support/mocks.ex | 2 +- 31 files changed, 1476 insertions(+), 1033 deletions(-) delete mode 100644 lib/disco_log/client.ex create mode 100644 lib/disco_log/discord/api.ex create mode 100644 lib/disco_log/discord/api/client.ex delete mode 100644 lib/disco_log/discord/client.ex delete mode 100644 lib/disco_log/discord/config.ex delete mode 100644 lib/disco_log/discord/context.ex create mode 100644 lib/disco_log/discord/prepare.ex create mode 100644 test/disco_log/discord/prepare_test.exs create mode 100644 test/support/discord/api/stub.ex diff --git a/config/test.exs b/config/test.exs index 39724ea..4b90c9b 100644 --- a/config/test.exs +++ b/config/test.exs @@ -9,7 +9,7 @@ config :disco_log, occurrences_channel_id: "", info_channel_id: "", error_channel_id: "", - discord: DiscoLog.DiscordMock, + discord_client_module: DiscoLog.Discord.API.Mock, websocket_adapter: DiscoLog.WebsocketClient.Mock, enable_presence: true diff --git a/lib/disco_log.ex b/lib/disco_log.ex index 56723cb..9da97ab 100644 --- a/lib/disco_log.ex +++ b/lib/disco_log.ex @@ -2,11 +2,68 @@ defmodule DiscoLog do @moduledoc """ Elixir-based built-in error tracking solution. """ + alias DiscoLog.Dedupe + alias DiscoLog.Error + alias DiscoLog.Storage + alias DiscoLog.Discord.API + alias DiscoLog.Discord.Prepare + def report(exception, stacktrace, given_context \\ %{}, config \\ nil) do config = config || DiscoLog.Config.read!() context = Map.merge(DiscoLog.Context.get(), given_context) error = DiscoLog.Error.new(exception, stacktrace, context, config) - DiscoLog.Client.send_error(error, config) + send_error(error, config) + end + + def send_error(%Error{} = error, config) do + with :ok <- maybe_dedupe(error, config) do + config.supervisor_name + |> Storage.get_thread_id(error.fingerprint) + |> case do + nil -> + available_tags = Storage.get_tags(config.supervisor_name) || %{} + + applied_tags = + error.context + |> Map.keys() + |> Enum.filter(&(&1 in Map.keys(available_tags))) + |> Enum.map(&Map.fetch!(available_tags, &1)) + + message = Prepare.prepare_occurrence(error, applied_tags) + + with {:ok, %{status: 201, body: %{"id" => thread_id}}} <- + API.post_thread(config.discord_client, config.occurrences_channel_id, message) do + Storage.add_thread_id(config.supervisor_name, error.fingerprint, thread_id) + end + + thread_id -> + message = Prepare.prepare_occurrence_message(error) + + API.post_message(config.discord_client, thread_id, message) + end + end + end + + def log_info(message, metadata, config) do + message = Prepare.prepare_message(message, metadata) + + API.post_message(config.discord_client, config.info_channel_id, message) + end + + def log_error(message, metadata, config) do + message = Prepare.prepare_message(message, metadata) + + API.post_message(config.discord_client, config.error_channel_id, message) + end + + defp maybe_dedupe(%Error{} = error, config) do + case Dedupe.insert(config.supervisor_name, error) do + :new -> + :ok + + :existing -> + :excluded + end end end diff --git a/lib/disco_log/client.ex b/lib/disco_log/client.ex deleted file mode 100644 index 89a3001..0000000 --- a/lib/disco_log/client.ex +++ /dev/null @@ -1,104 +0,0 @@ -defmodule DiscoLog.Client do - @moduledoc """ - Client for DiscoLog. - """ - alias DiscoLog.Dedupe - alias DiscoLog.Error - alias DiscoLog.Storage - - def send_error(%Error{} = error, config) do - config = put_dynamic_tags(config) - - with {:ok, %Error{} = error} <- maybe_call_before_send(error, config.before_send), - :ok <- maybe_dedupe(error, config) do - do_send_error(error, config) - end - end - - def log_info(message, metadata, config) do - with {:ok, {message, metadata}} <- - maybe_call_before_send({message, metadata}, config.before_send) do - config.discord.create_message( - config.discord_config, - config.info_channel_id, - message, - metadata - ) - end - end - - def log_error(message, metadata, config) do - with {:ok, {message, metadata}} <- - maybe_call_before_send({message, metadata}, config.before_send) do - config.discord.create_message( - config.discord_config, - config.error_channel_id, - message, - metadata - ) - end - end - - defp maybe_dedupe(%Error{} = error, config) do - case Dedupe.insert(config.supervisor_name, error) do - :new -> - :ok - - :existing -> - :excluded - end - end - - defp do_send_error(%Error{} = error, config) do - config.supervisor_name - |> Storage.get_thread_id(error.fingerprint) - |> create_thread_or_add_message(error, config) - end - - defp create_thread_or_add_message(nil, error, config) do - with {:ok, thread} <- config.discord.create_occurrence_thread(config.discord_config, error) do - Storage.add_thread_id(config.supervisor_name, error.fingerprint, thread["id"]) - end - end - - defp create_thread_or_add_message(thread_id, error, config) do - config.discord.create_occurrence_message(config.discord_config, thread_id, error) - end - - defp maybe_call_before_send(%Error{} = error, nil) do - {:ok, error} - end - - defp maybe_call_before_send({message, metadata}, nil) do - {:ok, {message, metadata}} - end - - defp maybe_call_before_send(error, callback) do - if result = call_before_send(error, callback) do - {:ok, result} - else - :excluded - end - end - - defp call_before_send(error, function) when is_function(function, 1) do - function.(error) || false - end - - defp call_before_send(error, {mod, fun}) do - apply(mod, fun, [error]) || false - end - - defp call_before_send(_error, other) do - raise ArgumentError, """ - :before_send must be an anonymous function or a {module, function} tuple, got: \ - #{inspect(other)}\ - """ - end - - defp put_dynamic_tags(config) do - update_in(config, [:discord_config, Access.key!(:occurrences_channel_tags)], fn _ -> - Storage.get_tags(config.supervisor_name) - end) - end -end diff --git a/lib/disco_log/config.ex b/lib/disco_log/config.ex index 34980f9..98bad2f 100644 --- a/lib/disco_log/config.ex +++ b/lib/disco_log/config.ex @@ -1,6 +1,4 @@ defmodule DiscoLog.Config do - alias DiscoLog.Discord - @configuration_schema [ otp_app: [ type: :atom, @@ -50,7 +48,7 @@ defmodule DiscoLog.Config do enable_discord_log: [ type: :boolean, default: false, - doc: "Logs requests to Discord API?" + doc: "Log requests to Discord API?" ], enable_presence: [ type: :boolean, @@ -82,15 +80,9 @@ defmodule DiscoLog.Config do default: [:cowboy, :bandit], doc: "Logs with domains from this list will be ignored" ], - before_send: [ - type: {:or, [nil, :mod_arg, {:fun, 1}]}, - default: nil, - doc: - "This callback will be called with error or {message, metadata} tuple as argument before it is sent" - ], - discord: [ + discord_client_module: [ type: :atom, - default: DiscoLog.Discord, + default: DiscoLog.Discord.API.Client, doc: "Discord client to use" ], supervisor_name: [ @@ -129,7 +121,7 @@ defmodule DiscoLog.Config do @compiled_schema NimbleOptions.new!(@configuration_schema) @moduledoc """ - Configuration related module for DiscoLog. + DiscoLog configuration ## Configuration Schema @@ -166,9 +158,9 @@ defmodule DiscoLog.Config do """ @spec validate(options :: keyword() | map()) :: {:ok, config()} | {:error, NimbleOptions.ValidationError.t()} - def validate(%{discord_config: _} = config) do + def validate(%{discord_client: _} = config) do config - |> Map.delete(:discord_config) + |> Map.delete(:discord_client) |> validate() end @@ -179,7 +171,8 @@ defmodule DiscoLog.Config do validated |> Map.new() |> then(fn config -> - Map.put(config, :discord_config, Discord.Config.new(config)) + client = config.discord_client_module.client(config.token) + Map.put(config, :discord_client, %{client | log?: config.enable_discord_log}) end) {:ok, config} diff --git a/lib/disco_log/discord.ex b/lib/disco_log/discord.ex index 8f3a123..172c3f2 100644 --- a/lib/disco_log/discord.ex +++ b/lib/disco_log/discord.ex @@ -1,84 +1,66 @@ -defmodule DiscoLog.DiscordBehaviour do - @moduledoc false - @type config :: %DiscoLog.Discord.Config{} - - @callback list_channels(config()) :: {:ok, list(any)} | {:error, String.t()} - - @callback fetch_or_create_channel( - config(), - channels :: list(any), - channel_config :: map(), - parent_id :: nil | String.t() - ) :: {:ok, map()} | {:error, String.t()} - - @callback maybe_delete_channel(config(), channels :: list(any), channel_config :: map()) :: - {:ok, map()} | {:error, String.t()} - - @callback create_occurrence_thread(config(), error :: String.t()) :: - {:ok, map()} | {:error, String.t()} - - @callback create_occurrence_message(config(), thread_id :: String.t(), error :: String.t()) :: - {:ok, map()} | {:error, String.t()} - - @callback list_occurrence_threads(config(), occurrence_channel_id :: String.t()) :: list(any) - - @callback delete_channel_messages(config(), channel_id :: String.t()) :: - list(any) - - @callback create_message( - config(), - channel_id :: String.t(), - message :: String.t(), - metadata :: map() - ) :: - {:ok, map()} | {:error, String.t()} - - @callback delete_threads(config(), channel_id :: String.t()) :: list(any) - - @callback get_gateway(config()) :: {:ok, String.t()} | {:error, String.t()} - - @callback list_tags(config(), occurence_channel_id :: String.t()) :: map() -end - defmodule DiscoLog.Discord do - @moduledoc """ - Abstraction over Discord api - """ - @behaviour DiscoLog.DiscordBehaviour - - alias DiscoLog.Discord - - @impl true - def list_channels(config), do: Discord.Client.list_channels(config) - - @impl true - defdelegate fetch_or_create_channel(config, channels, channel_config, parent_id \\ nil), - to: Discord.Context - - @impl true - defdelegate maybe_delete_channel(config, channels, channel_config), to: Discord.Context - - @impl true - defdelegate create_occurrence_thread(config, error), to: Discord.Context - - @impl true - defdelegate create_occurrence_message(config, thread_id, error), to: Discord.Context - - @impl true - defdelegate list_occurrence_threads(config, occurrence_channel_id), to: Discord.Context - - @impl true - defdelegate delete_channel_messages(config, channel_id), to: Discord.Context - - @impl true - defdelegate create_message(config, channel_id, message, metadata), to: Discord.Context - - @impl true - defdelegate delete_threads(config, channel_id), to: Discord.Context - - @impl true - defdelegate get_gateway(config), to: Discord.Context + @moduledoc false - @impl true - defdelegate list_tags(config, occurence_channel_id), to: Discord.Context + alias DiscoLog.Discord.API + alias DiscoLog.Discord.Prepare + + def list_occurrence_threads(discord_client, guild_id, occurrences_channel_id) do + case API.list_active_threads(discord_client, guild_id) do + {:ok, %{status: 200, body: %{"threads" => threads}}} -> + active_threads = + threads + |> Enum.filter(&(&1["parent_id"] == occurrences_channel_id)) + |> Enum.map(&{Prepare.fingerprint_from_thread_name(&1["name"]), &1["id"]}) + |> Map.new() + + {:ok, active_threads} + + {:ok, response} -> + {:error, response} + + other -> + other + end + end + + def list_occurrence_tags(discord_client, occurrences_channel_id) do + case API.get_channel(discord_client, occurrences_channel_id) do + {:ok, %{status: 200, body: %{"available_tags" => available_tags}}} -> + tags = for %{"id" => id, "name" => name} <- available_tags, into: %{}, do: {name, id} + {:ok, tags} + + {:ok, response} -> + {:error, response} + + error -> + error + end + end + + def get_gateway(discord_client) do + case API.get_gateway(discord_client) do + {:ok, %{status: 200, body: %{"url" => raw_uri}}} -> URI.new(raw_uri) + {:ok, response} -> {:error, response} + error -> error + end + end + + def delete_threads(discord_client, guild_id, channel_id) do + {:ok, %{status: 200, body: %{"threads" => threads}}} = + API.list_active_threads(discord_client, guild_id) + + threads + |> Enum.filter(&(&1["parent_id"] == channel_id)) + |> Enum.map(fn %{"id" => thread_id} -> + {:ok, %{status: 200}} = API.delete_thread(discord_client, thread_id) + end) + end + + def delete_channel_messages(discord_client, channel_id) do + {:ok, %{status: 200, body: messages}} = API.get_channel_messages(discord_client, channel_id) + + for %{"id" => message_id} <- messages do + API.delete_message(discord_client, channel_id, message_id) + end + end end diff --git a/lib/disco_log/discord/api.ex b/lib/disco_log/discord/api.ex new file mode 100644 index 0000000..10856a1 --- /dev/null +++ b/lib/disco_log/discord/api.ex @@ -0,0 +1,114 @@ +defmodule DiscoLog.Discord.API do + @moduledoc """ + A module for working with Discord REST API. + https://discord.com/developers/docs/reference + + This module is also a behavior. The default implementation uses the `Req` HTTP client. + If you want to use a different client, you'll need to implement the behavior and + put it under the `discord_client_module` configuration option. + """ + + require Logger + + defstruct [:client, :module, :log?] + + @typedoc """ + The client can be any term. It is passed as a first argument to `c:request/4`. For example, the + default `DiscoLog.Discord.API.Client` client uses `Req.Request.t()` as a client. + """ + @type client() :: any() + @type response() :: {:ok, %{status: non_neg_integer(), body: any()}} | {:error, Exception.t()} + + @callback client(token :: String.t()) :: %__MODULE__{client: client(), module: atom()} + @callback request(client :: client(), method :: atom(), url :: String.t(), opts :: keyword()) :: + response() + + @spec list_active_threads(client(), String.t()) :: response() + def list_active_threads(%__MODULE__{} = client, guild_id) do + with_log(client, :get, "/guilds/:guild_id/threads/active", path_params: [guild_id: guild_id]) + end + + @spec list_channels(client(), String.t()) :: response() + def list_channels(%__MODULE__{} = client, guild_id) do + with_log(client, :get, "/guilds/:guild_id/channels", path_params: [guild_id: guild_id]) + end + + @spec get_channel(client(), String.t()) :: response() + def get_channel(%__MODULE__{} = client, channel_id) do + with_log(client, :get, "/channels/:channel_id", path_params: [channel_id: channel_id]) + end + + @spec get_channel_messages(client(), String.t()) :: response() + def get_channel_messages(%__MODULE__{} = client, channel_id) do + with_log(client, :get, "/channels/:channel_id/messages", + path_params: [channel_id: channel_id] + ) + end + + @spec get_gateway(client()) :: response() + def get_gateway(%__MODULE__{} = client) do + with_log(client, :get, "/gateway/bot", []) + end + + @spec create_channel(client(), String.t(), map()) :: response() + def create_channel(%__MODULE__{} = client, guild_id, body) do + with_log(client, :post, "/guilds/:guild_id/channels", + path_params: [guild_id: guild_id], + json: body + ) + end + + @spec post_message(client(), String.t(), Keyword.t()) :: response() + def post_message(%__MODULE__{} = client, channel_id, fields) do + with_log(client, :post, "/channels/:channel_id/messages", + path_params: [channel_id: channel_id], + form_multipart: fields + ) + end + + @spec post_thread(client(), String.t(), Keyword.t()) :: response() + def post_thread(%__MODULE__{} = client, channel_id, fields) do + with_log(client, :post, "/channels/:channel_id/threads", + path_params: [channel_id: channel_id], + form_multipart: fields + ) + end + + @spec delete_thread(client(), String.t()) :: response() + def delete_thread(%__MODULE__{} = client, thread_id) do + with_log(client, :delete, "/channels/:thread_id", path_params: [thread_id: thread_id]) + end + + @spec delete_message(client(), String.t(), String.t()) :: response() + def delete_message(%__MODULE__{} = client, channel_id, message_id) do + with_log(client, :delete, "/channels/:channel_id/messages/:message_id", + path_params: [channel_id: channel_id, message_id: message_id] + ) + end + + @spec delete_channel(client(), String.t()) :: response() + def delete_channel(%__MODULE__{} = client, channel_id) do + with_log(client, :delete, "/channels/:channel_id", path_params: [channel_id: channel_id]) + end + + defp with_log(client, method, url, opts) do + resp = client.module.request(client.client, method, url, opts) + + if client.log? do + request = "#{method |> to_string() |> String.upcase()} #{to_string(url)}\n" + + response = + case resp do + {:ok, resp} -> + "Status: #{inspect(resp.status)}\nBody: #{inspect(resp.body, pretty: true)}" + + {:error, error} -> + "Error: #{inspect(error, pretty: true)}" + end + + Logger.debug("Request: #{request}\n#{inspect(opts, pretty: true)}\n#{response}") + end + + resp + end +end diff --git a/lib/disco_log/discord/api/client.ex b/lib/disco_log/discord/api/client.ex new file mode 100644 index 0000000..98e879b --- /dev/null +++ b/lib/disco_log/discord/api/client.ex @@ -0,0 +1,33 @@ +defmodule DiscoLog.Discord.API.Client do + @moduledoc """ + Default `DiscoLog.Discord.API` implementation. + """ + @behaviour DiscoLog.Discord.API + + @version DiscoLog.MixProject.project()[:version] + + @impl DiscoLog.Discord.API + def client(token) do + client = + Req.new( + base_url: "https://discord.com/api/v10", + headers: [ + {"User-Agent", "DiscoLog (https://github.com/mrdotb/disco-log, #{@version}"} + ], + auth: "Bot #{token}" + ) + + %DiscoLog.Discord.API{client: client, module: __MODULE__} + end + + @impl DiscoLog.Discord.API + def request(client, method, url, opts) do + client + |> Req.merge( + method: method, + url: url + ) + |> Req.merge(opts) + |> Req.request() + end +end diff --git a/lib/disco_log/discord/client.ex b/lib/disco_log/discord/client.ex deleted file mode 100644 index 0972218..0000000 --- a/lib/disco_log/discord/client.ex +++ /dev/null @@ -1,171 +0,0 @@ -defmodule DiscoLog.Discord.Client do - @moduledoc """ - This module contains the Discord API client. - """ - - require Logger - - @base_url "https://discord.com/api/v10" - @version DiscoLog.MixProject.project()[:version] - - def list_channels(config) do - case Req.get!(client(config), url: "/guilds/#{config.guild_id}/channels") do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to list channels"} - end - end - - def create_channel(config, params) do - case Req.post!( - client(config), - url: "/guilds/#{config.guild_id}/channels", - json: params - ) do - %Req.Response{status: 201, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to create channel"} - end - end - - def delete_channel(config, channel_id) do - case Req.delete!(client(config), url: "/channels/#{channel_id}") do - %Req.Response{status: 204} -> - :ok - - _ -> - {:error, "Failed to delete channel"} - end - end - - def list_active_threads(config) do - case Req.get!(client(config), url: "/guilds/#{config.guild_id}/threads/active") do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to list threads"} - end - end - - def create_form_forum_thread(config, fields) do - case Req.post!(client(config), - url: "/channels/#{config.occurrences_channel_id}/threads", - form_multipart: fields - ) do - %Req.Response{status: 201, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to create forum thread"} - end - end - - def delete_thread(config, thread_id) do - case Req.delete!(client(config), url: "/channels/#{thread_id}") do - %Req.Response{status: 204} -> - :ok - - _ -> - {:error, "Failed to delete thread"} - end - end - - def create_form_message(config, channel_id, fields) do - case Req.post!(client(config), - url: "/channels/#{channel_id}/messages", - form_multipart: fields - ) do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to create form message"} - end - end - - def create_json_message(config, channel_id, params) do - case Req.post!(client(config), url: "/channels/#{channel_id}/messages", json: params) do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to create json message"} - end - end - - def list_messages(config, channel_id, params \\ []) do - case Req.get!(client(config), url: "/channels/#{channel_id}/messages", params: params) do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to list messages"} - end - end - - def delete_message(config, channel_id, message_id) do - case Req.delete!(client(config), - url: "/channels/#{channel_id}/messages/#{message_id}", - max_retries: 10 - ) do - %Req.Response{status: 204} -> - :ok - - _ -> - {:error, "Failed to delete message"} - end - end - - def get_gateway(config) do - case Req.get!(client(config), url: "/gateway/bot") do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to get gateway"} - end - end - - def get_channel(config, channel_id) do - case Req.get!(client(config), url: "/channels/#{channel_id}") do - %Req.Response{status: 200, body: body} -> - {:ok, body} - - _ -> - {:error, "Failed to get channel"} - end - end - - def client(config) do - Req.new( - base_url: @base_url, - headers: [ - {"User-Agent", "DiscoLog (https://github.com/mrdotb/disco-log, #{@version})"}, - {"Authorization", "Bot #{config.token}"} - ] - ) - |> maybe_add_debug_log(config.enable_discord_log) - end - - defp maybe_add_debug_log(request, false), do: request - - defp maybe_add_debug_log(request, true) do - Req.Request.append_response_steps(request, log_response: &log_response/1) - end - - defp log_response({req, res} = result) do - Logger.debug(""" - Request: #{inspect(to_string(req.url), pretty: true)} - Request: #{inspect(to_string(req.body), pretty: true)} - Status: #{inspect(res.status, pretty: true)} - Body: #{inspect(res.body, pretty: true)} - """) - - result - end -end diff --git a/lib/disco_log/discord/config.ex b/lib/disco_log/discord/config.ex deleted file mode 100644 index e46395e..0000000 --- a/lib/disco_log/discord/config.ex +++ /dev/null @@ -1,47 +0,0 @@ -defmodule DiscoLog.Discord.Config do - @moduledoc """ - Discord configuration module. - """ - - @category %{ - name: "disco-log", - type: 4 - } - - @tags ~w(plug live_view oban)s - - @occurrences_channel %{ - name: "occurrences", - type: 15, - available_tags: Enum.map(@tags, &%{name: &1}) - } - - @info_channel %{ - name: "info", - type: 0 - } - - @error_channel %{ - name: "error", - type: 0 - } - - defstruct category: @category, - tags: @tags, - occurrences_channel: @occurrences_channel, - info_channel: @info_channel, - error_channel: @error_channel, - token: nil, - guild_id: nil, - category_id: nil, - occurrences_channel_id: nil, - occurrences_channel_tags: %{}, - info_channel_id: nil, - error_channel_id: nil, - enable_discord_log: nil - - @spec new(DiscoLog.Config.config()) :: %__MODULE__{} - def new(config) do - struct(__MODULE__, config) - end -end diff --git a/lib/disco_log/discord/context.ex b/lib/disco_log/discord/context.ex deleted file mode 100644 index 4e49b4e..0000000 --- a/lib/disco_log/discord/context.ex +++ /dev/null @@ -1,232 +0,0 @@ -defmodule DiscoLog.Discord.Context do - @moduledoc """ - Context around Discord. - """ - - alias DiscoLog.Discord - alias DiscoLog.Encoder - - def fetch_or_create_channel(config, channels, channel_config, parent_id) do - case Enum.find(channels, &find_channel(&1, channel_config)) do - channel when is_map(channel) -> - {:ok, channel} - - nil -> - params = maybe_add_parent_id(channel_config, parent_id) - - Discord.Client.create_channel(config, params) - end - end - - def maybe_delete_channel(config, channels, channel_config) do - case Enum.find(channels, &find_channel(&1, channel_config)) do - channel when is_map(channel) -> - Discord.Client.delete_channel(config, channel["id"]) - - nil -> - :ok - end - end - - defp find_channel(channel, config) do - channel["type"] == config[:type] and channel["name"] == config[:name] - end - - defp maybe_add_parent_id(params, nil), do: params - - defp maybe_add_parent_id(params, parent_id) do - Map.put(params, :parent_id, parent_id) - end - - def create_occurrence_thread(config, error) do - fields = prepare_occurrence_thread_fields(config, error) - - Discord.Client.create_form_forum_thread(config, fields) - end - - def create_occurrence_message(config, thread_id, error) do - fields = prepare_occurrence_message_fields(error) - - Discord.Client.create_form_message(config, thread_id, fields) - end - - def list_occurrence_threads(config, occurrences_channel_id) do - {:ok, response} = - Discord.Client.list_active_threads(config) - - response["threads"] - |> Enum.filter(&(&1["parent_id"] == occurrences_channel_id)) - |> Enum.map(&{extract_fingerprint(&1["name"]), &1["id"]}) - end - - def get_gateway(config) do - case Discord.Client.get_gateway(config) do - {:ok, %{"url" => gateway_url}} -> {:ok, gateway_url} - {:ok, _} -> {:error, "Unexpected response"} - other -> other - end - end - - def list_tags(config, occurrences_channel_id) do - {:ok, response} = - Discord.Client.get_channel(config, occurrences_channel_id) - - for %{"id" => id, "name" => name} <- response["available_tags"], into: %{}, do: {name, id} - end - - defp prepare_occurrence_thread_fields(config, error) do - [ - payload_json: - Encoder.encode!( - maybe_put_tag( - config, - %{ - name: thread_name(error), - message: prepare_error_message(error) - }, - error.context - ) - ) - ] - |> put_stacktrace(error.stacktrace) - |> maybe_put_context(error.context) - end - - defp prepare_occurrence_message_fields(error) do - [ - payload_json: Encoder.encode!(prepare_error_message(error)) - ] - |> put_stacktrace(error.stacktrace) - |> maybe_put_context(error.context) - end - - defp prepare_error_message(error) do - %{ - content: """ - **At:** - **Kind:** `#{error.kind}` - **Reason:** `#{error.reason}` - **Source Line:** #{source_line(error)} - **Source Function:** `#{error.source_function}` - **Fingerprint:** `#{error.fingerprint}` - """ - } - end - - # source line can be a link to the source code if source_url is set - defp source_line(error) do - if error.source_url do - # we wrap the url with `<>` to prevent an embed preview to be created - "[`#{error.source_line}`](<#{error.source_url}>)" - else - "`#{error.source_line}`" - end - end - - # Note can we do something nicer using discord embeds ? - # defp prepare_error_message(error) do - # %{ - # Maybe there is something nice to do with embeds fields - # https://discordjs.guide/popular-topics/embeds.html#embed-preview - # embeds: [ - # %{ - # fields: [ - # %{name: "at", value: ""}, - # %{name: "kind", value: backtick_wrap(error.kind)}, - # %{name: "reason", value: backtick_wrap(error.reason)}, - # %{name: "source_line", value: backtick_wrap(error.source_line)}, - # %{name: "source_function", value: backtick_wrap(error.source_function)}, - # %{name: "fingerprint", value: backtick_wrap(error.fingerprint)} - # ] - # } - # ] - # end - # defp backtick_wrap(string), do: "`#{string}`" - - defp maybe_put_tag(config, message, context) do - context - |> Map.keys() - |> Enum.filter(&(&1 in config.tags)) - |> Enum.map(&Map.fetch!(config.occurrences_channel_tags, &1)) - |> case do - [] -> message - tags -> Map.put(message, :applied_tags, tags) - end - end - - # Thread name are limited by 100 characters we use the 16 first characters for the fingerprint - defp thread_name(error), do: "#{error.fingerprint} #{error.kind}" - - defp extract_fingerprint(<> <> " " <> _rest), - do: fingerprint - - defp put_stacktrace(fields, stacktrace) do - Keyword.put(fields, :stacktrace, {to_string(stacktrace), filename: "stacktrace.ex"}) - end - - defp maybe_put_context(fields, context) when map_size(context) == 0, do: fields - - defp maybe_put_context(fields, context) do - Keyword.put( - fields, - :context, - {Encoder.encode!(context, pretty: true), filename: "context.json"} - ) - end - - def create_message(config, channel_id, message, metadata) when is_binary(message) do - fields = - [ - payload_json: - Encoder.encode!(%{ - content: message - }) - ] - |> maybe_put_metadata(metadata) - - Discord.Client.create_form_message(config, channel_id, fields) - end - - def create_message(config, channel_id, message, metadata) when is_map(message) do - fields = - [ - message: {Encoder.encode!(message, pretty: true), filename: "message.json"} - ] - |> maybe_put_metadata(metadata) - - Discord.Client.create_form_message(config, channel_id, fields) - end - - defp maybe_put_metadata(fields, metadata) when map_size(metadata) == 0, do: fields - - defp maybe_put_metadata(fields, metadata) do - Keyword.put( - fields, - :metadata, - {serialize_metadata(metadata), filename: "metadata.ex"} - ) - end - - def delete_threads(config, channel_id) do - {:ok, response} = Discord.Client.list_active_threads(config) - - response["threads"] - |> Enum.filter(&(&1["parent_id"] == channel_id)) - |> Enum.map(&Discord.Client.delete_thread(config, &1["id"])) - end - - def delete_channel_messages(config, channel_id) do - {:ok, response} = Discord.Client.list_messages(config, channel_id) - - response - |> Enum.map(& &1["id"]) - |> Enum.map(&Discord.Client.delete_message(config, channel_id, &1)) - end - - defp serialize_metadata(metadata) do - metadata - |> inspect(pretty: true, limit: :infinity, printable_limit: :infinity) - # 8MB is the max file attachment limit - |> String.byte_slice(0, 8_000_000) - end -end diff --git a/lib/disco_log/discord/prepare.ex b/lib/disco_log/discord/prepare.ex new file mode 100644 index 0000000..e6f2d8c --- /dev/null +++ b/lib/disco_log/discord/prepare.ex @@ -0,0 +1,99 @@ +defmodule DiscoLog.Discord.Prepare do + @moduledoc false + alias DiscoLog.Encoder + + def prepare_message(message, metadata) when is_binary(message) do + [ + payload_json: Encoder.encode!(%{content: message}) + ] + |> maybe_put_metadata(metadata) + end + + def prepare_message(message, metadata) when is_map(message) do + [ + message: {Encoder.encode!(message, pretty: true), filename: "message.json"} + ] + |> maybe_put_metadata(metadata) + end + + def prepare_occurrence(error, tags) do + [ + payload_json: + Encoder.encode!(%{ + message: prepare_error_message(error), + name: thread_name(error), + applied_tags: tags + }) + ] + |> put_stacktrace(error.stacktrace) + |> maybe_put_context(error.context) + end + + def prepare_occurrence_message(error) do + [ + payload_json: Encoder.encode!(prepare_error_message(error)) + ] + |> put_stacktrace(error.stacktrace) + |> maybe_put_context(error.context) + end + + def fingerprint_from_thread_name(<> <> " " <> _rest), + do: fingerprint + + # Thread name are limited by 100 characters we use the 16 first characters for the fingerprint + defp thread_name(error), do: "#{error.fingerprint} #{error.kind}" + + defp maybe_put_metadata(fields, metadata) when map_size(metadata) == 0, do: fields + + defp maybe_put_metadata(fields, metadata) do + Keyword.put( + fields, + :metadata, + {serialize_metadata(metadata), filename: "metadata.ex"} + ) + end + + defp serialize_metadata(metadata) do + metadata + |> inspect(pretty: true, limit: :infinity, printable_limit: :infinity) + # 8MB is the max file attachment limit + |> String.byte_slice(0, 8_000_000) + end + + defp prepare_error_message(error) do + %{ + content: """ + **At:** + **Kind:** `#{error.kind}` + **Reason:** `#{error.reason}` + **Source Line:** #{source_line(error)} + **Source Function:** `#{error.source_function}` + **Fingerprint:** `#{error.fingerprint}` + """ + } + end + + # source line can be a link to the source code if source_url is set + defp source_line(error) do + if error.source_url do + # we wrap the url with `<>` to prevent an embed preview to be created + "[`#{error.source_line}`](<#{error.source_url}>)" + else + "`#{error.source_line}`" + end + end + + defp put_stacktrace(fields, stacktrace) do + Keyword.put(fields, :stacktrace, {to_string(stacktrace), filename: "stacktrace.ex"}) + end + + defp maybe_put_context(fields, context) when map_size(context) == 0, do: fields + + defp maybe_put_context(fields, context) do + Keyword.put( + fields, + :context, + {Encoder.encode!(context, pretty: true), filename: "context.json"} + ) + end +end diff --git a/lib/disco_log/logger_handler.ex b/lib/disco_log/logger_handler.ex index 2aad5a2..0e1543f 100644 --- a/lib/disco_log/logger_handler.ex +++ b/lib/disco_log/logger_handler.ex @@ -4,34 +4,14 @@ defmodule DiscoLog.LoggerHandler do Original source: https://github.com/getsentry/sentry-elixir/blob/69ac8d0e3f33ff36ab1092bbd346fdb99cf9d061/lib/sentry/logger_handler.ex """ + @behaviour :logger_handler - alias DiscoLog.Client - alias DiscoLog.Config alias DiscoLog.Context alias DiscoLog.Error ## Logger handler callbacks - @spec adding_handler(:logger.handler_config()) :: {:ok, :logger.handler_config()} - def adding_handler(handler_config) do - with {:ok, config} <- Config.validate(handler_config.config) do - {:ok, Map.put(handler_config, :config, config)} - end - end - - @spec changing_config(:set | :update, :logger.handler_config(), :logger.handler_config()) :: - {:ok, :logger.handler_config()} - def changing_config(:set, _old_config, new_config) do - adding_handler(new_config) - end - - def changing_config(:update, old_config, new_config) do - old_config - |> Map.merge(new_config) - |> Map.put(:config, Map.merge(old_config.config, new_config.config)) - |> adding_handler() - end - + @impl :logger_handler def log(%{level: log_level, meta: log_meta} = log_event, %{ config: config }) do @@ -68,8 +48,7 @@ defmodule DiscoLog.LoggerHandler do ) do metadata = take_metadata(meta, config.metadata) message = IO.iodata_to_binary(unicode_chardata) - Client.log_info(message, metadata, config) - :ok + DiscoLog.log_info(message, metadata, config) end # "report" here is of type logger:report/0, which is a struct, map or keyword list. @@ -79,7 +58,6 @@ defmodule DiscoLog.LoggerHandler do ) do metadata = take_metadata(meta, config.metadata) log_info_report(report, metadata, config) - :ok end # erlang `:logger` support this format ex `:logger.info("Hello ~s", ["world"])` @@ -90,8 +68,7 @@ defmodule DiscoLog.LoggerHandler do ) do metadata = take_metadata(meta, config.metadata) string_message = format |> :io_lib.format(format_args) |> IO.chardata_to_string() - Client.log_info(string_message, metadata, config) - :ok + DiscoLog.log_info(string_message, metadata, config) end defp log_info(_log_event, _config) do @@ -115,7 +92,6 @@ defmodule DiscoLog.LoggerHandler do ) do metadata = take_metadata(meta, config.metadata) log_error_report(report, metadata, config) - :ok end # erlang `:logger` support this format ex `:logger.error("Hello ~s", ["world"])` @@ -126,8 +102,7 @@ defmodule DiscoLog.LoggerHandler do ) do metadata = take_metadata(meta, config.metadata) string_message = format |> :io_lib.format(format_args) |> IO.chardata_to_string() - Client.log_error(string_message, metadata, config) - :ok + DiscoLog.log_error(string_message, metadata, config) end defp log_error(_log_event, _config) do @@ -143,8 +118,7 @@ defmodule DiscoLog.LoggerHandler do when is_exception(exception) and is_list(stacktrace) do context = Map.put(Context.get(), :metadata, metadata) error = Error.new(exception, stacktrace, context, config) - Client.send_error(error, config) - :ok + DiscoLog.send_error(error, config) end defp log_from_crash_reason( @@ -160,31 +134,29 @@ defmodule DiscoLog.LoggerHandler do metadata: metadata }) - case reason do - {type, {GenServer, :call, [_pid, _call, _timeout]}} = reason - when type in [:noproc, :timeout] -> - reason = Exception.format_exit(reason) - context = Map.put(context, :extra_reason, reason) - error = Error.new({"genserver_call", type}, stacktrace, context, config) - Client.send_error(error, config) - - _other -> - case try_to_parse_message(chardata_message) do - nil -> - reason = inspect(reason) - error = Error.new({"genserver", reason}, stacktrace, context, config) - Client.send_error(error, config) - - %{reason: reason} = parsed_message -> - context = - Map.put(context, :extra_info_from_genserver, Map.delete(parsed_message, :reason)) - - error = Error.new({"genserver", reason}, stacktrace, context, config) - Client.send_error(error, config) - end - end + error = + case reason do + {type, {GenServer, :call, [_pid, _call, _timeout]}} = reason + when type in [:noproc, :timeout] -> + reason = Exception.format_exit(reason) + context = Map.put(context, :extra_reason, reason) + Error.new({"genserver_call", type}, stacktrace, context, config) + + _other -> + case try_to_parse_message(chardata_message) do + nil -> + reason = inspect(reason) + Error.new({"genserver", reason}, stacktrace, context, config) + + %{reason: reason} = parsed_message -> + context = + Map.put(context, :extra_info_from_genserver, Map.delete(parsed_message, :reason)) + + Error.new({"genserver", reason}, stacktrace, context, config) + end + end - :ok + DiscoLog.send_error(error, config) end defp log_from_crash_reason( @@ -194,8 +166,7 @@ defmodule DiscoLog.LoggerHandler do config ) do message = IO.iodata_to_binary(chardata_message) - Client.log_error(message, metadata, config) - :ok + DiscoLog.log_error(message, metadata, config) end defp extra_info_from_message([ @@ -357,20 +328,20 @@ defmodule DiscoLog.LoggerHandler do report |> Map.from_struct() |> Map.put(:__struct__, to_string(report.__struct__)) - |> Client.log_info(metadata, config) + |> DiscoLog.log_info(metadata, config) end defp log_info_report(report, metadata, config) do report |> Map.new() - |> Client.log_info(metadata, config) + |> DiscoLog.log_info(metadata, config) end defp log_error_report(report, metadata, config) when is_struct(report) do report |> Map.from_struct() |> Map.put(:__struct__, to_string(report.__struct__)) - |> Client.log_error(metadata, config) + |> DiscoLog.log_error(metadata, config) end defp log_error_report(report, metadata, config) do @@ -378,18 +349,18 @@ defmodule DiscoLog.LoggerHandler do %{reason: {exception, stacktrace}} when is_exception(exception) and is_list(stacktrace) -> context = Map.put(Context.get(), :metadata, metadata) error = Error.new(exception, stacktrace, context, config) - Client.send_error(error, config) + DiscoLog.send_error(error, config) %{reason: {reason, stacktrace}} when is_list(stacktrace) -> context = Map.put(Context.get(), :metadata, metadata) error = Error.new(reason, stacktrace, context, config) - Client.send_error(error, config) + DiscoLog.send_error(error, config) %{reason: reason} -> - Client.log_error(reason, metadata, config) + DiscoLog.log_error(reason, metadata, config) - _other -> - Client.log_error(report, metadata, config) + report -> + DiscoLog.log_error(report, metadata, config) end end end diff --git a/lib/disco_log/presence.ex b/lib/disco_log/presence.ex index 71ce629..7efcc9c 100644 --- a/lib/disco_log/presence.ex +++ b/lib/disco_log/presence.ex @@ -5,10 +5,11 @@ defmodule DiscoLog.Presence do use GenServer alias DiscoLog.WebsocketClient + alias DiscoLog.Discord defstruct [ - :discord, - :discord_config, + :bot_token, + :discord_client, :presence_status, :registry, :websocket_client, @@ -32,8 +33,8 @@ defmodule DiscoLog.Presence do @impl GenServer def init({opts, callers}) do state = %__MODULE__{ - discord_config: Keyword.fetch!(opts, :discord_config), - discord: Keyword.fetch!(opts, :discord), + bot_token: Keyword.fetch!(opts, :bot_token), + discord_client: Keyword.fetch!(opts, :discord_client), presence_status: Keyword.fetch!(opts, :presence_status), jitter: Keyword.get_lazy(opts, :jitter, fn -> :rand.uniform() end) } @@ -47,9 +48,11 @@ defmodule DiscoLog.Presence do # Connect to Gateway # https://discord.com/developers/docs/events/gateway#connecting @impl GenServer - def handle_continue(:connect, %__MODULE__{discord: discord, discord_config: config} = state) do - {:ok, raw_uri} = discord.get_gateway(config) - {:ok, uri} = URI.new(raw_uri) + def handle_continue( + :connect, + %__MODULE__{discord_client: discord_client} = state + ) do + {:ok, uri} = Discord.get_gateway(discord_client) {:ok, client} = WebsocketClient.connect(uri.host, uri.port, "/?v=10&encoding=json") {:noreply, %{state | websocket_client: client}} end @@ -72,7 +75,7 @@ defmodule DiscoLog.Presence do def handle_continue( :identify, %__MODULE__{ - discord_config: config, + bot_token: bot_token, websocket_client: client, presence_status: presence_status } = state @@ -80,7 +83,7 @@ defmodule DiscoLog.Presence do identify_event = %{ op: 2, d: %{ - token: config.token, + token: bot_token, intents: 0, presence: %{ activities: [%{type: 4, state: presence_status, name: "Name"}], diff --git a/lib/disco_log/storage.ex b/lib/disco_log/storage.ex index 1d8fb89..efb4707 100644 --- a/lib/disco_log/storage.ex +++ b/lib/disco_log/storage.ex @@ -4,7 +4,9 @@ defmodule DiscoLog.Storage do """ use GenServer - defstruct [:registry, :discord_config, :discord] + alias DiscoLog.Discord + + defstruct [:registry, :discord_client, :guild_id, :occurrences_channel_id] ## Public API @@ -56,8 +58,9 @@ defmodule DiscoLog.Storage do def init({opts, callers}) do state = %__MODULE__{ registry: DiscoLog.Registry.registry_name(opts[:supervisor_name]), - discord_config: Keyword.fetch!(opts, :discord_config), - discord: Keyword.fetch!(opts, :discord) + discord_client: Keyword.fetch!(opts, :discord_client), + guild_id: Keyword.fetch!(opts, :guild_id), + occurrences_channel_id: Keyword.fetch!(opts, :occurrences_channel_id) } Process.put(:"$callers", callers) @@ -68,14 +71,18 @@ defmodule DiscoLog.Storage do @impl GenServer def handle_continue( :restore, - %__MODULE__{discord_config: config, discord: discord, registry: registry} = state + %__MODULE__{ + discord_client: discord_client, + guild_id: guild_id, + occurrences_channel_id: occurrences_channel_id, + registry: registry + } = + state ) do - existing_threads = - config - |> discord.list_occurrence_threads(config.occurrences_channel_id) - |> Map.new() + {:ok, existing_threads} = + Discord.list_occurrence_threads(discord_client, guild_id, occurrences_channel_id) - existing_tags = discord.list_tags(config, config.occurrences_channel_id) + {:ok, existing_tags} = Discord.list_occurrence_tags(discord_client, occurrences_channel_id) Registry.register(registry, {__MODULE__, :threads}, existing_threads) Registry.register(registry, {__MODULE__, :tags}, existing_tags) diff --git a/lib/disco_log/supervisor.ex b/lib/disco_log/supervisor.ex index 0b7a55d..0db1133 100644 --- a/lib/disco_log/supervisor.ex +++ b/lib/disco_log/supervisor.ex @@ -33,8 +33,9 @@ defmodule DiscoLog.Supervisor do {Registry, keys: :unique, name: DiscoLog.Registry.registry_name(config.supervisor_name)}, {Storage, supervisor_name: config.supervisor_name, - discord_config: config.discord_config, - discord: config.discord}, + discord_client: config.discord_client, + guild_id: config.guild_id, + occurrences_channel_id: config.occurrences_channel_id}, {Dedupe, supervisor_name: config.supervisor_name} ] ++ maybe_presence(config) @@ -48,8 +49,8 @@ defmodule DiscoLog.Supervisor do [ {Presence, supervisor_name: config.supervisor_name, - discord_config: config.discord_config, - discord: config.discord, + bot_token: config.token, + discord_client: config.discord_client, presence_status: config.presence_status} ] else diff --git a/lib/disco_log/websocket_client.ex b/lib/disco_log/websocket_client.ex index fd7b89b..fc24b59 100644 --- a/lib/disco_log/websocket_client.ex +++ b/lib/disco_log/websocket_client.ex @@ -13,7 +13,7 @@ defmodule DiscoLog.WebsocketClient do } @callback connect(host :: Mint.Types.address(), port :: :inet.port_number(), path :: String.t()) :: - {:ok, t()} | {:error, Mint.Websocket.error()} + {:ok, t()} | {:error, Mint.WebSocket.error()} defdelegate connect(host, port, path), to: @adapter @callback boil_message_to_frame(client :: t(), message :: any()) :: diff --git a/lib/mix/tasks/disco_log.cleanup.ex b/lib/mix/tasks/disco_log.cleanup.ex index 49b455c..f4317c4 100644 --- a/lib/mix/tasks/disco_log.cleanup.ex +++ b/lib/mix/tasks/disco_log.cleanup.ex @@ -4,24 +4,22 @@ defmodule Mix.Tasks.DiscoLog.Cleanup do """ use Mix.Task - alias DiscoLog.Discord alias DiscoLog.Config + alias DiscoLog.Discord @impl Mix.Task def run(_args) do # Ensure req is started {:ok, _} = Application.ensure_all_started(:req) - config = Config.read!().discord_config + config = Config.read!() # Delete all threads from occurrences channel - Discord.delete_threads(config, config.occurrences_channel_id) + Discord.delete_threads(config.discord_client, config.guild_id, config.occurrences_channel_id) # Delete all messages from info and error channels - [ - config.info_channel_id, - config.error_channel_id - ] - |> Enum.each(&Discord.delete_channel_messages(config, &1)) + for channel_id <- [config.info_channel_id, config.error_channel_id] do + Discord.delete_channel_messages(config.discord_client, channel_id) + end Mix.shell().info("Messages from DiscoLog Discord channels were deleted successfully!") end diff --git a/lib/mix/tasks/disco_log.create.ex b/lib/mix/tasks/disco_log.create.ex index dfb3fc0..70bf4c9 100644 --- a/lib/mix/tasks/disco_log.create.ex +++ b/lib/mix/tasks/disco_log.create.ex @@ -4,37 +4,55 @@ defmodule Mix.Tasks.DiscoLog.Create do """ use Mix.Task - alias DiscoLog.Discord alias DiscoLog.Config + alias DiscoLog.Discord.API + + @default_tags Enum.map(~w(plug live_view oban tesla), &%{name: &1}) @impl Mix.Task def run(_args) do # Ensure req is started {:ok, _} = Application.ensure_all_started(:req) - config = Config.read!().discord_config + config = Config.read!() - with {:ok, channels} <- Discord.list_channels(config), - {:ok, category} <- Discord.fetch_or_create_channel(config, channels, config.category), - {:ok, occurrence} <- - Discord.fetch_or_create_channel( - config, + with {:ok, %{status: 200, body: channels}} <- + API.list_channels(config.discord_client, config.guild_id), + {:ok, %{body: %{"id" => category_id}}} <- + fetch_or_create_channel( + config.discord_client, + channels, + config.guild_id, + 4, + "disco-log", + nil + ), + {:ok, %{body: occurrence}} <- + fetch_or_create_channel( + config.discord_client, channels, - config.occurrences_channel, - category["id"] + config.guild_id, + 15, + "occurrences", + category_id, + %{available_tags: @default_tags} ), - {:ok, info} <- - Discord.fetch_or_create_channel( - config, + {:ok, %{body: info}} <- + fetch_or_create_channel( + config.discord_client, channels, - config.info_channel, - category["id"] + config.guild_id, + 0, + "info", + category_id ), - {:ok, error} <- - Discord.fetch_or_create_channel( - config, + {:ok, %{body: error}} <- + fetch_or_create_channel( + config.discord_client, channels, - config.error_channel, - category["id"] + config.guild_id, + 0, + "error", + category_id ) do Mix.shell().info("Discord channels for DiscoLog were created successfully!") Mix.shell().info("Complete the configuration by adding the following to your config") @@ -44,11 +62,35 @@ defmodule Mix.Tasks.DiscoLog.Create do otp_app: :app_name, token: "#{config.token}", guild_id: "#{config.guild_id}", - category_id: "#{category["id"]}", + category_id: "#{category_id}", occurrences_channel_id: "#{occurrence["id"]}", info_channel_id: "#{info["id"]}", error_channel_id: "#{error["id"]}" """) end end + + defp fetch_or_create_channel( + discord_client, + channels, + guild_id, + type, + name, + parent_id, + extra \\ %{} + ) do + channels + |> Enum.find(&match?(%{"type" => ^type, "name" => ^name, "parent_id" => ^parent_id}, &1)) + |> case do + nil -> + API.create_channel( + discord_client, + guild_id, + Map.merge(%{parent_id: parent_id, name: name, type: type}, extra) + ) + + channel when is_map(channel) -> + {:ok, %{body: channel}} + end + end end diff --git a/lib/mix/tasks/disco_log.drop.ex b/lib/mix/tasks/disco_log.drop.ex index efa2f92..06fdd30 100644 --- a/lib/mix/tasks/disco_log.drop.ex +++ b/lib/mix/tasks/disco_log.drop.ex @@ -4,25 +4,24 @@ defmodule Mix.Tasks.DiscoLog.Drop do """ use Mix.Task - alias DiscoLog.Discord alias DiscoLog.Config + alias DiscoLog.Discord.API @impl Mix.Task def run(_args) do # Ensure req is started {:ok, _} = Application.ensure_all_started(:req) - config = Config.read!().discord_config + config = Config.read!() - {:ok, channels} = Discord.list_channels(config.config) - - [ - config.category, - config.occurrences_channel, - config.info_channel, - config.error_channel - ] - |> Enum.each(&Discord.maybe_delete_channel(config, channels, &1)) + for channel_id <- [ + config.category_id, + config.occurrences_channel_id, + config.info_channel_id, + config.error_channel_id + ] do + API.delete_channel(config.discord_client, channel_id) + end Mix.shell().info("Discord channels for DiscoLog were deleted successfully!") end diff --git a/test/disco_log/application_test.exs b/test/disco_log/application_test.exs index 2801028..94fa08f 100644 --- a/test/disco_log/application_test.exs +++ b/test/disco_log/application_test.exs @@ -8,12 +8,8 @@ defmodule DiscoLog.ApplicationTest do setup_all :set_mox_global setup_all do - DiscoLog.DiscordMock - |> Mox.stub(:list_occurrence_threads, fn _, _ -> [] end) - |> Mox.stub(:list_tags, fn _, _ -> %{} end) - |> Mox.stub(:get_gateway, fn _ -> {:ok, "wss://example.com"} end) - - Mox.stub(DiscoLog.WebsocketClient.Mock, :connect, fn _, _, _ -> {:ok, %WebsocketClient{}} end) + stub_with(DiscoLog.Discord.API.Mock, DiscoLog.Discord.API.Stub) + stub(DiscoLog.WebsocketClient.Mock, :connect, fn _, _, _ -> {:ok, %WebsocketClient{}} end) Application.put_env(:disco_log, :enable, true) Application.stop(:disco_log) diff --git a/test/disco_log/config_test.exs b/test/disco_log/config_test.exs index 3920fe4..9cb2eab 100644 --- a/test/disco_log/config_test.exs +++ b/test/disco_log/config_test.exs @@ -2,7 +2,6 @@ defmodule DiscoLog.ConfigTest do use ExUnit.Case, async: true alias DiscoLog.Config - alias DiscoLog.Discord @example_config [ otp_app: :foo, @@ -18,8 +17,7 @@ defmodule DiscoLog.ConfigTest do instrument_phoenix: true, instrument_tesla: true, metadata: [:foo], - excluded_domains: [:cowboy], - before_send: {Foo, [1, 2, 3]} + excluded_domains: [:cowboy] ] describe inspect(&Config.validate/1) do @@ -27,8 +25,8 @@ defmodule DiscoLog.ConfigTest do assert %{} = Config.validate!(@example_config) end - test "adds Discord.Config" do - assert %{discord_config: %Discord.Config{}} = Config.validate!(@example_config) + test "adds discord_client" do + assert %{discord_client: %DiscoLog.Discord.API{}} = Config.validate!(@example_config) end end end diff --git a/test/disco_log/discord/prepare_test.exs b/test/disco_log/discord/prepare_test.exs new file mode 100644 index 0000000..14ece49 --- /dev/null +++ b/test/disco_log/discord/prepare_test.exs @@ -0,0 +1,226 @@ +defmodule DiscoLog.Discord.PrepareTest do + use DiscoLog.Test.Case, async: true + + alias DiscoLog.Discord.Prepare + + describe inspect(&Prepare.prepare_message/2) do + test "string message goes to content" do + assert [payload_json: "{\"content\":\"Hello World\"}"] = + Prepare.prepare_message("Hello World", %{}) + end + + test "map message is attached as a json file" do + assert [message: {"{\n \"foo\": \"bar\"\n}", [filename: "message.json"]}] = + Prepare.prepare_message(%{foo: "bar"}, %{}) + end + + test "metadata is attached" do + assert [ + metadata: {"%{foo: \"bar\"}", [filename: "metadata.ex"]}, + payload_json: "{\"content\":\"Hello\"}" + ] = Prepare.prepare_message("Hello", %{foo: "bar"}) + end + end + + describe inspect(&Prepare.prepare_occurrence/2) do + test "creates new thread with tags" do + error = %DiscoLog.Error{ + kind: "Elixir.RuntimeError", + reason: "foo", + source_line: "iex:7", + source_function: "elixir_eval.__FILE__/1", + context: %{}, + stacktrace: %DiscoLog.Stacktrace{ + lines: [ + %DiscoLog.Stacktrace.Line{ + application: "", + module: "elixir_eval", + top_module: "elixir_eval", + function: "__FILE__", + arity: 1, + file: "iex", + line: 7 + }, + %DiscoLog.Stacktrace.Line{ + application: "elixir", + module: "elixir", + top_module: "elixir", + function: "eval_external_handler", + arity: 3, + file: "src/elixir.erl", + line: 386 + }, + %DiscoLog.Stacktrace.Line{ + application: "stdlib", + module: "erl_eval", + top_module: "erl_eval", + function: "do_apply", + arity: 7, + file: "erl_eval.erl", + line: 919 + }, + %DiscoLog.Stacktrace.Line{ + application: "stdlib", + module: "erl_eval", + top_module: "erl_eval", + function: "try_clauses", + arity: 10, + file: "erl_eval.erl", + line: 1233 + }, + %DiscoLog.Stacktrace.Line{ + application: "elixir", + module: "elixir", + top_module: "elixir", + function: "eval_forms", + arity: 4, + file: "src/elixir.erl", + line: 364 + }, + %DiscoLog.Stacktrace.Line{ + application: "elixir", + module: "Module.ParallelChecker", + top_module: "Module", + function: "verify", + arity: 1, + file: "lib/module/parallel_checker.ex", + line: 120 + }, + %DiscoLog.Stacktrace.Line{ + application: "iex", + module: "IEx.Evaluator", + top_module: "IEx", + function: "eval_and_inspect", + arity: 3, + file: "lib/iex/evaluator.ex", + line: 336 + }, + %DiscoLog.Stacktrace.Line{ + application: "iex", + module: "IEx.Evaluator", + top_module: "IEx", + function: "eval_and_inspect_parsed", + arity: 3, + file: "lib/iex/evaluator.ex", + line: 310 + } + ] + }, + fingerprint: "DDF3A140618A73A3", + source_url: nil + } + + assert [ + stacktrace: {"elixir_eval.__FILE__/1 in" <> _, [filename: "stacktrace.ex"]}, + payload_json: payload + ] = Prepare.prepare_occurrence(error, ["tag_id_1"]) + + assert %{ + "applied_tags" => ["tag_id_1"], + "message" => %{ + "content" => " **At:** " <> _ + }, + "name" => <<_::binary-size(16)>> <> " Elixir.RuntimeError" + } = Jason.decode!(payload) + end + end + + describe inspect(&Prepare.prepare_occurence_message/1) do + test "creates new message to be put in thread" do + error = %DiscoLog.Error{ + kind: "Elixir.RuntimeError", + reason: "foo", + source_line: "iex:7", + source_function: "elixir_eval.__FILE__/1", + context: %{}, + stacktrace: %DiscoLog.Stacktrace{ + lines: [ + %DiscoLog.Stacktrace.Line{ + application: "", + module: "elixir_eval", + top_module: "elixir_eval", + function: "__FILE__", + arity: 1, + file: "iex", + line: 7 + }, + %DiscoLog.Stacktrace.Line{ + application: "elixir", + module: "elixir", + top_module: "elixir", + function: "eval_external_handler", + arity: 3, + file: "src/elixir.erl", + line: 386 + }, + %DiscoLog.Stacktrace.Line{ + application: "stdlib", + module: "erl_eval", + top_module: "erl_eval", + function: "do_apply", + arity: 7, + file: "erl_eval.erl", + line: 919 + }, + %DiscoLog.Stacktrace.Line{ + application: "stdlib", + module: "erl_eval", + top_module: "erl_eval", + function: "try_clauses", + arity: 10, + file: "erl_eval.erl", + line: 1233 + }, + %DiscoLog.Stacktrace.Line{ + application: "elixir", + module: "elixir", + top_module: "elixir", + function: "eval_forms", + arity: 4, + file: "src/elixir.erl", + line: 364 + }, + %DiscoLog.Stacktrace.Line{ + application: "elixir", + module: "Module.ParallelChecker", + top_module: "Module", + function: "verify", + arity: 1, + file: "lib/module/parallel_checker.ex", + line: 120 + }, + %DiscoLog.Stacktrace.Line{ + application: "iex", + module: "IEx.Evaluator", + top_module: "IEx", + function: "eval_and_inspect", + arity: 3, + file: "lib/iex/evaluator.ex", + line: 336 + }, + %DiscoLog.Stacktrace.Line{ + application: "iex", + module: "IEx.Evaluator", + top_module: "IEx", + function: "eval_and_inspect_parsed", + arity: 3, + file: "lib/iex/evaluator.ex", + line: 310 + } + ] + }, + fingerprint: "DDF3A140618A73A3", + source_url: nil + } + + assert [ + stacktrace: {"elixir_eval.__FILE__/1 in" <> _, [filename: "stacktrace.ex"]}, + payload_json: payload + ] = Prepare.prepare_occurrence_message(error) + + assert %{ + "content" => " **At:** " <> _ + } = Jason.decode!(payload) + end + end +end diff --git a/test/disco_log/integrations/oban_test.exs b/test/disco_log/integrations/oban_test.exs index f2b126e..2463e24 100644 --- a/test/disco_log/integrations/oban_test.exs +++ b/test/disco_log/integrations/oban_test.exs @@ -6,7 +6,7 @@ defmodule DiscoLog.ObanTest do @moduletag config: [supervisor_name: __MODULE__] alias DiscoLog.Integrations - alias DiscoLog.DiscordMock + alias DiscoLog.Discord.API setup :setup_supervisor setup :attach_oban @@ -18,29 +18,37 @@ defmodule DiscoLog.ObanTest do test "send the exception with the oban context" do pid = self() - ref = make_ref() - expect(DiscordMock, :create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) execute_job_exception() - assert_receive {^ref, error} + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, multipart} + ] - assert error.kind == to_string(RuntimeError) - assert error.reason == "Exception!" - oban = error.context["oban"] + assert {context_json, [filename: "context.json"]} = multipart[:context] - assert oban == %{ - "args" => %{foo: "bar"}, + assert %{ + "args" => %{"foo" => "bar"}, "attempt" => 1, "id" => 123, "priority" => 1, - "queue" => :default, - "state" => :failure, - "worker" => :"Test.Worker" - } + "queue" => "default", + "state" => "failure", + "worker" => "Test.Worker" + } = Jason.decode!(context_json)["oban"] + + assert %{ + "message" => %{ + "content" => _ + }, + "name" => <<_::binary-size(16)>> <> " Elixir.RuntimeError" + } = Jason.decode!(multipart[:payload_json]) end defp sample_metadata do diff --git a/test/disco_log/integrations/tesla_test.exs b/test/disco_log/integrations/tesla_test.exs index d8bae73..2baf94b 100644 --- a/test/disco_log/integrations/tesla_test.exs +++ b/test/disco_log/integrations/tesla_test.exs @@ -6,7 +6,7 @@ defmodule DiscoLog.TeslaTest do @moduletag config: [supervisor_name: __MODULE__] alias DiscoLog.Integrations - alias DiscoLog.DiscordMock + alias DiscoLog.Discord.API setup :setup_supervisor setup :attach_tesla @@ -18,19 +18,35 @@ defmodule DiscoLog.TeslaTest do test "send the exception with the tesla context" do pid = self() - ref = make_ref() - expect(DiscordMock, :create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) execute_tesla_exception() - assert_receive {^ref, error} + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, multipart} + ] - assert error.kind == to_string(RuntimeError) - assert error.reason == "Exception!" - assert is_map(error.context["tesla"]) + assert {context_json, [filename: "context.json"]} = multipart[:context] + + assert %{ + "method" => "get", + "request_headers" => [], + "response_headers" => [], + "status" => 500, + "url" => "http://example.com" + } = Jason.decode!(context_json)["tesla"] + + assert %{ + "message" => %{ + "content" => _ + }, + "name" => <<_::binary-size(16)>> <> " Elixir.RuntimeError" + } = Jason.decode!(multipart[:payload_json]) end defp execute_tesla_exception do diff --git a/test/disco_log/logger_handler_test.exs b/test/disco_log/logger_handler_test.exs index 8a1320f..c23164e 100644 --- a/test/disco_log/logger_handler_test.exs +++ b/test/disco_log/logger_handler_test.exs @@ -3,7 +3,7 @@ defmodule DiscoLog.LoggerHandlerTest do import Mox require Logger - alias DiscoLog.DiscordMock + alias DiscoLog.Discord.API @moduletag config: [supervisor_name: __MODULE__] @@ -21,61 +21,77 @@ defmodule DiscoLog.LoggerHandlerTest do test "info log string type" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.info("Info message") - assert_receive {"info_channel_id", "Info message", %{}} + assert_receive [{:path_params, [channel_id: "info_channel_id"]}, {:form_multipart, body}] + assert %{payload_json: %{content: "Info message"}} = decode_body(body) end test "info log report type map" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.info(%{message: "Info message"}) - assert_receive {"info_channel_id", %{message: "Info message"}, %{}} + assert_receive [{:path_params, [channel_id: "info_channel_id"]}, {:form_multipart, body}] + + assert %{message: {%{message: "Info message"}, [filename: "message.json"]}} = + decode_body(body) end test "info log report type keyword" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.info(message: "Info message") - assert_receive {"info_channel_id", %{message: "Info message"}, %{}} + assert_receive [{:path_params, [channel_id: "info_channel_id"]}, {:form_multipart, body}] + + assert %{message: {%{message: "Info message"}, [filename: "message.json"]}} = + decode_body(body) end test "info log report type struct" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.info(%Foo{}) - assert_receive {"info_channel_id", %{__struct__: "Elixir.Foo", bar: nil}, %{}} + assert_receive [{:path_params, [channel_id: "info_channel_id"]}, {:form_multipart, body}] + + assert %{message: {%{__struct__: "Elixir.Foo", bar: "nil"}, [filename: "message.json"]}} = + decode_body(body) end test "info log erlang format" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) :logger.info("Hello ~s", ["world"]) - assert_receive {"info_channel_id", "Hello world", %{}} + assert_receive [{:path_params, [channel_id: "info_channel_id"]}, {:form_multipart, body}] + assert %{payload_json: %{content: "Hello world"}} = decode_body(body) end end @@ -83,115 +99,157 @@ defmodule DiscoLog.LoggerHandlerTest do test "error log string type" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.error("Error message") - assert_receive {"error_channel_id", "Error message", %{}} + assert_receive [{:path_params, [channel_id: "error_channel_id"]}, {:form_multipart, body}] + assert %{payload_json: %{content: "Error message"}} = decode_body(body) end test "error log report type struct" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.error(%Foo{}) - assert_receive {"error_channel_id", %{__struct__: "Elixir.Foo", bar: nil}, %{}} + assert_receive [{:path_params, [channel_id: "error_channel_id"]}, {:form_multipart, body}] + + assert %{message: {%{__struct__: "Elixir.Foo", bar: "nil"}, [filename: "message.json"]}} = + decode_body(body) end test "error log report type map" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.error(%{message: "Error message"}) - assert_receive {"error_channel_id", %{message: "Error message"}, %{}} + assert_receive [{:path_params, [channel_id: "error_channel_id"]}, {:form_multipart, body}] + + assert %{message: {%{message: "Error message"}, [filename: "message.json"]}} = + decode_body(body) end test "error log report type keyword" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.error(message: "Error message") - assert_receive {"error_channel_id", [message: "Error message"], %{}} + assert_receive [{:path_params, [channel_id: "error_channel_id"]}, {:form_multipart, body}] + + assert %{message: {%{message: "Error message"}, [filename: "message.json"]}} = + decode_body(body) end test "error log erlang format" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) :logger.error("Hello ~s", ["world"]) - assert_receive {"error_channel_id", "Hello world", %{}} + assert_receive [{:path_params, [channel_id: "error_channel_id"]}, {:form_multipart, body}] + assert %{payload_json: %{content: "Hello world"}} = decode_body(body) end test "error log IO data" do pid = self() - expect(DiscordMock, :create_message, fn _config, channel_id, message, metadata -> - send(pid, {channel_id, message, metadata}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Logger.error(["Hello", " ", "world"]) - assert_receive {"error_channel_id", "Hello world", %{}} + assert_receive [{:path_params, [channel_id: "error_channel_id"]}, {:form_multipart, body}] + assert %{payload_json: %{content: "Hello world"}} = decode_body(body) end test "a logged raised exception is" do pid = self() - ref = make_ref() - expect(DiscordMock, :create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Task.start(fn -> raise "Unique Error" end) - assert_receive {^ref, error} - assert error.kind == to_string(RuntimeError) - assert error.reason == "Unique Error" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "Unique Error" + assert thread_name =~ "Elixir.RuntimeError" end test "badarith error" do pid = self() - ref = make_ref() - expect(DiscordMock, :create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Task.start(fn -> 1 + to_string(1) end) - assert_receive {^ref, error} - assert error.kind == to_string(ArithmeticError) - assert error.reason == "bad argument in arithmetic expression" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "bad argument in arithmetic expression" + assert thread_name =~ "Elixir.ArithmeticError" end test "undefined function errors" do pid = self() - ref = make_ref() - expect(DiscordMock, :create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) # This function does not exist and will raise when called @@ -201,26 +259,50 @@ defmodule DiscoLog.LoggerHandlerTest do apply(m, f, a) end) - assert_receive {^ref, error} - assert error.kind == to_string(UndefinedFunctionError) - assert error.reason =~ "is undefined or private" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "function DiscoLog.invalid_fun/0 is undefined or private" + assert thread_name =~ "Elixir.UndefinedFunctionError" end test "throws" do pid = self() - ref = make_ref() - expect(DiscordMock, :create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Task.start(fn -> throw("This is a test") end) - assert_receive {^ref, error} - assert error.kind == "genserver" - assert error.reason =~ ":nocatch" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "{:nocatch, \"This is a test\"}" + assert thread_name =~ "genserver" end end @@ -232,74 +314,108 @@ defmodule DiscoLog.LoggerHandlerTest do test "a GenServer raising an error is reported", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) run_and_catch_exit(test_genserver, fn -> Keyword.fetch!([], :foo) end) - assert_receive {^ref, error} - assert error.kind == to_string(KeyError) - assert error.reason == "key :foo not found in: []" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "key :foo not found in: []" + assert thread_name =~ "Elixir.KeyError" end test "a GenServer throw is reported", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) run_and_catch_exit(test_genserver, fn -> throw(:testing_throw) end) - assert_receive {^ref, error} - assert error.kind == "genserver" - assert error.reason =~ "testing_throw" - assert error.source_line == "nofile" - assert error.source_function == "nofunction" - assert error.context.extra_info_from_message.last_message =~ "GenServer throw is reported" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "** (stop) bad return value: :testing_throw" + assert message =~ "nofile" + assert message =~ "nofunction" + assert thread_name =~ "genserver" end test "abnormal GenServer exit is reported", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) run_and_catch_exit(test_genserver, fn -> {:stop, :bad_exit, :no_state} end) - assert_receive {^ref, error} - assert error.kind == "genserver" - assert error.reason =~ "bad_exit" - assert error.source_line == "nofile" - assert error.source_function == "nofunction" - assert error.context.extra_info_from_message.last_message =~ "GenServer exit is reported" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "** (stop) :bad_exit" + assert message =~ "nofile" + assert message =~ "nofunction" + assert thread_name =~ "genserver" end test "an exit while calling another GenServer is reported nicely", %{test_genserver: test_genserver} do - test_pid = self() - ref = make_ref() + pid = self() - DiscordMock - |> allow(test_pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(test_pid, {ref, error}) + API.Mock + |> allow(pid, test_genserver) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) # Get a PID and make sure it's done before using it. @@ -310,23 +426,37 @@ defmodule DiscoLog.LoggerHandlerTest do GenServer.call(pid, :ping) end) - assert_receive {^ref, error} - assert error.kind == "genserver_call" - assert error.reason == "noproc" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] - assert error.context.extra_reason =~ + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + }, + context: {%{extra_reason: extra_reason}, _} + } = decode_body(body) + + assert message =~ "genserver_call" + assert message =~ "noproc" + assert thread_name =~ "genserver_call" + + assert extra_reason =~ "** (EXIT) no process: the process is not alive or there's no process currently associated with the given name, possibly because its application isn't started" end test "a timeout while calling another GenServer is reported nicely", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) {:ok, agent} = Agent.start_link(fn -> nil end) @@ -335,50 +465,88 @@ defmodule DiscoLog.LoggerHandlerTest do Agent.get(agent, & &1, 0) end) - assert_receive {^ref, error} - assert error.kind == "genserver_call" - assert error.reason == "timeout" - assert error.context.extra_reason =~ "** (EXIT) time out" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + }, + context: {%{extra_reason: extra_reason}, _} + } = decode_body(body) + + assert message =~ "genserver_call" + assert message =~ "timeout" + assert thread_name =~ "genserver_call" + assert extra_reason =~ "** (EXIT) time out" end test "bad function call causing GenServer crash is reported", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) run_and_catch_exit(test_genserver, fn -> raise "Hello World" end) - assert_receive {^ref, error} - assert error.kind == to_string(RuntimeError) - assert error.reason == "Hello World" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "Hello World" + assert thread_name =~ "Elixir.RuntimeError" end test "an exit with a struct is reported nicely", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) run_and_catch_exit(test_genserver, fn -> {:stop, %Mint.HTTP1{}, :no_state} end) - assert_receive {^ref, error} - assert error.kind == "genserver" - assert error.reason =~ "** (stop) %Mint.HTTP1" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + } + } = decode_body(body) + + assert message =~ "** (stop) %Mint.HTTP1{" + assert thread_name =~ "genserver" end @tag config: [enable_presence: true] @@ -393,12 +561,17 @@ defmodule DiscoLog.LoggerHandlerTest do |> expect(:boil_message_to_frame, fn _client, {:ssl, :fake_ssl_closed} -> {:error, nil, %Mint.TransportError{reason: :closed}} end) - |> stub(:send_frame, fn _, _ -> {:error, :socket_closed_at_this_point} end) - DiscordMock + API.Mock |> allow(pid, test_genserver) - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + |> expect(:request, 2, fn + client, method, "/channels/:channel_id/threads" = url, opts -> + send(pid, {ref, opts}) + API.Stub.request(client, method, url, opts) + + # Presence will crash and restart, so we need to have gateway stubbed + client, method, "/gateway/bot" = url, opts -> + API.Stub.request(client, method, url, opts) end) pid = @@ -406,23 +579,36 @@ defmodule DiscoLog.LoggerHandlerTest do send(pid, {:ssl, :fake_ssl_closed}) - assert_receive {^ref, error} - assert error.kind == "genserver" - - assert error.reason =~ - "** (stop) {:error, nil, %Mint.TransportError{reason: :closed}}" - - assert error.context.extra_info_from_genserver.message =~ - "GenServer %{} terminating: ** (stop)" + assert_receive {^ref, + [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ]} + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + }, + context: {%{extra_info_from_genserver: %{message: extra_message}}, _} + } = decode_body(body) + + assert message =~ "** (stop) {:error, nil, %Mint.TransportError{reason: :closed}}" + assert thread_name =~ "genserver" + + assert extra_message =~ + "GenServer %{} terminating: ** (stop) {:error, nil, %Mint.TransportError{reason: :closed}}" end test "GenServer timeout is reported", %{test_genserver: test_genserver} do pid = self() - ref = make_ref() - DiscordMock - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + API.Mock + |> allow(pid, test_genserver) + |> expect(:request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) Task.start(fn -> @@ -433,15 +619,41 @@ defmodule DiscoLog.LoggerHandlerTest do ) end) - assert_receive {^ref, error} - assert error.kind == "genserver_call" - assert error.reason == "timeout" - assert error.context.extra_reason =~ "exited in: GenServer.call(" - assert error.context.extra_reason =~ "** (EXIT) time out" + assert_receive [ + {:path_params, [channel_id: "occurrences_channel_id"]}, + {:form_multipart, body} + ] + + assert %{ + payload_json: %{ + applied_tags: [], + message: %{content: message}, + name: thread_name + }, + context: {%{extra_reason: extra_reason}, _} + } = decode_body(body) + + assert message =~ "timeout" + assert thread_name =~ "genserver_call" + assert extra_reason =~ "exited in: GenServer.call(" end end defp run_and_catch_exit(test_genserver_pid, fun) do catch_exit(DiscoLog.TestGenServer.run(test_genserver_pid, fun)) end + + defp decode_body(body) do + Map.new(body, fn + {k, {content, file}} -> {k, {maybe_decode(content), file}} + {k, v} -> {k, maybe_decode(v)} + end) + end + + defp maybe_decode(binary) do + case Jason.decode(binary, keys: :atoms) do + {:ok, decoded} -> decoded + {:error, _} -> binary + end + end end diff --git a/test/disco_log/presence_test.exs b/test/disco_log/presence_test.exs index 98af916..c818d1d 100644 --- a/test/disco_log/presence_test.exs +++ b/test/disco_log/presence_test.exs @@ -5,7 +5,7 @@ defmodule DiscoLog.PresenceTest do alias DiscoLog.Presence alias DiscoLog.WebsocketClient - alias DiscoLog.DiscordMock + alias DiscoLog.Discord.API setup :verify_on_exit! @@ -16,10 +16,12 @@ defmodule DiscoLog.PresenceTest do describe "start_link" do test "connects to gateway on startup" do - expect(DiscordMock, :get_gateway, fn _config -> {:ok, "wss://foo.bar"} end) + expect(API.Mock, :request, fn client, :get, "/gateway/bot", [] -> + API.Stub.request(client, :get, "/gateway/bot", []) + end) expect(WebsocketClient.Mock, :connect, fn host, port, path -> - assert "foo.bar" = host + assert "gateway.discord.gg" = host assert 443 = port assert "/?v=10&encoding=json" = path {:ok, %WebsocketClient{}} @@ -30,8 +32,8 @@ defmodule DiscoLog.PresenceTest do {Presence, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken"}, - discord: DiscordMock, + bot_token: "mytoken", + discord_client: %API{module: API.Mock}, presence_status: "Status Message" ]} ) @@ -42,7 +44,6 @@ defmodule DiscoLog.PresenceTest do describe "Normal work" do setup do - stub(DiscordMock, :get_gateway, fn _config -> {:ok, "wss://gateway.discord.gg"} end) client = %WebsocketClient{} stub(WebsocketClient.Mock, :connect, fn _, _, _ -> {:ok, client} end) @@ -51,8 +52,8 @@ defmodule DiscoLog.PresenceTest do {Presence, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken"}, - discord: DiscordMock, + bot_token: "mytoken", + discord_client: %API{module: API.Mock}, presence_status: "Status Message", jitter: 1 ]} @@ -240,8 +241,6 @@ defmodule DiscoLog.PresenceTest do describe "Fail modes" do setup tags do - stub(DiscordMock, :get_gateway, fn _config -> {:ok, "wss://gateway.discord.gg"} end) - client = Map.merge(%{state: :open, websocket: %Mint.WebSocket{}}, Map.get(tags, :client, %{})) @@ -254,8 +253,8 @@ defmodule DiscoLog.PresenceTest do {Presence, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken"}, - discord: DiscordMock, + bot_token: "mytoken", + discord_client: %API{module: API.Mock}, presence_status: "Status Message", jitter: 1 ]} diff --git a/test/disco_log/sasl_test.exs b/test/disco_log/sasl_test.exs index 06519f3..2629614 100644 --- a/test/disco_log/sasl_test.exs +++ b/test/disco_log/sasl_test.exs @@ -4,7 +4,7 @@ defmodule DiscoLog.SaslTest do import Mox require Logger - alias DiscoLog.DiscordMock + alias DiscoLog.Discord.API @moduletag config: [supervisor_name: __MODULE__] @@ -20,6 +20,13 @@ defmodule DiscoLog.SaslTest do end) end + setup :set_mox_global + + setup do + stub_with(API.Mock, API.Stub) + :ok + end + setup :setup_supervisor setup %{config: config} do @@ -28,16 +35,15 @@ defmodule DiscoLog.SaslTest do on_exit(fn -> :logger.remove_handler(__MODULE__) end) end - setup :set_mox_global setup :verify_on_exit! test "reports crashes on c:GenServer.init/1" do pid = self() ref = make_ref() - DiscordMock - |> expect(:create_occurrence_thread, fn _config, error -> - send(pid, {ref, error}) + expect(API.Mock, :request, fn client, method, url, opts -> + send(pid, opts) + API.Stub.request(client, method, url, opts) end) defmodule CrashingGenServerInInit do @@ -47,9 +53,6 @@ defmodule DiscoLog.SaslTest do assert {:error, _reason_and_stacktrace} = GenServer.start(CrashingGenServerInInit, :no_arg) - # Pattern match the type cause we receive some other garbage messages - assert_receive {^ref, %DiscoLog.Error{} = error} - assert error.kind == to_string(RuntimeError) - assert error.reason == "oops" + assert_receive [{:path_params, [channel_id: "occurrences_channel_id"]} | _] end end diff --git a/test/disco_log/storage_test.exs b/test/disco_log/storage_test.exs index 31a4671..ddaf9b0 100644 --- a/test/disco_log/storage_test.exs +++ b/test/disco_log/storage_test.exs @@ -4,7 +4,7 @@ defmodule DiscoLog.StorageTest do import Mox alias DiscoLog.Storage - alias DiscoLog.DiscordMock + alias DiscoLog.Discord.API setup :verify_on_exit! @@ -15,16 +15,15 @@ defmodule DiscoLog.StorageTest do describe "start_link" do test "loads occurences and tags on startup" do - DiscordMock - |> expect(:list_occurrence_threads, fn _config, channel_id -> - assert channel_id == "channel_id" - - [{"fingerprint", "thread_id"}] - end) - |> expect(:list_tags, fn _config, channel_id -> - assert channel_id == "channel_id" - - %{"oban" => "oban_tag_id"} + API.Mock + |> expect(:request, 2, fn + client, :get, "/guilds/:guild_id/threads/active", opts -> + assert [path_params: [guild_id: "guild_id"]] = opts + API.Stub.request(client, :get, "/guilds/:guild_id/threads/active", []) + + client, :get, "/channels/:channel_id", opts -> + assert [path_params: [channel_id: "stub_occurrences_channel_id"]] = opts + API.Stub.request(client, :get, "/channels/:channel_id", []) end) pid = @@ -32,34 +31,39 @@ defmodule DiscoLog.StorageTest do {Storage, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken", occurrences_channel_id: "channel_id"}, - discord: DiscordMock + occurrences_channel_id: "stub_occurrences_channel_id", + guild_id: "guild_id", + discord_client: %API{module: API.Mock} ]} ) _ = :sys.get_status(pid) - assert [{pid, %{"fingerprint" => "thread_id"}}] == + assert [{pid, %{"STUBFINGERPRINT!" => "stub_thread_id"}}] == Registry.lookup(__MODULE__.Registry, {Storage, :threads}) - assert [{pid, %{"oban" => "oban_tag_id"}}] == + assert [ + {pid, + %{ + "oban" => "stub_oban_tag_id", + "live_view" => "stub_live_view_tag_id", + "plug" => "stub_plug_tag_id" + }} + ] == Registry.lookup(__MODULE__.Registry, {Storage, :tags}) end end describe inspect(&Storage.get_thread_id/2) do setup do - DiscordMock - |> stub(:list_occurrence_threads, fn _, _ -> [{"fingerprint", "thread_id"}] end) - |> stub(:list_tags, fn _, _ -> %{"oban" => "oban_tag_id"} end) - pid = start_link_supervised!( {Storage, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken", occurrences_channel_id: "channel_id"}, - discord: DiscordMock + occurrences_channel_id: "stub_occurrences_channel_id", + guild_id: "guild_id", + discord_client: %API{module: API.Mock} ]} ) @@ -68,7 +72,7 @@ defmodule DiscoLog.StorageTest do end test "thread id exists" do - assert "thread_id" = Storage.get_thread_id(__MODULE__, "fingerprint") + assert "stub_thread_id" = Storage.get_thread_id(__MODULE__, "STUBFINGERPRINT!") end test "nil if missing" do @@ -78,17 +82,14 @@ defmodule DiscoLog.StorageTest do describe inspect(&Storage.add_thread_id/3) do setup do - DiscordMock - |> stub(:list_occurrence_threads, fn _, _ -> [{"fingerprint", "thread_id"}] end) - |> stub(:list_tags, fn _, _ -> %{"oban" => "oban_tag_id"} end) - pid = start_link_supervised!( {Storage, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken", occurrences_channel_id: "channel_id"}, - discord: DiscordMock + occurrences_channel_id: "stub_occurrences_channel_id", + guild_id: "guild_id", + discord_client: %API{module: API.Mock} ]} ) @@ -118,17 +119,14 @@ defmodule DiscoLog.StorageTest do describe inspect(&Storage.get_tags/1) do setup do - DiscordMock - |> stub(:list_occurrence_threads, fn _, _ -> [{"fingerprint", "thread_id"}] end) - |> stub(:list_tags, fn _, _ -> %{"oban" => "oban_tag_id"} end) - pid = start_link_supervised!( {Storage, [ supervisor_name: __MODULE__, - discord_config: %{token: "mytoken", occurrences_channel_id: "channel_id"}, - discord: DiscordMock + occurrences_channel_id: "stub_occurrences_channel_id", + guild_id: "guild_id", + discord_client: %API{module: API.Mock} ]} ) @@ -137,7 +135,11 @@ defmodule DiscoLog.StorageTest do end test "retrieves all tags" do - assert %{"oban" => "oban_tag_id"} == Storage.get_tags(__MODULE__) + assert %{ + "oban" => "stub_oban_tag_id", + "live_view" => "stub_live_view_tag_id", + "plug" => "stub_plug_tag_id" + } == Storage.get_tags(__MODULE__) end end end diff --git a/test/support/case.ex b/test/support/case.ex index 9baf78e..a673a96 100644 --- a/test/support/case.ex +++ b/test/support/case.ex @@ -2,25 +2,33 @@ defmodule DiscoLog.Test.Case do @moduledoc false use ExUnit.CaseTemplate - @config DiscoLog.Config.validate!( - otp_app: :disco_log, - token: "mytoken", - guild_id: "guild_id", - category_id: "category_id", - occurrences_channel_id: "occurences_channel_id", - info_channel_id: "info_channel_id", - error_channel_id: "error_channel_id", - discord: DiscoLog.DiscordMock, - enable_presence: false, - enable_go_to_repo: true, - go_to_repo_top_modules: ["DiscoLog"], - repo_url: "https://github.com/mrdotb/disco-log/blob", - git_sha: "main" - ) + @config [ + otp_app: :disco_log, + token: "mytoken", + guild_id: "guild_id", + category_id: "category_id", + occurrences_channel_id: "occurrences_channel_id", + info_channel_id: "info_channel_id", + error_channel_id: "error_channel_id", + discord_client_module: DiscoLog.Discord.API.Mock, + enable_presence: false, + enable_go_to_repo: true, + go_to_repo_top_modules: ["DiscoLog"], + repo_url: "https://github.com/mrdotb/disco-log/blob", + git_sha: "main" + ] using do quote do import DiscoLog.Test.Case + + setup tags do + if tags[:async] do + Mox.stub_with(DiscoLog.Discord.API.Mock, DiscoLog.Discord.API.Stub) + end + + :ok + end end end @@ -31,10 +39,10 @@ defmodule DiscoLog.Test.Case do fun.() rescue exception -> - DiscoLog.Error.new(exception, __STACKTRACE__, %{}, @config) + DiscoLog.Error.new(exception, __STACKTRACE__, %{}, DiscoLog.Config.validate!(@config)) catch kind, reason -> - DiscoLog.Error.new({kind, reason}, __STACKTRACE__, %{}, @config) + DiscoLog.Error.new({kind, reason}, __STACKTRACE__, %{}, DiscoLog.Config.validate!(@config)) end @doc """ @@ -74,10 +82,10 @@ defmodule DiscoLog.Test.Case do token: "mytoken", guild_id: "guild_id", category_id: "category_id", - occurrences_channel_id: "occurences_channel_id", + occurrences_channel_id: "occurrences_channel_id", info_channel_id: "info_channel_id", error_channel_id: "error_channel_id", - discord: DiscoLog.DiscordMock, + discord_client_module: DiscoLog.Discord.API.Mock, enable_presence: false ] |> Keyword.merge(Map.fetch!(context, :config)) @@ -87,11 +95,6 @@ defmodule DiscoLog.Test.Case do {:ok, struct(DiscoLog.WebsocketClient, %{})} end) - DiscoLog.DiscordMock - |> Mox.stub(:get_gateway, fn _config -> {:ok, "wss://gateway.discord.gg"} end) - |> Mox.stub(:list_occurrence_threads, fn _, _ -> [] end) - |> Mox.stub(:list_tags, fn _, _ -> %{} end) - {:ok, _pid} = start_supervised({DiscoLog.Supervisor, config}) # Wait until async init is completed @@ -121,7 +124,7 @@ defmodule DiscoLog.Test.Case do NimbleOwnership.fetch_owner( {:global, Mox.Server}, [self() | callers], - DiscoLog.DiscordMock + DiscoLog.Discord.API.Mock ) if owner_pid == test_pid, do: event, else: :stop diff --git a/test/support/discord/api/stub.ex b/test/support/discord/api/stub.ex new file mode 100644 index 0000000..68204e5 --- /dev/null +++ b/test/support/discord/api/stub.ex @@ -0,0 +1,235 @@ +defmodule DiscoLog.Discord.API.Stub do + @moduledoc """ + A collection of canned API responses used as default stubs for `DiscoLog.Discord.API.Mock` + """ + @behaviour DiscoLog.Discord.API + + @impl DiscoLog.Discord.API + def client(_token), + do: %DiscoLog.Discord.API{client: :stub_client, module: DiscoLog.Discord.API.Mock} + + @impl DiscoLog.Discord.API + def request(_client, :get, "/gateway/bot", _opts) do + {:ok, + %{ + status: 200, + headers: %{}, + body: %{ + "session_start_limit" => %{ + "max_concurrency" => 1, + "remaining" => 988, + "reset_after" => 3_297_000, + "total" => 1000 + }, + "shards" => 1, + "url" => "wss://gateway.discord.gg" + } + }} + end + + def request(_client, :get, "/guilds/:guild_id/threads/active", _opts) do + {:ok, + %{ + status: 200, + headers: %{}, + body: %{ + "has_more" => false, + "members" => [], + "threads" => [ + %{ + "applied_tags" => ["1306066065390043156", "1306066121325285446"], + "bitrate" => 64_000, + "flags" => 0, + "guild_id" => "1302395532735414282", + "id" => "stub_thread_id", + "last_message_id" => "1327070193624547442", + "member_count" => 1, + "message_count" => 1, + "name" => "STUBFINGERPRINT! Elixir.RuntimeError", + "owner_id" => "1302396835582836757", + "parent_id" => "stub_occurrences_channel_id", + "rate_limit_per_user" => 0, + "rtc_region" => nil, + "thread_metadata" => %{ + "archive_timestamp" => "2025-01-10T00:23:09.502000+00:00", + "archived" => false, + "auto_archive_duration" => 4320, + "create_timestamp" => "2025-01-10T00:23:09.502000+00:00", + "locked" => false + }, + "total_message_sent" => 1, + "type" => 11, + "user_limit" => 0 + } + ] + } + }} + end + + def request(_client, :get, "/channels/:channel_id", _opts) do + {:ok, + %{ + status: 200, + headers: %{}, + body: %{ + "available_tags" => [ + %{ + "emoji_id" => nil, + "emoji_name" => nil, + "id" => "stub_plug_tag_id", + "moderated" => false, + "name" => "plug" + }, + %{ + "emoji_id" => nil, + "emoji_name" => nil, + "id" => "stub_live_view_tag_id", + "moderated" => false, + "name" => "live_view" + }, + %{ + "emoji_id" => nil, + "emoji_name" => nil, + "id" => "stub_oban_tag_id", + "moderated" => false, + "name" => "oban" + } + ], + "default_forum_layout" => 0, + "default_reaction_emoji" => nil, + "default_sort_order" => nil, + "flags" => 0, + "guild_id" => "1302395532735414282", + "icon_emoji" => nil, + "id" => "1306065664909512784", + "last_message_id" => "1327070191821131865", + "name" => "occurrences", + "nsfw" => false, + "parent_id" => "1306065439398428694", + "permission_overwrites" => [], + "position" => 11, + "rate_limit_per_user" => 0, + "template" => "", + "theme_color" => nil, + "topic" => nil, + "type" => 15 + } + }} + end + + def request(_client, :post, "/channels/:channel_id/messages", _opts) do + {:ok, + %{ + status: 200, + headers: %{}, + body: %{ + "attachments" => [], + "author" => %{ + "accent_color" => nil, + "avatar" => nil, + "avatar_decoration_data" => nil, + "banner" => nil, + "banner_color" => nil, + "bot" => true, + "clan" => nil, + "discriminator" => "9087", + "flags" => 0, + "global_name" => nil, + "id" => "1302396835582836757", + "primary_guild" => nil, + "public_flags" => 0, + "username" => "Disco Log" + }, + "channel_id" => "1306065758723379293", + "components" => [], + "content" => "Hello, World!", + "edited_timestamp" => nil, + "embeds" => [], + "flags" => 0, + "id" => "1327747295587995708", + "mention_everyone" => false, + "mention_roles" => [], + "mentions" => [], + "pinned" => false, + "timestamp" => "2025-01-11T21:13:43.620000+00:00", + "tts" => false, + "type" => 0 + } + }} + end + + def request(_client, :post, "/channels/:channel_id/threads", _opts) do + {:ok, + %{ + status: 201, + headers: %{}, + body: %{ + "bitrate" => 64_000, + "flags" => 0, + "guild_id" => "1302395532735414282", + "id" => "1327765635022852098", + "last_message_id" => "1327765635022852098", + "member" => %{ + "flags" => 1, + "id" => "1327765635022852098", + "join_timestamp" => "2025-01-11T22:26:36.114358+00:00", + "mute_config" => nil, + "muted" => false, + "user_id" => "1302396835582836757" + }, + "member_count" => 1, + "message" => %{ + "attachments" => [], + "author" => %{ + "accent_color" => nil, + "avatar" => nil, + "avatar_decoration_data" => nil, + "banner" => nil, + "banner_color" => nil, + "bot" => true, + "clan" => nil, + "discriminator" => "9087", + "flags" => 0, + "global_name" => nil, + "id" => "1302396835582836757", + "primary_guild" => nil, + "public_flags" => 0, + "username" => "Disco Log" + }, + "channel_id" => "1327765635022852098", + "components" => [], + "content" => + "**At:** \n **Kind:** ``\n **Reason:** ``\n **Source Line:** ``\n **Source Function:** ``\n **Fingerprint:** `foo`", + "edited_timestamp" => nil, + "embeds" => [], + "flags" => 0, + "id" => "1327765635022852098", + "mention_everyone" => false, + "mention_roles" => [], + "mentions" => [], + "pinned" => false, + "position" => 0, + "timestamp" => "2025-01-11T22:26:36.082000+00:00", + "tts" => false, + "type" => 0 + }, + "message_count" => 0, + "name" => "foo", + "owner_id" => "1302396835582836757", + "parent_id" => "1306065664909512784", + "rate_limit_per_user" => 0, + "rtc_region" => nil, + "thread_metadata" => %{ + "archive_timestamp" => "2025-01-11T22:26:36.082000+00:00", + "archived" => false, + "auto_archive_duration" => 4320, + "create_timestamp" => "2025-01-11T22:26:36.082000+00:00", + "locked" => false + }, + "total_message_sent" => 0, + "type" => 11, + "user_limit" => 0 + } + }} + end +end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index da9f00f..380ab65 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -1,4 +1,4 @@ -Mox.defmock(DiscoLog.DiscordMock, for: DiscoLog.DiscordBehaviour) +Mox.defmock(DiscoLog.Discord.API.Mock, for: DiscoLog.Discord.API) Mox.defmock(DiscoLog.WebsocketClient.Mock, for: DiscoLog.WebsocketClient) defmodule Env do