test(slack): cover send.ts customize-scope fallback retry path (#69009)

Adds 5 vitest cases for postSlackMessageBestEffort's silent retry
behavior when Slack rejects a chat:write.customize-identity post:

- Retry on err.data.needed matching chat:write.customize
- Retry on chat:write.customize in response_metadata.acceptedScopes
- Retry on chat:write.customize in response_metadata.scopes
- Rethrow on different missing_scope (e.g. channels:history)
- Rethrow when identity is empty (hasCustomIdentity returns false)
This commit is contained in:
martingarramon
2026-04-22 17:06:44 -03:00
committed by GitHub
parent bc4a097464
commit 238b31a00c

View File

@@ -0,0 +1,150 @@
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSlackSendTestClient, installSlackBlockTestMocks } from "./blocks.test-helpers.js";
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
logVerbose: vi.fn(),
danger: (message: string) => message,
shouldLogVerbose: () => false,
}));
installSlackBlockTestMocks();
const { sendMessageSlack } = await import("./send.js");
type SlackMissingScopeError = Error & {
data?: {
error?: string;
needed?: string;
response_metadata?: { scopes?: string[]; acceptedScopes?: string[] };
};
};
function buildMissingScopeError(overrides?: {
needed?: string;
scopes?: string[];
acceptedScopes?: string[];
}): SlackMissingScopeError {
const err = new Error("missing_scope") as SlackMissingScopeError;
const response_metadata =
overrides?.scopes || overrides?.acceptedScopes
? {
...(overrides?.scopes ? { scopes: overrides.scopes } : {}),
...(overrides?.acceptedScopes ? { acceptedScopes: overrides.acceptedScopes } : {}),
}
: undefined;
err.data = {
error: "missing_scope",
...(overrides?.needed != null ? { needed: overrides.needed } : {}),
...(response_metadata ? { response_metadata } : {}),
};
return err;
}
describe("sendMessageSlack customize-scope fallback", () => {
beforeEach(() => {
vi.mocked(logVerbose).mockClear();
});
it("retries without identity when needed contains chat:write.customize", async () => {
const client = createSlackSendTestClient();
vi.mocked(client.chat.postMessage)
.mockRejectedValueOnce(buildMissingScopeError({ needed: "chat:write.customize" }))
.mockResolvedValueOnce({ ts: "171234.567" });
const result = await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
client,
identity: { username: "Bot", iconUrl: "https://example.com/bot.png" },
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
const [firstCall] = vi.mocked(client.chat.postMessage).mock.calls[0];
const [secondCall] = vi.mocked(client.chat.postMessage).mock.calls[1];
expect(firstCall).toEqual(
expect.objectContaining({
username: "Bot",
icon_url: "https://example.com/bot.png",
}),
);
expect(secondCall).not.toHaveProperty("username");
expect(secondCall).not.toHaveProperty("icon_url");
expect(secondCall).not.toHaveProperty("icon_emoji");
expect(vi.mocked(logVerbose)).toHaveBeenCalledWith(
"slack send: missing chat:write.customize, retrying without custom identity",
);
expect(result.messageId).toBe("171234.567");
});
it("retries when chat:write.customize appears only in response_metadata.acceptedScopes", async () => {
const client = createSlackSendTestClient();
vi.mocked(client.chat.postMessage)
.mockRejectedValueOnce(
buildMissingScopeError({ acceptedScopes: ["chat:write", "chat:write.customize"] }),
)
.mockResolvedValueOnce({ ts: "171234.567" });
await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
client,
identity: { iconEmoji: ":robot_face:" },
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
const [secondCall] = vi.mocked(client.chat.postMessage).mock.calls[1];
expect(secondCall).not.toHaveProperty("icon_emoji");
expect(vi.mocked(logVerbose)).toHaveBeenCalledWith(
"slack send: missing chat:write.customize, retrying without custom identity",
);
});
it("retries when chat:write.customize appears only in response_metadata.scopes", async () => {
const client = createSlackSendTestClient();
vi.mocked(client.chat.postMessage)
.mockRejectedValueOnce(buildMissingScopeError({ scopes: ["chat:write.customize"] }))
.mockResolvedValueOnce({ ts: "171234.567" });
await sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
client,
identity: { username: "Bot" },
});
expect(client.chat.postMessage).toHaveBeenCalledTimes(2);
expect(vi.mocked(logVerbose)).toHaveBeenCalledWith(
"slack send: missing chat:write.customize, retrying without custom identity",
);
});
it("rethrows missing_scope errors that reference a different scope", async () => {
const client = createSlackSendTestClient();
const err = buildMissingScopeError({ needed: "channels:history" });
vi.mocked(client.chat.postMessage).mockRejectedValueOnce(err);
await expect(
sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
client,
identity: { username: "Bot" },
}),
).rejects.toBe(err);
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(vi.mocked(logVerbose)).not.toHaveBeenCalled();
});
it("rethrows customize-scope errors when identity is empty", async () => {
const client = createSlackSendTestClient();
const err = buildMissingScopeError({ needed: "chat:write.customize" });
vi.mocked(client.chat.postMessage).mockRejectedValueOnce(err);
await expect(
sendMessageSlack("channel:C123", "hello", {
token: "xoxb-test",
client,
}),
).rejects.toBe(err);
expect(client.chat.postMessage).toHaveBeenCalledTimes(1);
expect(vi.mocked(logVerbose)).not.toHaveBeenCalled();
});
});