From d00ab060483849f79a04516694ed6c9b08056cff Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Apr 2026 03:21:21 +0100 Subject: [PATCH] test(msteams): share thread handler test setup --- .../message-handler.test-support.ts | 103 +++++++++++++ .../message-handler.thread-parent.test.ts | 135 +++-------------- .../message-handler.thread-session.test.ts | 141 +++--------------- 3 files changed, 142 insertions(+), 237 deletions(-) create mode 100644 extensions/msteams/src/monitor-handler/message-handler.test-support.ts diff --git a/extensions/msteams/src/monitor-handler/message-handler.test-support.ts b/extensions/msteams/src/monitor-handler/message-handler.test-support.ts new file mode 100644 index 00000000000..383c92a80b1 --- /dev/null +++ b/extensions/msteams/src/monitor-handler/message-handler.test-support.ts @@ -0,0 +1,103 @@ +import { vi } from "vitest"; +import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js"; +import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; +import { setMSTeamsRuntime } from "../runtime.js"; + +export const channelConversationId = "19:general@thread.tacv2"; + +export function createMessageHandlerDeps(cfg: OpenClawConfig) { + const enqueueSystemEvent = vi.fn(); + const recordInboundSession = vi.fn(async (_params: { sessionKey: string }) => undefined); + const resolveAgentRoute = vi.fn(({ peer }: { peer: { kind: string; id: string } }) => ({ + sessionKey: `agent:main:msteams:${peer.kind}:${peer.id}`, + agentId: "main", + accountId: "default", + mainSessionKey: "agent:main:main", + lastRoutePolicy: "session" as const, + matchedBy: "default" as const, + })); + + setMSTeamsRuntime({ + logging: { shouldLogVerbose: () => false }, + system: { enqueueSystemEvent }, + channel: { + debounce: { + resolveInboundDebounceMs: () => 0, + createInboundDebouncer: (params: { + onFlush: (entries: T[]) => Promise; + }): { enqueue: (entry: T) => Promise } => ({ + enqueue: async (entry: T) => { + await params.onFlush([entry]); + }, + }), + }, + pairing: { + readAllowFromStore: vi.fn(async () => []), + upsertPairingRequest: vi.fn(async () => null), + }, + text: { + hasControlCommand: () => false, + resolveTextChunkLimit: () => 4000, + }, + routing: { resolveAgentRoute }, + reply: { + formatAgentEnvelope: ({ body }: { body: string }) => body, + finalizeInboundContext: >(ctx: T) => ctx, + }, + session: { + recordInboundSession, + resolveStorePath: () => "/tmp/test-store", + }, + }, + } as unknown as PluginRuntime); + + const deps: MSTeamsMessageHandlerDeps = { + cfg, + runtime: { error: vi.fn() } as unknown as RuntimeEnv, + appId: "test-app", + adapter: {} as MSTeamsMessageHandlerDeps["adapter"], + tokenProvider: { + getAccessToken: vi.fn(async () => "token"), + }, + textLimit: 4000, + mediaMaxBytes: 1024 * 1024, + conversationStore: { + get: vi.fn(async () => null), + upsert: vi.fn(async () => undefined), + list: vi.fn(async () => []), + remove: vi.fn(async () => false), + findPreferredDmByUserId: vi.fn(async () => null), + findByUserId: vi.fn(async () => null), + } satisfies MSTeamsMessageHandlerDeps["conversationStore"], + pollStore: { + recordVote: vi.fn(async () => null), + } as unknown as MSTeamsMessageHandlerDeps["pollStore"], + log: { + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + } as unknown as MSTeamsMessageHandlerDeps["log"], + }; + + return { + deps, + enqueueSystemEvent, + recordInboundSession, + resolveAgentRoute, + }; +} + +export function buildChannelActivity(overrides: Record = {}) { + return { + id: "msg-1", + type: "message", + text: "hello", + from: { id: "user-id", aadObjectId: "user-aad", name: "Test User" }, + recipient: { id: "bot-id", name: "Bot" }, + conversation: { id: channelConversationId, conversationType: "channel" }, + channelData: { team: { id: "team-1" } }, + attachments: [], + entities: [{ type: "mention", mentioned: { id: "bot-id" } }], + ...overrides, + }; +} diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts index d4bec1a2306..dd2dc006738 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-parent.test.ts @@ -1,9 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js"; -import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; -import { setMSTeamsRuntime } from "../runtime.js"; +import type { OpenClawConfig } from "../../runtime-api.js"; import { _resetThreadParentContextCachesForTest } from "../thread-parent-context.js"; import { createMSTeamsMessageHandler } from "./message-handler.js"; +import { + buildChannelActivity, + channelConversationId, + createMessageHandlerDeps, +} from "./message-handler.test-support.js"; const runtimeApiMockState = vi.hoisted(() => ({ dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({ @@ -46,100 +49,6 @@ vi.mock("../reply-dispatcher.js", () => ({ })); describe("msteams thread parent context injection", () => { - const channelConversationId = "19:general@thread.tacv2"; - - function createDeps(cfg: OpenClawConfig) { - const enqueueSystemEvent = vi.fn(); - const recordInboundSession = vi.fn(async (_params: { sessionKey: string }) => undefined); - const resolveAgentRoute = vi.fn(({ peer }: { peer: { kind: string; id: string } }) => ({ - sessionKey: `agent:main:msteams:${peer.kind}:${peer.id}`, - agentId: "main", - accountId: "default", - mainSessionKey: "agent:main:main", - lastRoutePolicy: "session" as const, - matchedBy: "default" as const, - })); - - setMSTeamsRuntime({ - logging: { shouldLogVerbose: () => false }, - system: { enqueueSystemEvent }, - channel: { - debounce: { - resolveInboundDebounceMs: () => 0, - createInboundDebouncer: (params: { - onFlush: (entries: T[]) => Promise; - }): { enqueue: (entry: T) => Promise } => ({ - enqueue: async (entry: T) => { - await params.onFlush([entry]); - }, - }), - }, - pairing: { - readAllowFromStore: vi.fn(async () => []), - upsertPairingRequest: vi.fn(async () => null), - }, - text: { - hasControlCommand: () => false, - resolveTextChunkLimit: () => 4000, - }, - routing: { resolveAgentRoute }, - reply: { - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext: >(ctx: T) => ctx, - }, - session: { - recordInboundSession, - resolveStorePath: () => "/tmp/test-store", - }, - }, - } as unknown as PluginRuntime); - - const deps: MSTeamsMessageHandlerDeps = { - cfg, - runtime: { error: vi.fn() } as unknown as RuntimeEnv, - appId: "test-app", - adapter: {} as MSTeamsMessageHandlerDeps["adapter"], - tokenProvider: { - getAccessToken: vi.fn(async () => "token"), - }, - textLimit: 4000, - mediaMaxBytes: 1024 * 1024, - conversationStore: { - get: vi.fn(async () => null), - upsert: vi.fn(async () => undefined), - list: vi.fn(async () => []), - remove: vi.fn(async () => false), - findPreferredDmByUserId: vi.fn(async () => null), - findByUserId: vi.fn(async () => null), - } satisfies MSTeamsMessageHandlerDeps["conversationStore"], - pollStore: { - recordVote: vi.fn(async () => null), - } as unknown as MSTeamsMessageHandlerDeps["pollStore"], - log: { - info: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - } as unknown as MSTeamsMessageHandlerDeps["log"], - }; - - return { deps, enqueueSystemEvent }; - } - - function channelActivity(overrides: Record = {}) { - return { - id: "msg-1", - type: "message", - text: "hello", - from: { id: "user-id", aadObjectId: "user-aad", name: "Test User" }, - recipient: { id: "bot-id", name: "Bot" }, - conversation: { id: channelConversationId, conversationType: "channel" }, - channelData: { team: { id: "team-1" } }, - attachments: [], - entities: [{ type: "mention", mentioned: { id: "bot-id" } }], - ...overrides, - }; - } - function findParentSystemEventCall( mock: ReturnType, ): [string, { sessionKey: string; contextKey?: string }] | undefined { @@ -167,11 +76,11 @@ describe("msteams thread parent context injection", () => { from: { user: { displayName: "Alice", id: "alice-id" } }, body: { content: "Can someone investigate the latency spike?", contentType: "text" }, }); - const { deps, enqueueSystemEvent } = createDeps(cfg); + const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), + activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -188,16 +97,16 @@ describe("msteams thread parent context injection", () => { from: { user: { displayName: "Alice" } }, body: { content: "Original question", contentType: "text" }, }); - const { deps } = createDeps(cfg); + const { deps } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), + activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); await handler({ - activity: channelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }), + activity: buildChannelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -211,16 +120,16 @@ describe("msteams thread parent context injection", () => { from: { user: { displayName: "Alice" } }, body: { content: "Original question", contentType: "text" }, }); - const { deps, enqueueSystemEvent } = createDeps(cfg); + const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), + activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); await handler({ - activity: channelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }), + activity: buildChannelActivity({ id: "msg-reply-2", replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -236,7 +145,7 @@ describe("msteams thread parent context injection", () => { from: { user: { displayName: "Mallory", id: "mallory-aad" } }, body: { content: "Blocked context", contentType: "text" }, }); - const { deps, enqueueSystemEvent } = createDeps({ + const { deps, enqueueSystemEvent } = createMessageHandlerDeps({ channels: { msteams: { groupPolicy: "allowlist", @@ -255,7 +164,7 @@ describe("msteams thread parent context injection", () => { const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: channelActivity({ + activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123", from: { id: "alice-id", aadObjectId: "alice-aad", name: "Alice" }, @@ -268,11 +177,11 @@ describe("msteams thread parent context injection", () => { it("handles Graph failure gracefully without throwing or emitting a parent event", async () => { fetchChannelMessageMock.mockRejectedValueOnce(new Error("graph down")); - const { deps, enqueueSystemEvent } = createDeps(cfg); + const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: channelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), + activity: buildChannelActivity({ id: "msg-reply-1", replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -288,14 +197,14 @@ describe("msteams thread parent context injection", () => { from: { user: { displayName: "Alice" } }, body: { content: "should-not-happen", contentType: "text" }, }); - const { deps, enqueueSystemEvent } = createDeps({ + const { deps, enqueueSystemEvent } = createMessageHandlerDeps({ channels: { msteams: { allowFrom: ["*"] } }, } as OpenClawConfig); const handler = createMSTeamsMessageHandler(deps); await handler({ activity: { - ...channelActivity(), + ...buildChannelActivity(), conversation: { id: "a:dm-conversation", conversationType: "personal" }, channelData: {}, replyToId: "dm-parent", @@ -314,11 +223,11 @@ describe("msteams thread parent context injection", () => { from: { user: { displayName: "Alice" } }, body: { content: "should-not-happen", contentType: "text" }, }); - const { deps, enqueueSystemEvent } = createDeps(cfg); + const { deps, enqueueSystemEvent } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: channelActivity({ id: "msg-root-1", replyToId: undefined }), + activity: buildChannelActivity({ id: "msg-root-1", replyToId: undefined }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); diff --git a/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts b/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts index 80679a99be4..b04f5519df1 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.thread-session.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js"; -import type { MSTeamsConversationStore } from "../conversation-store.js"; -import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.js"; -import { setMSTeamsRuntime } from "../runtime.js"; +import type { OpenClawConfig } from "../../runtime-api.js"; import { createMSTeamsMessageHandler } from "./message-handler.js"; +import { + buildChannelActivity, + channelConversationId, + createMessageHandlerDeps, +} from "./message-handler.test-support.js"; const runtimeApiMockState = vi.hoisted(() => ({ dispatchReplyFromConfigWithSettledDispatcher: vi.fn(async (params: { ctxPayload: unknown }) => ({ @@ -42,125 +44,16 @@ vi.mock("../reply-dispatcher.js", () => ({ })); describe("msteams thread session isolation", () => { - const channelConversationId = "19:general@thread.tacv2"; - - function createDeps(cfg: OpenClawConfig) { - const recordInboundSession = vi.fn(async (_params: { sessionKey: string }) => undefined); - const resolveAgentRoute = vi.fn(({ peer }: { peer: { kind: string; id: string } }) => ({ - sessionKey: `agent:main:msteams:${peer.kind}:${peer.id}`, - agentId: "main", - accountId: "default", - mainSessionKey: "agent:main:main", - lastRoutePolicy: "session" as const, - matchedBy: "default" as const, - })); - - setMSTeamsRuntime({ - logging: { shouldLogVerbose: () => false }, - system: { enqueueSystemEvent: vi.fn() }, - channel: { - debounce: { - resolveInboundDebounceMs: () => 0, - createInboundDebouncer: (params: { - onFlush: (entries: T[]) => Promise; - }): { enqueue: (entry: T) => Promise } => ({ - enqueue: async (entry: T) => { - await params.onFlush([entry]); - }, - }), - }, - pairing: { - readAllowFromStore: vi.fn(async () => []), - upsertPairingRequest: vi.fn(async () => null), - }, - text: { - hasControlCommand: () => false, - resolveTextChunkLimit: () => 4000, - }, - routing: { - resolveAgentRoute, - }, - reply: { - formatAgentEnvelope: ({ body }: { body: string }) => body, - finalizeInboundContext: >(ctx: T) => ctx, - }, - session: { - recordInboundSession, - resolveStorePath: () => "/tmp/test-store", - }, - }, - } as unknown as PluginRuntime); - - const deps: MSTeamsMessageHandlerDeps = { - cfg, - runtime: { error: vi.fn() } as unknown as RuntimeEnv, - appId: "test-app", - adapter: {} as MSTeamsMessageHandlerDeps["adapter"], - tokenProvider: { - getAccessToken: vi.fn(async () => "token"), - }, - textLimit: 4000, - mediaMaxBytes: 1024 * 1024, - conversationStore: { - get: vi.fn(async () => null), - upsert: vi.fn(async () => undefined), - list: vi.fn(async () => []), - remove: vi.fn(async () => false), - findPreferredDmByUserId: vi.fn(async () => null), - findByUserId: vi.fn(async () => null), - } satisfies MSTeamsConversationStore, - pollStore: { - recordVote: vi.fn(async () => null), - } as unknown as MSTeamsMessageHandlerDeps["pollStore"], - log: { - info: vi.fn(), - debug: vi.fn(), - error: vi.fn(), - } as unknown as MSTeamsMessageHandlerDeps["log"], - }; - - return { - deps, - recordInboundSession, - resolveAgentRoute, - }; - } - - function buildActivity(overrides: Record = {}) { - return { - id: "msg-1", - type: "message", - text: "hello", - from: { - id: "user-id", - aadObjectId: "user-aad", - name: "Test User", - }, - recipient: { - id: "bot-id", - name: "Bot", - }, - conversation: { - id: channelConversationId, - conversationType: "channel", - }, - channelData: { team: { id: "team-1" } }, - attachments: [], - entities: [{ type: "mention", mentioned: { id: "bot-id" } }], - ...overrides, - }; - } - it("appends thread suffix to session key for channel thread replies", async () => { const cfg: OpenClawConfig = { channels: { msteams: { groupPolicy: "open" } }, } as OpenClawConfig; - const { deps, recordInboundSession } = createDeps(cfg); + const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); // Thread reply: has replyToId pointing to the thread root await handler({ - activity: buildActivity({ replyToId: "thread-root-123" }), + activity: buildChannelActivity({ replyToId: "thread-root-123" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -174,12 +67,12 @@ describe("msteams thread session isolation", () => { const cfg: OpenClawConfig = { channels: { msteams: { groupPolicy: "open" } }, } as OpenClawConfig; - const { deps, recordInboundSession } = createDeps(cfg); + const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); // Top-level channel message: no replyToId await handler({ - activity: buildActivity({ replyToId: undefined }), + activity: buildChannelActivity({ replyToId: undefined }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -193,16 +86,16 @@ describe("msteams thread session isolation", () => { const cfg: OpenClawConfig = { channels: { msteams: { groupPolicy: "open" } }, } as OpenClawConfig; - const { deps, recordInboundSession } = createDeps(cfg); + const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ - activity: buildActivity({ id: "msg-1", replyToId: "thread-A" }), + activity: buildChannelActivity({ id: "msg-1", replyToId: "thread-A" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); await handler({ - activity: buildActivity({ id: "msg-2", replyToId: "thread-B" }), + activity: buildChannelActivity({ id: "msg-2", replyToId: "thread-B" }), sendActivity: vi.fn(async () => undefined), } as unknown as Parameters[0]); @@ -218,12 +111,12 @@ describe("msteams thread session isolation", () => { const cfg: OpenClawConfig = { channels: { msteams: { allowFrom: ["*"] } }, } as OpenClawConfig; - const { deps, recordInboundSession } = createDeps(cfg); + const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ activity: { - ...buildActivity(), + ...buildChannelActivity(), conversation: { id: "a:dm-conversation", conversationType: "personal", @@ -244,12 +137,12 @@ describe("msteams thread session isolation", () => { const cfg: OpenClawConfig = { channels: { msteams: { groupPolicy: "open" } }, } as OpenClawConfig; - const { deps, recordInboundSession } = createDeps(cfg); + const { deps, recordInboundSession } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); await handler({ activity: { - ...buildActivity(), + ...buildChannelActivity(), conversation: { id: "19:group-chat-id@unq.gbl.spaces", conversationType: "groupChat",