From acf3ff91e4464b8edb2f2ffd4a4ff5a3f86cef34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 18:54:29 +0000 Subject: [PATCH] refactor: dedupe discord native command test scaffolding --- .../native-command.commands-allowfrom.test.ts | 256 ++++++------------ .../native-command.plugin-dispatch.test.ts | 54 +--- .../monitor/native-command.test-helpers.ts | 60 ++++ 3 files changed, 163 insertions(+), 207 deletions(-) create mode 100644 src/discord/monitor/native-command.test-helpers.ts diff --git a/src/discord/monitor/native-command.commands-allowfrom.test.ts b/src/discord/monitor/native-command.commands-allowfrom.test.ts index e5757846b94..218df22f071 100644 --- a/src/discord/monitor/native-command.commands-allowfrom.test.ts +++ b/src/discord/monitor/native-command.commands-allowfrom.test.ts @@ -1,60 +1,27 @@ -import { ChannelType } from "@buape/carbon"; +import { ChannelType } from "discord-api-types/v10"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js"; import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js"; import type { OpenClawConfig } from "../../config/config.js"; import * as pluginCommandsModule from "../../plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; +import { + createMockCommandInteraction, + type MockCommandInteraction, +} from "./native-command.test-helpers.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; -type MockCommandInteraction = { - user: { id: string; username: string; globalName: string }; - channel: { type: ChannelType; id: string }; - guild: { id: string; name?: string } | null; - rawData: { id: string; member: { roles: string[] } }; - options: { - getString: ReturnType; - getNumber: ReturnType; - getBoolean: ReturnType; - }; - reply: ReturnType; - followUp: ReturnType; - client: object; -}; - -function createInteraction(params?: { - userId?: string; - channelId?: string; - guildId?: string; - guildName?: string; -}): MockCommandInteraction { - return { - user: { - id: params?.userId ?? "123456789012345678", - username: "discord-user", - globalName: "Discord User", - }, - channel: { - type: ChannelType.GuildText, - id: params?.channelId ?? "234567890123456789", - }, - guild: { - id: params?.guildId ?? "345678901234567890", - name: params?.guildName ?? "Test Guild", - }, - rawData: { - id: "interaction-1", - member: { roles: [] }, - }, - options: { - getString: vi.fn().mockReturnValue(null), - getNumber: vi.fn().mockReturnValue(null), - getBoolean: vi.fn().mockReturnValue(null), - }, - reply: vi.fn().mockResolvedValue({ ok: true }), - followUp: vi.fn().mockResolvedValue({ ok: true }), - client: {}, - }; +function createInteraction(params?: { userId?: string }): MockCommandInteraction { + return createMockCommandInteraction({ + userId: params?.userId ?? "123456789012345678", + username: "discord-user", + globalName: "Discord User", + channelType: ChannelType.GuildText, + channelId: "234567890123456789", + guildId: "345678901234567890", + guildName: "Test Guild", + interactionId: "interaction-1", + }); } function createConfig(): OpenClawConfig { @@ -99,147 +66,102 @@ function createCommand(cfg: OpenClawConfig) { }); } +function createDispatchSpy() { + return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({ + counts: { + final: 1, + block: 0, + tool: 0, + }, + } as never); +} + +async function runGuildSlashCommand(params?: { + userId?: string; + mutateConfig?: (cfg: OpenClawConfig) => void; +}) { + const cfg = createConfig(); + params?.mutateConfig?.(cfg); + const command = createCommand(cfg); + const interaction = createInteraction({ userId: params?.userId }); + vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); + const dispatchSpy = createDispatchSpy(); + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + return { dispatchSpy, interaction }; +} + +function expectNotUnauthorizedReply(interaction: MockCommandInteraction) { + expect(interaction.reply).not.toHaveBeenCalledWith( + expect.objectContaining({ content: "You are not authorized to use this command." }), + ); +} + +function expectUnauthorizedReply(interaction: MockCommandInteraction) { + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: "You are not authorized to use this command.", + ephemeral: true, + }), + ); +} + describe("Discord native slash commands with commands.allowFrom", () => { beforeEach(() => { vi.restoreAllMocks(); }); it("authorizes guild slash commands when commands.allowFrom.discord matches the sender", async () => { - const cfg = createConfig(); - const command = createCommand(cfg); - const interaction = createInteraction(); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - + const { dispatchSpy, interaction } = await runGuildSlashCommand(); expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(interaction.reply).not.toHaveBeenCalledWith( - expect.objectContaining({ content: "You are not authorized to use this command." }), - ); + expectNotUnauthorizedReply(interaction); }); it("authorizes guild slash commands from the global commands.allowFrom list when provider-specific allowFrom is missing", async () => { - const cfg = createConfig(); - cfg.commands = { - allowFrom: { - "*": ["user:123456789012345678"], + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.commands = { + allowFrom: { + "*": ["user:123456789012345678"], + }, + }; }, - }; - const command = createCommand(cfg); - const interaction = createInteraction(); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - + }); expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(interaction.reply).not.toHaveBeenCalledWith( - expect.objectContaining({ content: "You are not authorized to use this command." }), - ); + expectNotUnauthorizedReply(interaction); }); it("authorizes guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord matches the sender", async () => { - const cfg = createConfig(); - cfg.commands = { - ...cfg.commands, - useAccessGroups: false, - }; - const command = createCommand(cfg); - const interaction = createInteraction(); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + mutateConfig: (cfg) => { + cfg.commands = { + ...cfg.commands, + useAccessGroups: false, + }; + }, + }); expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(interaction.reply).not.toHaveBeenCalledWith( - expect.objectContaining({ content: "You are not authorized to use this command." }), - ); + expectNotUnauthorizedReply(interaction); }); it("rejects guild slash commands when commands.allowFrom.discord does not match the sender", async () => { - const cfg = createConfig(); - const command = createCommand(cfg); - const interaction = createInteraction({ userId: "999999999999999999" }); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + userId: "999999999999999999", + }); expect(dispatchSpy).not.toHaveBeenCalled(); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: "You are not authorized to use this command.", - ephemeral: true, - }), - ); + expectUnauthorizedReply(interaction); }); it("rejects guild slash commands when commands.useAccessGroups is false and commands.allowFrom.discord does not match the sender", async () => { - const cfg = createConfig(); - cfg.commands = { - ...cfg.commands, - useAccessGroups: false, - }; - const command = createCommand(cfg); - const interaction = createInteraction({ userId: "999999999999999999" }); - - vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null); - const dispatchSpy = vi - .spyOn(dispatcherModule, "dispatchReplyWithDispatcher") - .mockResolvedValue({ - counts: { - final: 1, - block: 0, - tool: 0, - }, - } as never); - - await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); - + const { dispatchSpy, interaction } = await runGuildSlashCommand({ + userId: "999999999999999999", + mutateConfig: (cfg) => { + cfg.commands = { + ...cfg.commands, + useAccessGroups: false, + }; + }, + }); expect(dispatchSpy).not.toHaveBeenCalled(); - expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ - content: "You are not authorized to use this command.", - ephemeral: true, - }), - ); + expectUnauthorizedReply(interaction); }); }); diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts index 291c6d45bba..c7e81afe298 100644 --- a/src/discord/monitor/native-command.plugin-dispatch.test.ts +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -5,6 +5,10 @@ import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js import type { OpenClawConfig } from "../../config/config.js"; import * as pluginCommandsModule from "../../plugins/commands.js"; import { createDiscordNativeCommand } from "./native-command.js"; +import { + createMockCommandInteraction, + type MockCommandInteraction, +} from "./native-command.test-helpers.js"; import { createNoopThreadBindingManager } from "./thread-bindings.js"; type ResolveConfiguredAcpBindingRecordFn = @@ -29,52 +33,22 @@ vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => { }; }); -type MockCommandInteraction = { - user: { id: string; username: string; globalName: string }; - channel: { type: ChannelType; id: string }; - guild: { id: string; name?: string } | null; - rawData: { id: string; member: { roles: string[] } }; - options: { - getString: ReturnType; - getNumber: ReturnType; - getBoolean: ReturnType; - }; - reply: ReturnType; - followUp: ReturnType; - client: object; -}; - function createInteraction(params?: { channelType?: ChannelType; channelId?: string; guildId?: string; guildName?: string; }): MockCommandInteraction { - const guild = params?.guildId ? { id: params.guildId, name: params.guildName } : null; - return { - user: { - id: "owner", - username: "tester", - globalName: "Tester", - }, - channel: { - type: params?.channelType ?? ChannelType.DM, - id: params?.channelId ?? "dm-1", - }, - guild, - rawData: { - id: "interaction-1", - member: { roles: [] }, - }, - options: { - getString: vi.fn().mockReturnValue(null), - getNumber: vi.fn().mockReturnValue(null), - getBoolean: vi.fn().mockReturnValue(null), - }, - reply: vi.fn().mockResolvedValue({ ok: true }), - followUp: vi.fn().mockResolvedValue({ ok: true }), - client: {}, - }; + return createMockCommandInteraction({ + userId: "owner", + username: "tester", + globalName: "Tester", + channelType: params?.channelType ?? ChannelType.DM, + channelId: params?.channelId ?? "dm-1", + guildId: params?.guildId ?? null, + guildName: params?.guildName, + interactionId: "interaction-1", + }); } function createConfig(): OpenClawConfig { diff --git a/src/discord/monitor/native-command.test-helpers.ts b/src/discord/monitor/native-command.test-helpers.ts new file mode 100644 index 00000000000..fe6ab6e1252 --- /dev/null +++ b/src/discord/monitor/native-command.test-helpers.ts @@ -0,0 +1,60 @@ +import { ChannelType } from "discord-api-types/v10"; +import { vi } from "vitest"; + +export type MockCommandInteraction = { + user: { id: string; username: string; globalName: string }; + channel: { type: ChannelType; id: string }; + guild: { id: string; name?: string } | null; + rawData: { id: string; member: { roles: string[] } }; + options: { + getString: ReturnType; + getNumber: ReturnType; + getBoolean: ReturnType; + }; + reply: ReturnType; + followUp: ReturnType; + client: object; +}; + +type CreateMockCommandInteractionParams = { + userId?: string; + username?: string; + globalName?: string; + channelType?: ChannelType; + channelId?: string; + guildId?: string | null; + guildName?: string; + interactionId?: string; +}; + +export function createMockCommandInteraction( + params: CreateMockCommandInteractionParams = {}, +): MockCommandInteraction { + const guildId = params.guildId; + const guild = + guildId === null || guildId === undefined ? null : { id: guildId, name: params.guildName }; + return { + user: { + id: params.userId ?? "owner", + username: params.username ?? "tester", + globalName: params.globalName ?? "Tester", + }, + channel: { + type: params.channelType ?? ChannelType.DM, + id: params.channelId ?? "dm-1", + }, + guild, + rawData: { + id: params.interactionId ?? "interaction-1", + member: { roles: [] }, + }, + options: { + getString: vi.fn().mockReturnValue(null), + getNumber: vi.fn().mockReturnValue(null), + getBoolean: vi.fn().mockReturnValue(null), + }, + reply: vi.fn().mockResolvedValue({ ok: true }), + followUp: vi.fn().mockResolvedValue({ ok: true }), + client: {}, + }; +}