diff --git a/src/slack/monitor.test-helpers.ts b/src/slack/monitor.test-helpers.ts index 151eb58711..17b868fa97 100644 --- a/src/slack/monitor.test-helpers.ts +++ b/src/slack/monitor.test-helpers.ts @@ -34,6 +34,7 @@ type SlackClient = { conversations: { info: Mock<(...args: unknown[]) => Promise>>; replies: Mock<(...args: unknown[]) => Promise>>; + history: Mock<(...args: unknown[]) => Promise>>; }; users: { info: Mock<(...args: unknown[]) => Promise<{ user: { profile: { display_name: string } } }>>; @@ -197,6 +198,7 @@ vi.mock("@slack/bolt", () => { channel: { name: "dm", is_im: true }, }), replies: vi.fn().mockResolvedValue({ messages: [] }), + history: vi.fn().mockResolvedValue({ messages: [] }), }, users: { info: vi.fn().mockResolvedValue({ diff --git a/src/slack/monitor.threading.missing-thread-ts.test.ts b/src/slack/monitor.threading.missing-thread-ts.test.ts index 57e0d7ff01..69117616a4 100644 --- a/src/slack/monitor.threading.missing-thread-ts.test.ts +++ b/src/slack/monitor.threading.missing-thread-ts.test.ts @@ -1,122 +1,75 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; +import { + flush, + getSlackClient, + getSlackHandlerOrThrow, + getSlackTestState, + resetSlackTestState, + startSlackMonitor, + stopSlackMonitor, +} from "./monitor.test-helpers.js"; const { monitorSlackProvider } = await import("./monitor.js"); -const sendMock = vi.fn(); -const replyMock = vi.fn(); -const updateLastRouteMock = vi.fn(); -const reactMock = vi.fn(); -let config: Record = {}; -const readAllowFromStoreMock = vi.fn(); -const upsertPairingRequestMock = vi.fn(); -const getSlackHandlers = () => - ( - globalThis as { - __slackHandlers?: Map Promise>; - } - ).__slackHandlers; -const getSlackClient = () => - (globalThis as { __slackClient?: Record }).__slackClient; +const slackTestState = getSlackTestState(); -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +type SlackConversationsClient = { + history: ReturnType; + info: ReturnType; +}; + +function makeThreadReplyEvent() { return { - ...actual, - loadConfig: () => config, - }; -}); - -vi.mock("../auto-reply/reply.js", () => ({ - getReplyFromConfig: (...args: unknown[]) => replyMock(...args), -})); - -vi.mock("./resolve-channels.js", () => ({ - resolveSlackChannelAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./resolve-users.js", () => ({ - resolveSlackUserAllowlist: async ({ entries }: { entries: string[] }) => - entries.map((input) => ({ input, resolved: false })), -})); - -vi.mock("./send.js", () => ({ - sendMessageSlack: (...args: unknown[]) => sendMock(...args), -})); - -vi.mock("../pairing/pairing-store.js", () => ({ - readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args), - upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), -})); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), - updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), - resolveSessionKey: vi.fn(), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@slack/bolt", () => { - const handlers = new Map Promise>(); - (globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers; - const client = { - auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) }, - conversations: { - info: vi.fn().mockResolvedValue({ - channel: { name: "general", is_channel: true }, - }), - replies: vi.fn().mockResolvedValue({ messages: [] }), - history: vi.fn().mockResolvedValue({ messages: [] }), - }, - users: { - info: vi.fn().mockResolvedValue({ - user: { profile: { display_name: "Ada" } }, - }), - }, - assistant: { - threads: { - setStatus: vi.fn().mockResolvedValue({ ok: true }), - }, - }, - reactions: { - add: (...args: unknown[]) => reactMock(...args), + event: { + type: "message", + user: "U1", + text: "hello", + ts: "456", + parent_user_id: "U2", + channel: "C1", + channel_type: "channel", }, }; - (globalThis as { __slackClient?: typeof client }).__slackClient = client; - class App { - client = client; - event(name: string, handler: (args: unknown) => Promise) { - handlers.set(name, handler); - } - command() { - /* no-op */ - } - start = vi.fn().mockResolvedValue(undefined); - stop = vi.fn().mockResolvedValue(undefined); - } - class HTTPReceiver { - requestListener = vi.fn(); - } - return { App, HTTPReceiver, default: { App, HTTPReceiver } }; -}); +} -const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); - -async function waitForEvent(name: string) { - for (let i = 0; i < 10; i += 1) { - if (getSlackHandlers()?.has(name)) { - return; - } - await flush(); +function getConversationsClient(): SlackConversationsClient { + const client = getSlackClient(); + if (!client) { + throw new Error("Slack client not registered"); } + return client.conversations as SlackConversationsClient; +} + +async function runMissingThreadScenario(params: { + historyResponse?: { messages: Array<{ ts?: string; thread_ts?: string }> }; + historyError?: Error; +}) { + slackTestState.replyMock.mockResolvedValue({ text: "thread reply" }); + + const conversations = getConversationsClient(); + if (params.historyError) { + conversations.history.mockRejectedValueOnce(params.historyError); + } else { + conversations.history.mockResolvedValueOnce( + params.historyResponse ?? { messages: [{ ts: "456" }] }, + ); + } + + const { controller, run } = startSlackMonitor(monitorSlackProvider); + const handler = await getSlackHandlerOrThrow("message"); + await handler(makeThreadReplyEvent()); + + await flush(); + await stopSlackMonitor({ controller, run }); + + expect(slackTestState.sendMock).toHaveBeenCalledTimes(1); + return slackTestState.sendMock.mock.calls[0]?.[2]; } beforeEach(() => { resetInboundDedupe(); - getSlackHandlers()?.clear(); - config = { + resetSlackTestState({ messages: { responsePrefix: "PFX" }, channels: { slack: { @@ -125,60 +78,32 @@ beforeEach(() => { channels: { C1: { allow: true, requireMention: false } }, }, }, - }; - sendMock.mockReset().mockResolvedValue(undefined); - replyMock.mockReset(); - updateLastRouteMock.mockReset(); - reactMock.mockReset(); - readAllowFromStoreMock.mockReset().mockResolvedValue([]); - upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true }); + }); + const conversations = getConversationsClient(); + conversations.info.mockResolvedValue({ + channel: { name: "general", is_channel: true }, + }); }); describe("monitorSlackProvider threading", () => { it("recovers missing thread_ts when parent_user_id is present", async () => { - replyMock.mockResolvedValue({ text: "thread reply" }); - - const client = getSlackClient(); - if (!client) { - throw new Error("Slack client not registered"); - } - const conversations = client.conversations as { - history: ReturnType; - }; - conversations.history.mockResolvedValueOnce({ - messages: [{ ts: "456", thread_ts: "111.222" }], + const options = await runMissingThreadScenario({ + historyResponse: { messages: [{ ts: "456", thread_ts: "111.222" }] }, }); + expect(options).toMatchObject({ threadTs: "111.222" }); + }); - const controller = new AbortController(); - const run = monitorSlackProvider({ - botToken: "bot-token", - appToken: "app-token", - abortSignal: controller.signal, + it("continues without thread_ts when history lookup returns no thread result", async () => { + const options = await runMissingThreadScenario({ + historyResponse: { messages: [{ ts: "456" }] }, }); + expect(options).not.toMatchObject({ threadTs: "111.222" }); + }); - await waitForEvent("message"); - const handler = getSlackHandlers()?.get("message"); - if (!handler) { - throw new Error("Slack message handler not registered"); - } - - await handler({ - event: { - type: "message", - user: "U1", - text: "hello", - ts: "456", - parent_user_id: "U2", - channel: "C1", - channel_type: "channel", - }, + it("continues without thread_ts when history lookup throws", async () => { + const options = await runMissingThreadScenario({ + historyError: new Error("history failed"), }); - - await flush(); - controller.abort(); - await run; - - expect(sendMock).toHaveBeenCalledTimes(1); - expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs: "111.222" }); + expect(options).not.toMatchObject({ threadTs: "111.222" }); }); });