refactor: dedupe discord native command test scaffolding

This commit is contained in:
Peter Steinberger
2026-03-07 18:54:29 +00:00
parent 0848a47c97
commit acf3ff91e4
3 changed files with 163 additions and 207 deletions

View File

@@ -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<typeof vi.fn>;
getNumber: ReturnType<typeof vi.fn>;
getBoolean: ReturnType<typeof vi.fn>;
};
reply: ReturnType<typeof vi.fn>;
followUp: ReturnType<typeof vi.fn>;
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<void> }).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<void> }).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<void> }).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<void> }).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<void> }).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<void> }).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);
});
});

View File

@@ -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<typeof vi.fn>;
getNumber: ReturnType<typeof vi.fn>;
getBoolean: ReturnType<typeof vi.fn>;
};
reply: ReturnType<typeof vi.fn>;
followUp: ReturnType<typeof vi.fn>;
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 {

View File

@@ -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<typeof vi.fn>;
getNumber: ReturnType<typeof vi.fn>;
getBoolean: ReturnType<typeof vi.fn>;
};
reply: ReturnType<typeof vi.fn>;
followUp: ReturnType<typeof vi.fn>;
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: {},
};
}