Files
openclaw/extensions/discord/src/internal/command-deploy.test.ts

396 lines
13 KiB
TypeScript

// Discord tests cover command deploy plugin behavior.
/* oxlint-disable typescript/unbound-method -- vitest mocks of RequestClient methods (createRest) intentionally expose vi.fn refs via `restA.get`/`.post`; not unbound class methods. */
import {
ApplicationCommandType,
type APIApplicationCommand,
type APIApplicationCommandOption,
} from "discord-api-types/v10";
import { describe, expect, test, vi } from "vitest";
import type { DiscordCommandDeployHashStore } from "../command-deploy-store.js";
import { commandsEqual } from "./command-comparison.js";
import { DiscordCommandDeployer } from "./command-deploy.js";
import { BaseCommand } from "./commands.js";
import type { RequestClient } from "./rest.js";
/**
* Regression tests for Discord slash-command reconcile/deploy equality.
*
* These protect against a class of bugs where Discord's server-side storage
* normalization causes our desired descriptor to re-compare unequal to the
* command Discord returns, which leads to a spurious `PATCH` on every
* gateway startup and, under the per-application rate limit, a cascade of
* `429` responses that silently drop some commands until the next restart.
*/
describe("commandsEqual", () => {
// Shape of what Discord returns on `GET /applications/{appId}/commands`.
// Fields like `version`, `dm_permission`, `nsfw`, `application_id` are
// always present on the server side but absent from our locally-serialized
// desired descriptors — they must therefore be ignored by the comparator.
function currentFromDiscord(
overrides: Partial<APIApplicationCommand> = {},
): APIApplicationCommand {
return {
id: "cmd-1",
application_id: "app",
type: 1,
name: "ping",
description: "ping the bot",
version: "v1",
default_member_permissions: null,
dm_permission: true,
nsfw: false,
...overrides,
} as APIApplicationCommand;
}
// Shape of what a `BaseCommand.serialize()` produces locally.
function desiredFromLocal(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
name: "ping",
description: "ping the bot",
type: 1,
default_member_permissions: null,
...overrides,
};
}
test("ignores Discord server-side default fields (dm_permission, nsfw, version, id, application_id)", () => {
expect(commandsEqual(currentFromDiscord(), desiredFromLocal())).toBe(true);
});
test("ignores Discord null localization maps when local command omits them", () => {
const current = currentFromDiscord({
name_localizations: null,
description_localizations: null,
options: [
{
type: 3,
name: "name",
name_localizations: null,
description: "Skill name",
description_localizations: null,
} as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
options: [{ name: "name", description: "Skill name", type: 3 }],
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats `required: false` on an option as equivalent to field absent", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [
{ type: 3, name: "name", description: "Skill name" } as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
name: "skill",
description: "Run a skill.",
options: [{ name: "name", description: "Skill name", type: 3, required: false }],
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("keeps `required: true` meaningful", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [
{ type: 3, name: "name", description: "Skill name" } as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
name: "skill",
description: "Run a skill.",
options: [{ name: "name", description: "Skill name", type: 3, required: true }],
});
expect(commandsEqual(current, desired)).toBe(false);
});
test("treats CJK descriptions with `\\n` separators as equal to Discord's collapsed form", () => {
// Discord server collapses whitespace between CJK characters when storing
// command descriptions, so our local desired `\n`-separated description
// round-trips back without the newline.
const current = currentFromDiscord({
description:
"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。支持直接输入 URL。",
});
const desired = desiredFromLocal({
description:
"将任意文本转化为杂志质感 HTML 信息卡片,并自动截图保存为图片。\n支持直接输入 URL。",
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats mixed CJK/ASCII descriptions with consecutive whitespace as equal to collapsed form", () => {
const current = currentFromDiscord({
description: "联网操作策略框架。访问需登录站点时触发。",
});
const desired = desiredFromLocal({
description: "联网操作策略框架。\n\n访问需登录站点时触发。",
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
const current = currentFromDiscord({
description_localizations: {
"zh-CN": "第一行说明。第二行说明。",
},
});
const desired = desiredFromLocal({
description_localizations: {
"zh-CN": "第一行说明。\n第二行说明。",
},
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("treats option localized descriptions with CJK whitespace as equal to Discord's collapsed form", () => {
const current = currentFromDiscord({
name: "skill",
description: "Run a skill.",
options: [
{
type: 3,
name: "name",
description: "Skill name",
description_localizations: { "zh-CN": "技能名称。直接输入。" },
} as APIApplicationCommandOption,
],
});
const desired = desiredFromLocal({
name: "skill",
description: "Run a skill.",
options: [
{
name: "name",
description: "Skill name",
description_localizations: { "zh-CN": "技能名称。\n直接输入。" },
type: 3,
},
],
});
expect(commandsEqual(current, desired)).toBe(true);
});
test("keeps localized substantive description differences meaningful", () => {
const current = currentFromDiscord({
description_localizations: {
"zh-CN": "旧说明",
},
});
const desired = desiredFromLocal({
description_localizations: {
"zh-CN": "新说明",
},
});
expect(commandsEqual(current, desired)).toBe(false);
});
test("keeps substantive description differences meaningful", () => {
const current = currentFromDiscord({ description: "old text" });
const desired = desiredFromLocal({ description: "new text" });
expect(commandsEqual(current, desired)).toBe(false);
});
test("treats ASCII `\\n` as whitespace and collapses it to space for comparison", () => {
// For pure ASCII descriptions, `\n` collapses to a single space so
// "ping the bot" == "ping\nthe bot". The contract is: whitespace
// differences (ASCII or CJK-boundary) are never substantive after
// Discord's server normalization.
const current = currentFromDiscord({ description: "ping the bot" });
const desired = desiredFromLocal({ description: "ping\nthe bot" });
expect(commandsEqual(current, desired)).toBe(true);
});
});
/**
* Regression for #77359: persisted hashes are scoped by Discord application id.
* Identical command sets for separate bots must never suppress each other's deploy.
*/
describe("DiscordCommandDeployer SQLite cache", () => {
class StaticCommand extends BaseCommand {
readonly commandKind = "leaf";
name: string;
override description = "ping the bot";
type = ApplicationCommandType.ChatInput;
constructor(name: string) {
super();
this.name = name;
}
serializeOptions() {
return undefined;
}
}
function createRest(): RequestClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
put: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
} as unknown as RequestClient;
}
function createHashStore(initial: Record<string, string> = {}): {
rows: Map<string, string>;
store: DiscordCommandDeployHashStore;
} {
const rows = new Map(Object.entries(initial));
return {
rows,
store: {
lookup: vi.fn(async (key: string) => rows.get(key)),
register: vi.fn(async (key: string, value: string) => {
rows.set(key, value);
}),
},
};
}
test("two applications with identical command sets each reconcile their own application", async () => {
const { store, rows } = createHashStore();
const commands = [new StaticCommand("ping")];
const restA = createRest();
const restB = createRest();
await Promise.all([
new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStore: store,
rest: () => restA,
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStore: store,
rest: () => restB,
}).deploy({ mode: "reconcile" }),
]);
expect(restA.get).toHaveBeenCalledTimes(1);
expect(restA.post).toHaveBeenCalledTimes(1);
expect(restB.get).toHaveBeenCalledTimes(1);
expect(restB.post).toHaveBeenCalledTimes(1);
expect([...rows.keys()].toSorted()).toEqual([
"app:app-default:global:reconcile",
"app:app-secondary:global:reconcile",
]);
});
test("skips unchanged command deploys across deployer restarts", async () => {
const { store } = createHashStore();
const commands = [new StaticCommand("ping")];
const firstRest = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStore: store,
rest: () => firstRest,
}).deploy({ mode: "reconcile" });
const secondRest = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStore: store,
rest: () => secondRest,
}).deploy({ mode: "reconcile" });
expect(firstRest.get).toHaveBeenCalledTimes(1);
expect(firstRest.post).toHaveBeenCalledTimes(1);
expect(secondRest.get).not.toHaveBeenCalled();
expect(secondRest.post).not.toHaveBeenCalled();
expect(store.lookup).toHaveBeenLastCalledWith("app:app-default:global:reconcile");
});
test("loads only the exact scoped key needed by a deployment", async () => {
const { store } = createHashStore({
"app:other:global:reconcile": "unrelated",
});
const rest = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands: [new StaticCommand("ping")],
hashStore: store,
rest: () => rest,
}).deploy({ mode: "reconcile" });
expect(store.lookup).toHaveBeenCalledOnce();
expect(store.lookup).toHaveBeenCalledWith("app:app-default:global:reconcile");
});
test("treats a SQLite lookup failure as a cache miss and repairs the row", async () => {
const register = vi.fn(async () => undefined);
const store: DiscordCommandDeployHashStore = {
lookup: vi.fn(async () => {
throw new Error("database unavailable");
}),
register,
};
const rest = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands: [new StaticCommand("ping")],
hashStore: store,
rest: () => rest,
}).deploy({ mode: "reconcile" });
expect(rest.get).toHaveBeenCalledTimes(1);
expect(rest.post).toHaveBeenCalledTimes(1);
expect(register).toHaveBeenCalledOnce();
});
test("keeps successful deploys successful when SQLite persistence fails", async () => {
const store: DiscordCommandDeployHashStore = {
lookup: vi.fn(async () => undefined),
register: vi.fn(async () => {
throw new Error("database unavailable");
}),
};
const rest = createRest();
const deployer = new DiscordCommandDeployer({
clientId: "app-default",
commands: [new StaticCommand("ping")],
hashStore: store,
rest: () => rest,
});
await deployer.deploy({ mode: "reconcile" });
await deployer.deploy({ mode: "reconcile" });
expect(rest.get).toHaveBeenCalledTimes(1);
expect(rest.post).toHaveBeenCalledTimes(1);
expect(store.register).toHaveBeenCalledOnce();
});
test("does not persist a hash when Discord deployment fails", async () => {
const { store } = createHashStore();
const rest = createRest();
rest.post = vi.fn(async () => {
throw new Error("Discord rejected deploy");
}) as RequestClient["post"];
await expect(
new DiscordCommandDeployer({
clientId: "app-default",
commands: [new StaticCommand("ping")],
hashStore: store,
rest: () => rest,
}).deploy({ mode: "reconcile" }),
).rejects.toThrow("Discord rejected deploy");
expect(store.register).not.toHaveBeenCalled();
});
});