From 238b31a00c2b0e3da1753bb8596cea0eacea4124 Mon Sep 17 00:00:00 2001 From: martingarramon Date: Wed, 22 Apr 2026 17:06:44 -0300 Subject: [PATCH] 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) --- .../slack/src/send.identity-fallback.test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 extensions/slack/src/send.identity-fallback.test.ts diff --git a/extensions/slack/src/send.identity-fallback.test.ts b/extensions/slack/src/send.identity-fallback.test.ts new file mode 100644 index 00000000000..e356f7a19e9 --- /dev/null +++ b/extensions/slack/src/send.identity-fallback.test.ts @@ -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(); + }); +});