diff --git a/src/discord/send.creates-thread.test.ts b/src/discord/send.creates-thread.test.ts index 8b5994f4c..5e2f5b2d7 100644 --- a/src/discord/send.creates-thread.test.ts +++ b/src/discord/send.creates-thread.test.ts @@ -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); diff --git a/src/discord/send.sends-basic-channel-messages.test.ts b/src/discord/send.sends-basic-channel-messages.test.ts index 1e2ddeaf3..e523cb619 100644 --- a/src/discord/send.sends-basic-channel-messages.test.ts +++ b/src/discord/send.sends-basic-channel-messages.test.ts @@ -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( { diff --git a/src/discord/send.test-harness.ts b/src/discord/send.test-harness.ts new file mode 100644 index 000000000..02474b70d --- /dev/null +++ b/src/discord/send.test-harness.ts @@ -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, + }; +}