api-surface #1

Merged
luna merged 51 commits from api-surface into mistress 2026-07-05 14:43:13 +00:00
4 changed files with 516 additions and 0 deletions
Showing only changes of commit 217361de42 - Show all commits

feat(api): full channels + messages endpoint rollout

Luna 2026-07-05 06:23:09 -03:00

View file

@ -21,4 +21,84 @@ defmodule Dexcord.Api.Channels do
binary_wrap: :name,
reason: true,
returns: Dexcord.Channel
endpoint :edit_channel, :patch, "/channels/:channel_id",
body: true,
reason: true,
returns: Dexcord.Channel
endpoint :delete_channel, :delete, "/channels/:channel_id",
reason: true,
returns: Dexcord.Channel
endpoint :set_voice_channel_status, :put, "/channels/:channel_id/voice-status",
body: true,
reason: true,
returns: nil
endpoint :edit_channel_permissions, :put, "/channels/:channel_id/permissions/:overwrite_id",
body: true,
reason: true,
returns: nil
endpoint :delete_channel_permission, :delete, "/channels/:channel_id/permissions/:overwrite_id",
reason: true,
returns: nil
endpoint :get_channel_invites, :get, "/channels/:channel_id/invites", returns: [Dexcord.Invite]
endpoint :create_channel_invite, :post, "/channels/:channel_id/invites",
body: true,
reason: true,
returns: Dexcord.Invite
endpoint :follow_announcement_channel, :post, "/channels/:channel_id/followers",
body: true,
reason: true,
returns: Dexcord.FollowedChannel
endpoint :trigger_typing, :post, "/channels/:channel_id/typing", returns: nil
endpoint :group_dm_add_recipient, :put, "/channels/:channel_id/recipients/:user_id",
body: true,
returns: nil
endpoint :group_dm_remove_recipient, :delete, "/channels/:channel_id/recipients/:user_id",
returns: nil
endpoint :start_thread, :post, "/channels/:channel_id/threads",
body: true,
binary_wrap: :name,
files: true,
reason: true,
returns: Dexcord.Channel
endpoint :join_thread, :put, "/channels/:channel_id/thread-members/@me", returns: nil
endpoint :add_thread_member, :put, "/channels/:channel_id/thread-members/:user_id", returns: nil
endpoint :leave_thread, :delete, "/channels/:channel_id/thread-members/@me", returns: nil
endpoint :remove_thread_member, :delete, "/channels/:channel_id/thread-members/:user_id",
returns: nil
endpoint :get_thread_member, :get, "/channels/:channel_id/thread-members/:user_id",
query: [:with_member],
returns: Dexcord.ThreadMember
endpoint :list_thread_members, :get, "/channels/:channel_id/thread-members",
query: [:with_member, :after, :limit],
returns: [Dexcord.ThreadMember]
endpoint :list_public_archived_threads, :get, "/channels/:channel_id/threads/archived/public",
query: [:before, :limit],
returns: :raw
endpoint :list_private_archived_threads, :get, "/channels/:channel_id/threads/archived/private",
query: [:before, :limit],
returns: :raw
endpoint :list_joined_private_archived_threads,
:get,
"/channels/:channel_id/users/@me/threads/archived/private",
query: [:before, :limit],
returns: :raw
end

View file

@ -43,6 +43,84 @@ defmodule Dexcord.Api.Messages do
"/channels/:channel_id/messages/:message_id/reactions/:emoji/@me",
returns: nil
endpoint :get_channel_message, :get, "/channels/:channel_id/messages/:message_id",
returns: Dexcord.Message
endpoint :crosspost_message, :post, "/channels/:channel_id/messages/:message_id/crosspost",
returns: Dexcord.Message
endpoint :delete_own_reaction,
:delete,
"/channels/:channel_id/messages/:message_id/reactions/:emoji/@me", returns: nil
endpoint :delete_user_reaction,
:delete,
"/channels/:channel_id/messages/:message_id/reactions/:emoji/:user_id", returns: nil
endpoint :get_reactions, :get, "/channels/:channel_id/messages/:message_id/reactions/:emoji",
query: [:type, :after, :limit],
returns: [Dexcord.User]
endpoint :delete_all_reactions, :delete, "/channels/:channel_id/messages/:message_id/reactions",
returns: nil
endpoint :delete_all_reactions_for_emoji,
:delete,
"/channels/:channel_id/messages/:message_id/reactions/:emoji", returns: nil
endpoint :bulk_delete_messages, :post, "/channels/:channel_id/messages/bulk-delete",
body: true,
reason: true,
returns: nil
endpoint :get_channel_pins, :get, "/channels/:channel_id/messages/pins",
query: [:before, :limit],
returns: :raw
endpoint :pin_message, :put, "/channels/:channel_id/messages/pins/:message_id",
reason: true,
returns: nil
endpoint :unpin_message, :delete, "/channels/:channel_id/messages/pins/:message_id",
reason: true,
returns: nil
endpoint :search_guild_messages, :get, "/guilds/:guild_id/messages/search",
query: [
:limit,
:offset,
:max_id,
:min_id,
:slop,
:content,
:channel_id,
:author_type,
:author_id,
:mentions,
:mentions_role_id,
:mention_everyone,
:replied_to_user_id,
:replied_to_message_id,
:pinned,
:has,
:embed_type,
:embed_provider,
:link_hostname,
:attachment_filename,
:attachment_extension,
:sort_by,
:sort_order,
:include_nsfw
],
returns: :raw
endpoint :get_answer_voters, :get, "/channels/:channel_id/polls/:message_id/answers/:answer_id",
query: [:after, :limit],
returns: :raw
endpoint :end_poll, :post, "/channels/:channel_id/polls/:message_id/expire",
returns: Dexcord.Message
@doc """
Nostrum-compatible locator arity for `get_channel_messages/2`.

View file

@ -0,0 +1,178 @@
defmodule Dexcord.ApiChannelsTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Channels` group, driven through
the real `Dexcord.Api` + `Dexcord.Api.Ratelimit` against `Dexcord.FakeRest`.
One round-trip per shape family plus the specials called out in Phase 5 Task 2.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@token "test.token.value"
setup do
Dexcord.EnvSandbox.sandbox_env()
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
start_supervised!({Finch, name: Dexcord.Finch})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
test "edit_channel PATCHes a body and decodes the concrete channel struct" do
FakeRest.stub(
:patch,
"/channels/1",
FakeRest.resp(200, body: ~s({"id":"1","type":0,"name":"renamed"}))
)
assert {:ok, %Dexcord.TextChannel{id: 1, name: "renamed"}} =
Api.edit_channel(1, %{"name" => "renamed"}, reason: "tidy up")
assert_receive {:rest_hit, info}
assert info.method == "PATCH"
assert info.path == "/channels/1"
assert JSON.decode!(info.body) == %{"name" => "renamed"}
headers = Map.new(info.headers)
assert headers["x-audit-log-reason"] == "tidy%20up"
end
test "delete_channel decodes the deleted channel and carries a reason" do
FakeRest.stub(:delete, "/channels/5", FakeRest.resp(200, body: ~s({"id":"5","type":0})))
assert {:ok, %Dexcord.TextChannel{id: 5}} = Api.delete_channel(5, reason: "gone")
assert_receive {:rest_hit, info}
assert info.method == "DELETE"
assert Map.new(info.headers)["x-audit-log-reason"] == "gone"
end
test "get_channel_invites decodes a list of invites" do
FakeRest.stub(
:get,
"/channels/1/invites",
FakeRest.resp(200, body: ~s([{"code":"abc"},{"code":"def"}]))
)
assert {:ok, [%Dexcord.Invite{code: "abc"}, %Dexcord.Invite{code: "def"}]} =
Api.get_channel_invites(1)
end
test "create_channel_invite posts a body and decodes an invite" do
FakeRest.stub(
:post,
"/channels/1/invites",
FakeRest.resp(200, body: ~s({"code":"xyz"}))
)
assert {:ok, %Dexcord.Invite{code: "xyz"}} =
Api.create_channel_invite(1, %{"max_age" => 3600}, reason: "share")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"max_age" => 3600}
end
test "follow_announcement_channel decodes a FollowedChannel" do
FakeRest.stub(
:post,
"/channels/1/followers",
FakeRest.resp(200, body: ~s({"channel_id":"9","webhook_id":"10"}))
)
assert {:ok, %Dexcord.FollowedChannel{channel_id: 9, webhook_id: 10}} =
Api.follow_announcement_channel(1, %{"webhook_channel_id" => "9"})
end
test "trigger_typing POSTs with no body and returns {:ok, nil}" do
FakeRest.stub(:post, "/channels/1/typing", FakeRest.resp(204))
assert {:ok, nil} = Api.trigger_typing(1)
assert_receive {:rest_hit, info}
assert info.method == "POST"
assert info.body == ""
end
test "edit_channel_permissions PUTs a body, returns nil, carries a reason" do
FakeRest.stub(:put, "/channels/1/permissions/2", FakeRest.resp(204))
assert {:ok, nil} =
Api.edit_channel_permissions(1, 2, %{"allow" => "8", "type" => 0}, reason: "perm")
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/permissions/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "perm"
end
test "get_thread_member forwards the with_member query and decodes a ThreadMember" do
FakeRest.stub(
:get,
"/channels/1/thread-members/2",
FakeRest.resp(200, body: ~s({"id":"1","user_id":"2"}))
)
assert {:ok, %Dexcord.ThreadMember{user_id: 2}} =
Api.get_thread_member(1, 2, with_member: true)
assert_receive {:rest_hit, info}
assert info.query_string == "with_member=true"
end
test "list_thread_members decodes a list and forwards the query keys" do
FakeRest.stub(
:get,
"/channels/1/thread-members",
FakeRest.resp(200, body: ~s([{"user_id":"2"},{"user_id":"3"}]))
)
assert {:ok, [%Dexcord.ThreadMember{user_id: 2}, %Dexcord.ThreadMember{user_id: 3}]} =
Api.list_thread_members(1, with_member: true, limit: 50)
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{"with_member" => "true", "limit" => "50"}
end
test "list_public_archived_threads returns the raw envelope untouched" do
FakeRest.stub(
:get,
"/channels/1/threads/archived/public",
FakeRest.resp(200, body: ~s({"threads":[],"members":[],"has_more":false}))
)
assert {:ok, %{"threads" => [], "members" => [], "has_more" => false}} =
Api.list_public_archived_threads(1, limit: 10)
end
test "start_thread wraps a binary as name, carries a reason, decodes a Thread" do
FakeRest.stub(
:post,
"/channels/1/threads",
FakeRest.resp(201, body: ~s({"id":"77","type":11,"name":"chat"}))
)
assert {:ok, %Dexcord.Thread{id: 77, name: "chat"}} =
Api.start_thread(1, "chat", reason: "spin up")
assert_receive {:rest_hit, info}
assert JSON.decode!(info.body) == %{"name" => "chat"}
assert Map.new(info.headers)["x-audit-log-reason"] == "spin%20up"
end
test "group_dm_add_recipient PUTs a body and returns nil" do
FakeRest.stub(:put, "/channels/1/recipients/2", FakeRest.resp(204))
assert {:ok, nil} =
Api.group_dm_add_recipient(1, 2, %{"access_token" => "tok", "nick" => "friend"})
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/recipients/2"
assert JSON.decode!(info.body) == %{"access_token" => "tok", "nick" => "friend"}
end
end

View file

@ -0,0 +1,180 @@
defmodule Dexcord.ApiMessagesTest do
@moduledoc """
Representative wire tests for the `Dexcord.Api.Messages` group (Phase 5 Task 2):
reactions, bulk delete, the new pins route, polls, and guild message search.
"""
use ExUnit.Case, async: false
alias Dexcord.Api
alias Dexcord.Api.Ratelimit
alias Dexcord.FakeRest
@token "test.token.value"
setup do
Dexcord.EnvSandbox.sandbox_env()
Dexcord.Config.put(%{token: @token, handler: nil, intents: 0})
start_supervised!({Finch, name: Dexcord.Finch})
start_supervised!(Ratelimit)
start_supervised!(FakeRest)
Application.put_env(:dexcord, :api_base_url, FakeRest.base_url())
FakeRest.subscribe(self())
:ok
end
test "get_channel_message decodes a single Message" do
FakeRest.stub(
:get,
"/channels/1/messages/2",
FakeRest.resp(200, body: ~s({"id":"2","content":"hi"}))
)
assert {:ok, %Dexcord.Message{id: 2, content: "hi"}} = Api.get_channel_message(1, 2)
end
test "crosspost_message decodes a Message" do
FakeRest.stub(
:post,
"/channels/1/messages/2/crosspost",
FakeRest.resp(200, body: ~s({"id":"2"}))
)
assert {:ok, %Dexcord.Message{id: 2}} = Api.crosspost_message(1, 2)
end
test "delete_own_reaction percent-encodes the emoji in the path" do
FakeRest.stub(
:delete,
"/channels/1/messages/2/reactions/%F0%9F%94%A5/@me",
FakeRest.resp(204)
)
assert {:ok, nil} = Api.delete_own_reaction(1, 2, "🔥")
assert_receive {:rest_hit, info}
assert info.method == "DELETE"
assert info.path == "/channels/1/messages/2/reactions/%F0%9F%94%A5/@me"
end
test "delete_user_reaction targets a specific user's reaction" do
FakeRest.stub(
:delete,
"/channels/1/messages/2/reactions/%F0%9F%94%A5/3",
FakeRest.resp(204)
)
assert {:ok, nil} = Api.delete_user_reaction(1, 2, "🔥", 3)
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/2/reactions/%F0%9F%94%A5/3"
end
test "get_reactions decodes a list of users and forwards the query" do
FakeRest.stub(
:get,
"/channels/1/messages/2/reactions/%F0%9F%94%A5",
FakeRest.resp(200, body: ~s([{"id":"10"},{"id":"11"}]))
)
assert {:ok, [%Dexcord.User{id: 10}, %Dexcord.User{id: 11}]} =
Api.get_reactions(1, 2, "🔥", limit: 25)
assert_receive {:rest_hit, info}
assert info.query_string == "limit=25"
end
test "delete_all_reactions returns nil" do
FakeRest.stub(:delete, "/channels/1/messages/2/reactions", FakeRest.resp(204))
assert {:ok, nil} = Api.delete_all_reactions(1, 2)
end
test "bulk_delete_messages posts a messages array body with a reason" do
FakeRest.stub(:post, "/channels/1/messages/bulk-delete", FakeRest.resp(204))
assert {:ok, nil} =
Api.bulk_delete_messages(1, %{"messages" => ["2", "3", "4"]}, reason: "cleanup")
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/bulk-delete"
assert JSON.decode!(info.body) == %{"messages" => ["2", "3", "4"]}
assert Map.new(info.headers)["x-audit-log-reason"] == "cleanup"
end
test "get_channel_pins hits the new pins route and returns the raw envelope" do
FakeRest.stub(
:get,
"/channels/1/messages/pins",
FakeRest.resp(200, body: ~s({"items":[],"has_more":false}))
)
assert {:ok, %{"items" => [], "has_more" => false}} =
Api.get_channel_pins(1, limit: 5)
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/pins"
end
test "pin_message PUTs the new pins route with a reason header" do
FakeRest.stub(:put, "/channels/1/messages/pins/2", FakeRest.resp(204))
assert {:ok, nil} = Api.pin_message(1, 2, reason: "important")
assert_receive {:rest_hit, info}
assert info.method == "PUT"
assert info.path == "/channels/1/messages/pins/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "important"
end
test "unpin_message DELETEs the new pins route with a reason header" do
FakeRest.stub(:delete, "/channels/1/messages/pins/2", FakeRest.resp(204))
assert {:ok, nil} = Api.unpin_message(1, 2, reason: "stale")
assert_receive {:rest_hit, info}
assert info.path == "/channels/1/messages/pins/2"
assert Map.new(info.headers)["x-audit-log-reason"] == "stale"
end
test "search_guild_messages forwards its query keys and returns raw" do
FakeRest.stub(
:get,
"/guilds/1/messages/search",
FakeRest.resp(200, body: ~s({"messages":[],"total_results":0}))
)
assert {:ok, %{"messages" => [], "total_results" => 0}} =
Api.search_guild_messages(1, content: "hello", limit: 10, author_id: "42")
assert_receive {:rest_hit, info}
assert URI.decode_query(info.query_string) == %{
"content" => "hello",
"limit" => "10",
"author_id" => "42"
}
end
test "get_answer_voters returns the raw voters envelope" do
FakeRest.stub(
:get,
"/channels/1/polls/2/answers/3",
FakeRest.resp(200, body: ~s({"users":[]}))
)
assert {:ok, %{"users" => []}} = Api.get_answer_voters(1, 2, 3, limit: 5)
end
test "end_poll returns the finalized Message" do
FakeRest.stub(
:post,
"/channels/1/polls/2/expire",
FakeRest.resp(200, body: ~s({"id":"2","content":"poll over"}))
)
assert {:ok, %Dexcord.Message{id: 2, content: "poll over"}} = Api.end_poll(1, 2)
end
end