refactor(test): share discord send rest harness

This commit is contained in:
Peter Steinberger
2026-02-14 21:16:57 +00:00
parent 0b59c48087
commit b97191b81a
3 changed files with 97 additions and 118 deletions

View File

@@ -16,43 +16,12 @@ import {
uploadEmojiDiscord,
uploadStickerDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "photo.jpg",
contentType: "image/jpeg",
kind: "image",
}),
loadWebMediaRaw: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "asset.png",
contentType: "image/png",
kind: "image",
}),
}));
const makeRest = () => {
const postMock = vi.fn();
const putMock = vi.fn();
const getMock = vi.fn();
const patchMock = vi.fn();
const deleteMock = vi.fn();
return {
rest: {
post: postMock,
put: putMock,
get: getMock,
patch: patchMock,
delete: deleteMock,
} as unknown as import("@buape/carbon").RequestClient,
postMock,
putMock,
getMock,
patchMock,
deleteMock,
};
};
vi.mock("../web/media.js", async () => {
const { discordWebMediaMockFactory } = await import("./send.test-harness.js");
return discordWebMediaMockFactory();
});
describe("sendMessageDiscord", () => {
beforeEach(() => {
@@ -60,7 +29,7 @@ describe("sendMessageDiscord", () => {
});
it("creates a thread", async () => {
const { rest, getMock, postMock } = makeRest();
const { rest, getMock, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread", messageId: "m1" }, { rest, token: "t" });
expect(getMock).not.toHaveBeenCalled();
@@ -71,7 +40,7 @@ describe("sendMessageDiscord", () => {
});
it("creates forum threads with an initial message", async () => {
const { rest, getMock, postMock } = makeRest();
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildForum });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" });
@@ -88,7 +57,7 @@ describe("sendMessageDiscord", () => {
});
it("creates media threads with provided content", async () => {
const { rest, getMock, postMock } = makeRest();
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildMedia });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
@@ -108,7 +77,7 @@ describe("sendMessageDiscord", () => {
});
it("falls back when channel lookup is unavailable", async () => {
const { rest, getMock, postMock } = makeRest();
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockRejectedValue(new Error("lookup failed"));
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord("chan1", { name: "thread" }, { rest, token: "t" });
@@ -121,7 +90,7 @@ describe("sendMessageDiscord", () => {
});
it("respects explicit thread type for standalone threads", async () => {
const { rest, getMock, postMock } = makeRest();
const { rest, getMock, postMock } = makeDiscordRest();
getMock.mockResolvedValue({ type: ChannelType.GuildText });
postMock.mockResolvedValue({ id: "t1" });
await createThreadDiscord(
@@ -139,14 +108,14 @@ describe("sendMessageDiscord", () => {
});
it("lists active threads by guild", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue({ threads: [] });
await listThreadsDiscord({ guildId: "g1" }, { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.guildActiveThreads("g1"));
});
it("times out a member", async () => {
const { rest, patchMock } = makeRest();
const { rest, patchMock } = makeDiscordRest();
patchMock.mockResolvedValue({ id: "m1" });
await timeoutMemberDiscord(
{ guildId: "g1", userId: "u1", durationMinutes: 10 },
@@ -163,7 +132,7 @@ describe("sendMessageDiscord", () => {
});
it("adds and removes roles", async () => {
const { rest, putMock, deleteMock } = makeRest();
const { rest, putMock, deleteMock } = makeDiscordRest();
putMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await addRoleDiscord({ guildId: "g1", userId: "u1", roleId: "r1" }, { rest, token: "t" });
@@ -173,7 +142,7 @@ describe("sendMessageDiscord", () => {
});
it("bans a member", async () => {
const { rest, putMock } = makeRest();
const { rest, putMock } = makeDiscordRest();
putMock.mockResolvedValue({});
await banMemberDiscord(
{ guildId: "g1", userId: "u1", deleteMessageDays: 2 },
@@ -192,7 +161,7 @@ describe("listGuildEmojisDiscord", () => {
});
it("lists emojis for a guild", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue([{ id: "e1", name: "party" }]);
await listGuildEmojisDiscord("g1", { rest, token: "t" });
expect(getMock).toHaveBeenCalledWith(Routes.guildEmojis("g1"));
@@ -205,7 +174,7 @@ describe("uploadEmojiDiscord", () => {
});
it("uploads emoji assets", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "e1" });
await uploadEmojiDiscord(
{
@@ -235,7 +204,7 @@ describe("uploadStickerDiscord", () => {
});
it("uploads sticker assets", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "s1" });
await uploadStickerDiscord(
{
@@ -272,7 +241,7 @@ describe("sendStickerDiscord", () => {
});
it("sends sticker payloads", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
const res = await sendStickerDiscord("channel:789", ["123"], {
rest,
@@ -298,7 +267,7 @@ describe("sendPollDiscord", () => {
});
it("sends polls with answers", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
const res = await sendPollDiscord(
"channel:789",
@@ -350,7 +319,7 @@ describe("retry rate limits", () => {
});
it("retries on Discord rate limits", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
postMock
@@ -370,7 +339,7 @@ describe("retry rate limits", () => {
it("uses retry_after delays when rate limited", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0.5);
postMock
@@ -394,7 +363,7 @@ describe("retry rate limits", () => {
});
it("stops after max retry attempts", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
postMock.mockRejectedValue(rateLimitError);
@@ -410,7 +379,7 @@ describe("retry rate limits", () => {
});
it("does not retry non-rate-limit errors", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockRejectedValueOnce(new Error("network error"));
await expect(sendMessageDiscord("channel:789", "hello", { rest, token: "t" })).rejects.toThrow(
@@ -420,7 +389,7 @@ describe("retry rate limits", () => {
});
it("retries reactions on rate limits", async () => {
const { rest, putMock } = makeRest();
const { rest, putMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
putMock.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(undefined);
@@ -436,7 +405,7 @@ describe("retry rate limits", () => {
});
it("retries media upload without duplicating overflow text", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
const rateLimitError = createMockRateLimitError(0);
const text = "a".repeat(2005);

View File

@@ -14,43 +14,12 @@ import {
sendMessageDiscord,
unpinMessageDiscord,
} from "./send.js";
import { makeDiscordRest } from "./send.test-harness.js";
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "photo.jpg",
contentType: "image/jpeg",
kind: "image",
}),
loadWebMediaRaw: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "asset.png",
contentType: "image/png",
kind: "image",
}),
}));
const makeRest = () => {
const postMock = vi.fn();
const putMock = vi.fn();
const getMock = vi.fn();
const patchMock = vi.fn();
const deleteMock = vi.fn();
return {
rest: {
post: postMock,
put: putMock,
get: getMock,
patch: patchMock,
delete: deleteMock,
} as unknown as import("@buape/carbon").RequestClient,
postMock,
putMock,
getMock,
patchMock,
deleteMock,
};
};
vi.mock("../web/media.js", async () => {
const { discordWebMediaMockFactory } = await import("./send.test-harness.js");
return discordWebMediaMockFactory();
});
describe("sendMessageDiscord", () => {
beforeEach(() => {
@@ -58,7 +27,7 @@ describe("sendMessageDiscord", () => {
});
it("sends basic channel messages", async () => {
const { rest, postMock, getMock } = makeRest();
const { rest, postMock, getMock } = makeDiscordRest();
// Channel type lookup returns a normal text channel (not a forum).
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
postMock.mockResolvedValue({
@@ -77,7 +46,7 @@ describe("sendMessageDiscord", () => {
});
it("auto-creates a forum thread when target is a Forum channel", async () => {
const { rest, postMock, getMock } = makeRest();
const { rest, postMock, getMock } = makeDiscordRest();
// Channel type lookup returns a Forum channel.
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
postMock.mockResolvedValue({
@@ -102,7 +71,7 @@ describe("sendMessageDiscord", () => {
});
it("posts media as a follow-up message in forum channels", async () => {
const { rest, postMock, getMock } = makeRest();
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
postMock
.mockResolvedValueOnce({
@@ -138,7 +107,7 @@ describe("sendMessageDiscord", () => {
});
it("chunks long forum posts into follow-up messages", async () => {
const { rest, postMock, getMock } = makeRest();
const { rest, postMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({ type: ChannelType.GuildForum });
postMock
.mockResolvedValueOnce({
@@ -160,7 +129,7 @@ describe("sendMessageDiscord", () => {
});
it("starts DM when recipient is a user", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock
.mockResolvedValueOnce({ id: "chan1" })
.mockResolvedValueOnce({ id: "msg1", channel_id: "chan1" });
@@ -182,7 +151,7 @@ describe("sendMessageDiscord", () => {
});
it("rejects bare numeric IDs as ambiguous", async () => {
const { rest } = makeRest();
const { rest } = makeDiscordRest();
await expect(
sendMessageDiscord("273512430271856640", "hello", { rest, token: "t" }),
).rejects.toThrow(/Ambiguous Discord recipient/);
@@ -195,7 +164,7 @@ describe("sendMessageDiscord", () => {
});
it("adds missing permission hints on 50013", async () => {
const { rest, postMock, getMock } = makeRest();
const { rest, postMock, getMock } = makeDiscordRest();
const perms = PermissionFlagsBits.ViewChannel;
const apiError = Object.assign(new Error("Missing Permissions"), {
code: 50013,
@@ -228,7 +197,7 @@ describe("sendMessageDiscord", () => {
});
it("uploads media attachments", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "photo", {
rest,
@@ -247,7 +216,7 @@ describe("sendMessageDiscord", () => {
});
it("sends media with empty text without content field", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "", {
rest,
@@ -261,7 +230,7 @@ describe("sendMessageDiscord", () => {
});
it("preserves whitespace in media captions", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
await sendMessageDiscord("channel:789", " spaced ", {
rest,
@@ -273,7 +242,7 @@ describe("sendMessageDiscord", () => {
});
it("includes message_reference when replying", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
await sendMessageDiscord("channel:789", "hello", {
rest,
@@ -288,7 +257,7 @@ describe("sendMessageDiscord", () => {
});
it("replies only on the first chunk", async () => {
const { rest, postMock } = makeRest();
const { rest, postMock } = makeDiscordRest();
postMock.mockResolvedValue({ id: "msg1", channel_id: "789" });
await sendMessageDiscord("channel:789", "a".repeat(2001), {
rest,
@@ -312,7 +281,7 @@ describe("reactMessageDiscord", () => {
});
it("reacts with unicode emoji", async () => {
const { rest, putMock } = makeRest();
const { rest, putMock } = makeDiscordRest();
await reactMessageDiscord("chan1", "msg1", "✅", { rest, token: "t" });
expect(putMock).toHaveBeenCalledWith(
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
@@ -320,7 +289,7 @@ describe("reactMessageDiscord", () => {
});
it("normalizes variation selectors in unicode emoji", async () => {
const { rest, putMock } = makeRest();
const { rest, putMock } = makeDiscordRest();
await reactMessageDiscord("chan1", "msg1", "⭐️", { rest, token: "t" });
expect(putMock).toHaveBeenCalledWith(
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%AD%90"),
@@ -328,7 +297,7 @@ describe("reactMessageDiscord", () => {
});
it("reacts with custom emoji syntax", async () => {
const { rest, putMock } = makeRest();
const { rest, putMock } = makeDiscordRest();
await reactMessageDiscord("chan1", "msg1", "<:party_blob:123>", {
rest,
token: "t",
@@ -345,7 +314,7 @@ describe("removeReactionDiscord", () => {
});
it("removes a unicode emoji reaction", async () => {
const { rest, deleteMock } = makeRest();
const { rest, deleteMock } = makeDiscordRest();
await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t" });
expect(deleteMock).toHaveBeenCalledWith(
Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"),
@@ -359,7 +328,7 @@ describe("removeOwnReactionsDiscord", () => {
});
it("removes all own reactions on a message", async () => {
const { rest, getMock, deleteMock } = makeRest();
const { rest, getMock, deleteMock } = makeDiscordRest();
getMock.mockResolvedValue({
reactions: [
{ emoji: { name: "✅", id: null } },
@@ -386,7 +355,7 @@ describe("fetchReactionsDiscord", () => {
});
it("returns reactions with users", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock
.mockResolvedValueOnce({
reactions: [
@@ -421,7 +390,7 @@ describe("fetchChannelPermissionsDiscord", () => {
});
it("calculates permissions from guild roles", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
const perms = PermissionFlagsBits.ViewChannel | PermissionFlagsBits.SendMessages;
getMock
.mockResolvedValueOnce({
@@ -449,7 +418,7 @@ describe("fetchChannelPermissionsDiscord", () => {
});
it("treats Administrator as all permissions despite overwrites", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock
.mockResolvedValueOnce({
id: "chan1",
@@ -483,7 +452,7 @@ describe("readMessagesDiscord", () => {
});
it("passes query params as an object", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue([]);
await readMessagesDiscord("chan1", { limit: 5, before: "10" }, { rest, token: "t" });
const call = getMock.mock.calls[0];
@@ -498,7 +467,7 @@ describe("edit/delete message helpers", () => {
});
it("edits message content", async () => {
const { rest, patchMock } = makeRest();
const { rest, patchMock } = makeDiscordRest();
patchMock.mockResolvedValue({ id: "m1" });
await editMessageDiscord("chan1", "m1", { content: "hello" }, { rest, token: "t" });
expect(patchMock).toHaveBeenCalledWith(
@@ -508,7 +477,7 @@ describe("edit/delete message helpers", () => {
});
it("deletes message", async () => {
const { rest, deleteMock } = makeRest();
const { rest, deleteMock } = makeDiscordRest();
deleteMock.mockResolvedValue({});
await deleteMessageDiscord("chan1", "m1", { rest, token: "t" });
expect(deleteMock).toHaveBeenCalledWith(Routes.channelMessage("chan1", "m1"));
@@ -521,7 +490,7 @@ describe("pin helpers", () => {
});
it("pins and unpins messages", async () => {
const { rest, putMock, deleteMock } = makeRest();
const { rest, putMock, deleteMock } = makeDiscordRest();
putMock.mockResolvedValue({});
deleteMock.mockResolvedValue({});
await pinMessageDiscord("chan1", "m1", { rest, token: "t" });
@@ -537,7 +506,7 @@ describe("searchMessagesDiscord", () => {
});
it("uses URLSearchParams for search", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue({ total_results: 0, messages: [] });
await searchMessagesDiscord(
{ guildId: "g1", content: "hello", limit: 5 },
@@ -548,7 +517,7 @@ describe("searchMessagesDiscord", () => {
});
it("supports channel/author arrays and clamps limit", async () => {
const { rest, getMock } = makeRest();
const { rest, getMock } = makeDiscordRest();
getMock.mockResolvedValue({ total_results: 0, messages: [] });
await searchMessagesDiscord(
{

View File

@@ -0,0 +1,41 @@
import { vi } from "vitest";
export function discordWebMediaMockFactory() {
return {
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "photo.jpg",
contentType: "image/jpeg",
kind: "image",
}),
loadWebMediaRaw: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "asset.png",
contentType: "image/png",
kind: "image",
}),
};
}
export function makeDiscordRest() {
const postMock = vi.fn();
const putMock = vi.fn();
const getMock = vi.fn();
const patchMock = vi.fn();
const deleteMock = vi.fn();
return {
rest: {
post: postMock,
put: putMock,
get: getMock,
patch: patchMock,
delete: deleteMock,
} as unknown as import("@buape/carbon").RequestClient,
postMock,
putMock,
getMock,
patchMock,
deleteMock,
};
}