Files
openclaw/extensions/msteams/src/channel.actions.test.ts
2026-03-27 01:04:01 +00:00

403 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const {
editMessageMSTeamsMock,
deleteMessageMSTeamsMock,
getMessageMSTeamsMock,
listReactionsMSTeamsMock,
pinMessageMSTeamsMock,
reactMessageMSTeamsMock,
searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeamsMock,
sendMessageMSTeamsMock,
unpinMessageMSTeamsMock,
} = vi.hoisted(() => ({
editMessageMSTeamsMock: vi.fn(),
deleteMessageMSTeamsMock: vi.fn(),
getMessageMSTeamsMock: vi.fn(),
listReactionsMSTeamsMock: vi.fn(),
pinMessageMSTeamsMock: vi.fn(),
reactMessageMSTeamsMock: vi.fn(),
searchMessagesMSTeamsMock: vi.fn(),
sendAdaptiveCardMSTeamsMock: vi.fn(),
sendMessageMSTeamsMock: vi.fn(),
unpinMessageMSTeamsMock: vi.fn(),
}));
vi.mock("./channel.runtime.js", () => ({
msTeamsChannelRuntime: {
editMessageMSTeams: editMessageMSTeamsMock,
deleteMessageMSTeams: deleteMessageMSTeamsMock,
getMessageMSTeams: getMessageMSTeamsMock,
listReactionsMSTeams: listReactionsMSTeamsMock,
pinMessageMSTeams: pinMessageMSTeamsMock,
reactMessageMSTeams: reactMessageMSTeamsMock,
searchMessagesMSTeams: searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeams: sendAdaptiveCardMSTeamsMock,
sendMessageMSTeams: sendMessageMSTeamsMock,
unpinMessageMSTeams: unpinMessageMSTeamsMock,
},
}));
import { msteamsPlugin } from "./channel.js";
const actionMocks = [
editMessageMSTeamsMock,
deleteMessageMSTeamsMock,
getMessageMSTeamsMock,
listReactionsMSTeamsMock,
pinMessageMSTeamsMock,
reactMessageMSTeamsMock,
searchMessagesMSTeamsMock,
sendAdaptiveCardMSTeamsMock,
sendMessageMSTeamsMock,
unpinMessageMSTeamsMock,
];
const currentChannelId = "conversation:19:ctx@thread.tacv2";
const reactChannelId = "conversation:19:react@thread.tacv2";
const targetChannelId = "conversation:19:target@thread.tacv2";
const editedConversationId = "19:edited@thread.tacv2";
const editedMessageId = "msg-edit-1";
const readMessage = { id: "msg-1", text: "hello" };
const reactionType = "like";
const updatedText = "updated text";
const reactionTypes = ["like", "heart", "laugh", "surprised", "sad", "angry"];
const deleteMissingTargetError = "Delete requires a target (to) and messageId.";
const reactionsMissingTargetError = "Reactions requires a target (to) and messageId.";
const cardSendMissingTargetError = "Card send requires a target (to).";
const reactMissingEmojiError =
"React requires an emoji (reaction type). Valid types: like, heart, laugh, surprised, sad, angry.";
const reactMissingEmojiDetail = "React requires an emoji (reaction type).";
const searchMissingQueryError = "Search requires a target (to) and query.";
function padded(value: string) {
return ` ${value} `;
}
function msteamsActionDetails(action: string, details?: Record<string, unknown>) {
return {
channel: "msteams",
action,
...details,
};
}
function okMSTeamsActionDetails(action: string, details?: Record<string, unknown>) {
return msteamsActionDetails(action, { ok: true, ...details });
}
function requireMSTeamsHandleAction() {
const handleAction = msteamsPlugin.actions?.handleAction;
if (!handleAction) {
throw new Error("msteams actions.handleAction unavailable");
}
return handleAction;
}
async function runAction(params: {
action: string;
cfg?: Record<string, unknown>;
params?: Record<string, unknown>;
toolContext?: Record<string, unknown>;
mediaLocalRoots?: readonly string[];
}) {
const handleAction = requireMSTeamsHandleAction();
return await handleAction({
channel: "msteams",
action: params.action,
cfg: params.cfg ?? {},
params: params.params ?? {},
mediaLocalRoots: params.mediaLocalRoots,
toolContext: params.toolContext,
} as any);
}
async function expectActionError(
params: Parameters<typeof runAction>[0],
expectedMessage: string,
expectedDetails?: Record<string, unknown>,
) {
await expect(runAction(params)).resolves.toEqual({
isError: true,
content: [{ type: "text", text: expectedMessage }],
details: expectedDetails ?? { error: expectedMessage },
});
}
async function expectActionParamError(
action: Parameters<typeof runAction>[0]["action"],
params: Record<string, unknown>,
expectedMessage: string,
expectedDetails?: Record<string, unknown>,
) {
await expectActionError({ action, params }, expectedMessage, expectedDetails);
}
function expectActionSuccess(
result: Awaited<ReturnType<typeof runAction>>,
details: Record<string, unknown>,
contentDetails: Record<string, unknown> = details,
) {
expect(result).toEqual({
content: [
{
type: "text",
text: JSON.stringify(contentDetails),
},
],
details,
});
}
function expectActionRuntimeCall(
mockFn: ReturnType<typeof vi.fn>,
params: Record<string, unknown>,
) {
expect(mockFn).toHaveBeenCalledWith({
cfg: {},
...params,
});
}
async function expectSuccessfulAction(params: {
mockFn: ReturnType<typeof vi.fn>;
mockResult: unknown;
action: Parameters<typeof runAction>[0]["action"];
actionParams?: Parameters<typeof runAction>[0]["params"];
toolContext?: Parameters<typeof runAction>[0]["toolContext"];
mediaLocalRoots?: Parameters<typeof runAction>[0]["mediaLocalRoots"];
runtimeParams: Record<string, unknown>;
details: Record<string, unknown>;
contentDetails?: Record<string, unknown>;
}) {
params.mockFn.mockResolvedValue(params.mockResult);
const result = await runAction({
action: params.action,
params: params.actionParams,
mediaLocalRoots: params.mediaLocalRoots,
toolContext: params.toolContext,
});
expectActionRuntimeCall(params.mockFn, params.runtimeParams);
expectActionSuccess(result, params.details, params.contentDetails);
}
describe("msteamsPlugin message actions", () => {
beforeEach(() => {
for (const mockFn of actionMocks) {
mockFn.mockReset();
}
});
it("falls back to toolContext.currentChannelId for read actions", async () => {
await expectSuccessfulAction({
mockFn: getMessageMSTeamsMock,
mockResult: readMessage,
action: "read",
actionParams: {
messageId: padded("msg-1"),
},
toolContext: {
currentChannelId: padded(currentChannelId),
},
runtimeParams: {
to: currentChannelId,
messageId: "msg-1",
},
details: okMSTeamsActionDetails("read", {
message: readMessage,
}),
contentDetails: {
ok: true,
channel: "msteams",
action: "read",
message: readMessage,
},
});
});
it("advertises upload-file in the message tool surface", () => {
expect(
msteamsPlugin.actions?.describeMessageTool?.({
cfg: {
channels: {
msteams: {
appId: "app-id",
appPassword: "secret",
tenantId: "tenant-id",
},
},
} as any,
})?.actions,
).toContain("upload-file");
});
it("routes upload-file through sendMessageMSTeams with filename override", async () => {
await expectSuccessfulAction({
mockFn: sendMessageMSTeamsMock,
mockResult: {
messageId: "msg-upload-1",
conversationId: "conv-upload-1",
},
action: "upload-file",
actionParams: {
target: padded(targetChannelId),
path: " /tmp/report.pdf ",
message: "Quarterly report",
filename: "Q1-report.pdf",
},
mediaLocalRoots: ["/tmp"],
runtimeParams: {
to: targetChannelId,
text: "Quarterly report",
mediaUrl: " /tmp/report.pdf ",
filename: "Q1-report.pdf",
mediaLocalRoots: ["/tmp"],
},
details: {
ok: true,
channel: "msteams",
messageId: "msg-upload-1",
},
contentDetails: {
ok: true,
channel: "msteams",
action: "upload-file",
messageId: "msg-upload-1",
conversationId: "conv-upload-1",
},
});
});
it("accepts target as an alias for pin actions", async () => {
await expectSuccessfulAction({
mockFn: pinMessageMSTeamsMock,
mockResult: { ok: true, pinnedMessageId: "pin-1" },
action: "pin",
actionParams: {
target: padded(targetChannelId),
messageId: padded("msg-2"),
},
runtimeParams: {
to: targetChannelId,
messageId: "msg-2",
},
details: okMSTeamsActionDetails("pin", {
pinnedMessageId: "pin-1",
}),
});
});
it("falls back from content to message fields for edit actions", async () => {
await expectSuccessfulAction({
mockFn: editMessageMSTeamsMock,
mockResult: { conversationId: editedConversationId },
action: "edit",
actionParams: {
to: targetChannelId,
messageId: editedMessageId,
content: updatedText,
},
runtimeParams: {
to: targetChannelId,
activityId: editedMessageId,
text: updatedText,
},
details: {
ok: true,
channel: "msteams",
},
contentDetails: {
ok: true,
channel: "msteams",
conversationId: editedConversationId,
},
});
});
it("falls back from pinnedMessageId to messageId for unpin actions", async () => {
await expectSuccessfulAction({
mockFn: unpinMessageMSTeamsMock,
mockResult: { ok: true },
action: "unpin",
actionParams: {
target: padded(targetChannelId),
messageId: padded("pin-2"),
},
runtimeParams: {
to: targetChannelId,
pinnedMessageId: "pin-2",
},
details: okMSTeamsActionDetails("unpin"),
});
});
it("reuses currentChannelId fallback for react actions", async () => {
await expectSuccessfulAction({
mockFn: reactMessageMSTeamsMock,
mockResult: { ok: true },
action: "react",
actionParams: {
messageId: padded("msg-3"),
emoji: padded(reactionType),
},
toolContext: {
currentChannelId: padded(reactChannelId),
},
runtimeParams: {
to: reactChannelId,
messageId: "msg-3",
reactionType,
},
details: okMSTeamsActionDetails("react", {
reactionType,
}),
contentDetails: {
channel: "msteams",
action: "react",
reactionType,
ok: true,
},
});
});
it("shares the missing target and messageId validation across actions", async () => {
await expectActionParamError("delete", {}, deleteMissingTargetError);
await expectActionParamError("reactions", { to: targetChannelId }, reactionsMissingTargetError);
});
it("keeps card-send target validation shared", async () => {
await expectActionParamError(
"send",
{ card: { type: "AdaptiveCard" } },
cardSendMissingTargetError,
);
});
it("reports the allowed reaction types when emoji is missing", async () => {
await expectActionParamError(
"react",
{
to: targetChannelId,
messageId: "msg-4",
},
reactMissingEmojiError,
{
error: reactMissingEmojiDetail,
validTypes: reactionTypes,
},
);
});
it("requires a non-empty search query after trimming", async () => {
await expectActionParamError(
"search",
{
to: targetChannelId,
query: " ",
},
searchMissingQueryError,
);
});
});