mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-21 05:32:53 +00:00
perf(msteams): split graph message tests
This commit is contained in:
266
extensions/msteams/src/graph-messages.actions.test.ts
Normal file
266
extensions/msteams/src/graph-messages.actions.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
pinMessageMSTeams,
|
||||
reactMessageMSTeams,
|
||||
unpinMessageMSTeams,
|
||||
unreactMessageMSTeams,
|
||||
} from "./graph-messages.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
resolveGraphToken: vi.fn(),
|
||||
fetchGraphJson: vi.fn(),
|
||||
postGraphJson: vi.fn(),
|
||||
postGraphBetaJson: vi.fn(),
|
||||
deleteGraphRequest: vi.fn(),
|
||||
findPreferredDmByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGraphToken: mockState.resolveGraphToken,
|
||||
fetchGraphJson: mockState.fetchGraphJson,
|
||||
postGraphJson: mockState.postGraphJson,
|
||||
postGraphBetaJson: mockState.postGraphBetaJson,
|
||||
deleteGraphRequest: mockState.deleteGraphRequest,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./conversation-store-fs.js", () => ({
|
||||
createMSTeamsConversationStoreFs: () => ({
|
||||
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TOKEN = "test-graph-token";
|
||||
const CHAT_ID = "19:abc@thread.tacv2";
|
||||
const CHANNEL_TO = "team-id-1/channel-id-1";
|
||||
|
||||
describe("pinMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("pins a message in a chat", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({ id: "pinned-1" });
|
||||
|
||||
const result = await pinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, pinnedMessageId: "pinned-1" });
|
||||
expect(mockState.postGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages`,
|
||||
body: { message: { id: "msg-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("pins a message in a channel", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({});
|
||||
|
||||
const result = await pinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/pinnedMessages",
|
||||
body: { message: { id: "msg-2" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unpinMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("unpins a message from a chat", async () => {
|
||||
mockState.deleteGraphRequest.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unpinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
pinnedMessageId: "pinned-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages/pinned-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("unpins a message from a channel", async () => {
|
||||
mockState.deleteGraphRequest.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unpinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
pinnedMessageId: "pinned-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/pinnedMessages/pinned-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("sets a like reaction on a chat message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "like",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
|
||||
body: { reactionType: "like" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a reaction on a channel message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
reactionType: "heart",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/setReaction",
|
||||
body: { reactionType: "heart" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes reaction type to lowercase", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "LAUGH",
|
||||
});
|
||||
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
|
||||
body: { reactionType: "laugh" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid reaction type", async () => {
|
||||
await expect(
|
||||
reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "thumbsup",
|
||||
}),
|
||||
).rejects.toThrow('Invalid reaction type "thumbsup"');
|
||||
});
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-user-1",
|
||||
messageId: "msg-1",
|
||||
reactionType: "like",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages/msg-1/setReaction`,
|
||||
body: { reactionType: "like" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreactMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("removes a reaction from a chat message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "sad",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`,
|
||||
body: { reactionType: "sad" },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a reaction from a channel message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
reactionType: "angry",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/unsetReaction",
|
||||
body: { reactionType: "angry" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid reaction type", async () => {
|
||||
await expect(
|
||||
unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "clap",
|
||||
}),
|
||||
).rejects.toThrow('Invalid reaction type "clap"');
|
||||
});
|
||||
});
|
||||
312
extensions/msteams/src/graph-messages.read.test.ts
Normal file
312
extensions/msteams/src/graph-messages.read.test.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { getMessageMSTeams, listPinsMSTeams, listReactionsMSTeams } from "./graph-messages.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
resolveGraphToken: vi.fn(),
|
||||
fetchGraphJson: vi.fn(),
|
||||
postGraphJson: vi.fn(),
|
||||
postGraphBetaJson: vi.fn(),
|
||||
deleteGraphRequest: vi.fn(),
|
||||
findPreferredDmByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGraphToken: mockState.resolveGraphToken,
|
||||
fetchGraphJson: mockState.fetchGraphJson,
|
||||
postGraphJson: mockState.postGraphJson,
|
||||
postGraphBetaJson: mockState.postGraphBetaJson,
|
||||
deleteGraphRequest: mockState.deleteGraphRequest,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./conversation-store-fs.js", () => ({
|
||||
createMSTeamsConversationStoreFs: () => ({
|
||||
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TOKEN = "test-graph-token";
|
||||
const CHAT_ID = "19:abc@thread.tacv2";
|
||||
const CHANNEL_TO = "team-id-1/channel-id-1";
|
||||
|
||||
describe("getMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("resolves user: target using graphChatId from store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-framework-dm-id",
|
||||
reference: { graphChatId: "19:graph-native-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "From user DM" },
|
||||
createdDateTime: "2026-03-23T12:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-object-id-123",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-object-id-123");
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:graph-native-chat@thread.tacv2")}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to conversationId when it starts with 19:", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "19:resolved-chat@thread.tacv2",
|
||||
reference: {},
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello" },
|
||||
createdDateTime: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-id",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:resolved-chat@thread.tacv2")}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when user: target has no stored conversation", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:unknown-user",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).rejects.toThrow("No conversation found for user:unknown-user");
|
||||
});
|
||||
|
||||
it("throws when user: target has Bot Framework ID and no graphChatId", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-framework-dm-id",
|
||||
reference: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:some-user",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).rejects.toThrow("Bot Framework ID");
|
||||
});
|
||||
|
||||
it("strips conversation: prefix from target", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello" },
|
||||
from: undefined,
|
||||
createdDateTime: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: `conversation:${CHAT_ID}`,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a message from a chat conversation", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello world", contentType: "text" },
|
||||
from: { user: { id: "user-1", displayName: "Alice" } },
|
||||
createdDateTime: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
|
||||
const result = await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "msg-1",
|
||||
text: "Hello world",
|
||||
from: { user: { id: "user-1", displayName: "Alice" } },
|
||||
createdAt: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a message from a channel conversation", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-2",
|
||||
body: { content: "Channel message" },
|
||||
from: { application: { id: "app-1", displayName: "Bot" } },
|
||||
createdDateTime: "2026-03-23T11:00:00Z",
|
||||
});
|
||||
|
||||
const result = await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "msg-2",
|
||||
text: "Channel message",
|
||||
from: { application: { id: "app-1", displayName: "Bot" } },
|
||||
createdAt: "2026-03-23T11:00:00Z",
|
||||
});
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPinsMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("lists pinned messages in a chat", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "pinned-1",
|
||||
message: { id: "msg-1", body: { content: "Pinned msg" } },
|
||||
},
|
||||
{
|
||||
id: "pinned-2",
|
||||
message: { id: "msg-2", body: { content: "Another pin" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listPinsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
});
|
||||
|
||||
expect(result.pins).toEqual([
|
||||
{ id: "pinned-1", pinnedMessageId: "pinned-1", messageId: "msg-1", text: "Pinned msg" },
|
||||
{ id: "pinned-2", pinnedMessageId: "pinned-2", messageId: "msg-2", text: "Another pin" },
|
||||
]);
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages?$expand=message`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no pins exist", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await listPinsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
});
|
||||
|
||||
expect(result.pins).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listReactionsMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("lists reactions grouped by type with user details", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello" },
|
||||
reactions: [
|
||||
{ reactionType: "like", user: { id: "u1", displayName: "Alice" } },
|
||||
{ reactionType: "like", user: { id: "u2", displayName: "Bob" } },
|
||||
{ reactionType: "heart", user: { id: "u1", displayName: "Alice" } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listReactionsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result.reactions).toEqual([
|
||||
{
|
||||
reactionType: "like",
|
||||
count: 2,
|
||||
users: [
|
||||
{ id: "u1", displayName: "Alice" },
|
||||
{ id: "u2", displayName: "Bob" },
|
||||
],
|
||||
},
|
||||
{
|
||||
reactionType: "heart",
|
||||
count: 1,
|
||||
users: [{ id: "u1", displayName: "Alice" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when message has no reactions", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "No reactions" },
|
||||
});
|
||||
|
||||
const result = await listReactionsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result.reactions).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches from channel path for channel targets", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-2",
|
||||
body: { content: "Channel msg" },
|
||||
reactions: [{ reactionType: "surprised", user: { id: "u3", displayName: "Carol" } }],
|
||||
});
|
||||
|
||||
const result = await listReactionsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
});
|
||||
|
||||
expect(result.reactions).toEqual([
|
||||
{ reactionType: "surprised", count: 1, users: [{ id: "u3", displayName: "Carol" }] },
|
||||
]);
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
234
extensions/msteams/src/graph-messages.search.test.ts
Normal file
234
extensions/msteams/src/graph-messages.search.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { searchMessagesMSTeams } from "./graph-messages.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
resolveGraphToken: vi.fn(),
|
||||
fetchGraphJson: vi.fn(),
|
||||
postGraphJson: vi.fn(),
|
||||
postGraphBetaJson: vi.fn(),
|
||||
deleteGraphRequest: vi.fn(),
|
||||
findPreferredDmByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGraphToken: mockState.resolveGraphToken,
|
||||
fetchGraphJson: mockState.fetchGraphJson,
|
||||
postGraphJson: mockState.postGraphJson,
|
||||
postGraphBetaJson: mockState.postGraphBetaJson,
|
||||
deleteGraphRequest: mockState.deleteGraphRequest,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./conversation-store-fs.js", () => ({
|
||||
createMSTeamsConversationStoreFs: () => ({
|
||||
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TOKEN = "test-graph-token";
|
||||
const CHAT_ID = "19:abc@thread.tacv2";
|
||||
const CHANNEL_TO = "team-id-1/channel-id-1";
|
||||
|
||||
describe("searchMessagesMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("searches chat messages with query string", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "msg-1",
|
||||
body: { content: "Meeting notes from Monday" },
|
||||
from: { user: { id: "u1", displayName: "Alice" } },
|
||||
createdDateTime: "2026-03-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "meeting notes",
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "Meeting notes from Monday",
|
||||
from: { user: { id: "u1", displayName: "Alice" } },
|
||||
createdAt: "2026-03-25T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain(`/chats/${encodeURIComponent(CHAT_ID)}/messages?`);
|
||||
expect(calledPath).toContain("$search=");
|
||||
expect(calledPath).toContain("$top=25");
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain('$search="meeting notes"');
|
||||
});
|
||||
|
||||
it("searches channel messages", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "msg-2",
|
||||
body: { content: "Sprint review" },
|
||||
from: { user: { id: "u2", displayName: "Bob" } },
|
||||
createdDateTime: "2026-03-25T11:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
query: "sprint",
|
||||
});
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("/teams/team-id-1/channels/channel-id-1/messages?");
|
||||
});
|
||||
|
||||
it("applies limit parameter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=10");
|
||||
});
|
||||
|
||||
it("clamps limit to max 50", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=50");
|
||||
});
|
||||
|
||||
it("clamps limit to min 1", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=1");
|
||||
});
|
||||
|
||||
it("applies from filter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "budget",
|
||||
from: "Alice",
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$filter=");
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain("from/user/displayName eq 'Alice'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in from filter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
from: "O'Brien",
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain("O''Brien");
|
||||
});
|
||||
|
||||
it("strips double quotes from query to prevent injection", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: 'say "hello" world',
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain('$search="say hello world"');
|
||||
expect(decoded).not.toContain('""');
|
||||
});
|
||||
|
||||
it("passes ConsistencyLevel: eventual header", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no messages match", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "nonexistent",
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-user-1",
|
||||
query: "hello",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain(
|
||||
`/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,750 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
getMessageMSTeams,
|
||||
listPinsMSTeams,
|
||||
listReactionsMSTeams,
|
||||
pinMessageMSTeams,
|
||||
reactMessageMSTeams,
|
||||
searchMessagesMSTeams,
|
||||
unpinMessageMSTeams,
|
||||
unreactMessageMSTeams,
|
||||
} from "./graph-messages.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
resolveGraphToken: vi.fn(),
|
||||
fetchGraphJson: vi.fn(),
|
||||
postGraphJson: vi.fn(),
|
||||
postGraphBetaJson: vi.fn(),
|
||||
deleteGraphRequest: vi.fn(),
|
||||
findPreferredDmByUserId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./graph.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./graph.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGraphToken: mockState.resolveGraphToken,
|
||||
fetchGraphJson: mockState.fetchGraphJson,
|
||||
postGraphJson: mockState.postGraphJson,
|
||||
postGraphBetaJson: mockState.postGraphBetaJson,
|
||||
deleteGraphRequest: mockState.deleteGraphRequest,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./conversation-store-fs.js", () => ({
|
||||
createMSTeamsConversationStoreFs: () => ({
|
||||
findPreferredDmByUserId: mockState.findPreferredDmByUserId,
|
||||
}),
|
||||
}));
|
||||
|
||||
const TOKEN = "test-graph-token";
|
||||
const CHAT_ID = "19:abc@thread.tacv2";
|
||||
const CHANNEL_TO = "team-id-1/channel-id-1";
|
||||
|
||||
describe("getMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("resolves user: target using graphChatId from store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-framework-dm-id",
|
||||
reference: { graphChatId: "19:graph-native-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "From user DM" },
|
||||
createdDateTime: "2026-03-23T12:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-object-id-123",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-object-id-123");
|
||||
// Must use the graphChatId, not the Bot Framework conversation ID
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:graph-native-chat@thread.tacv2")}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to conversationId when it starts with 19:", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "19:resolved-chat@thread.tacv2",
|
||||
reference: {},
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello" },
|
||||
createdDateTime: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-id",
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:resolved-chat@thread.tacv2")}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when user: target has no stored conversation", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:unknown-user",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).rejects.toThrow("No conversation found for user:unknown-user");
|
||||
});
|
||||
|
||||
it("throws when user: target has Bot Framework ID and no graphChatId", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-framework-dm-id",
|
||||
reference: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:some-user",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).rejects.toThrow("Bot Framework ID");
|
||||
});
|
||||
|
||||
it("strips conversation: prefix from target", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello" },
|
||||
from: undefined,
|
||||
createdDateTime: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
|
||||
await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: `conversation:${CHAT_ID}`,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a message from a chat conversation", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello world", contentType: "text" },
|
||||
from: { user: { id: "user-1", displayName: "Alice" } },
|
||||
createdDateTime: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
|
||||
const result = await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "msg-1",
|
||||
text: "Hello world",
|
||||
from: { user: { id: "user-1", displayName: "Alice" } },
|
||||
createdAt: "2026-03-23T10:00:00Z",
|
||||
});
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a message from a channel conversation", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-2",
|
||||
body: { content: "Channel message" },
|
||||
from: { application: { id: "app-1", displayName: "Bot" } },
|
||||
createdDateTime: "2026-03-23T11:00:00Z",
|
||||
});
|
||||
|
||||
const result = await getMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "msg-2",
|
||||
text: "Channel message",
|
||||
from: { application: { id: "app-1", displayName: "Bot" } },
|
||||
createdAt: "2026-03-23T11:00:00Z",
|
||||
});
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("pins a message in a chat", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({ id: "pinned-1" });
|
||||
|
||||
const result = await pinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true, pinnedMessageId: "pinned-1" });
|
||||
expect(mockState.postGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages`,
|
||||
body: { message: { id: "msg-1" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("pins a message in a channel", async () => {
|
||||
mockState.postGraphJson.mockResolvedValue({});
|
||||
|
||||
const result = await pinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/pinnedMessages",
|
||||
body: { message: { id: "msg-2" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unpinMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("unpins a message from a chat", async () => {
|
||||
mockState.deleteGraphRequest.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unpinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
pinnedMessageId: "pinned-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages/pinned-1`,
|
||||
});
|
||||
});
|
||||
|
||||
it("unpins a message from a channel", async () => {
|
||||
mockState.deleteGraphRequest.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unpinMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
pinnedMessageId: "pinned-2",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.deleteGraphRequest).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/pinnedMessages/pinned-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listPinsMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("lists pinned messages in a chat", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "pinned-1",
|
||||
message: { id: "msg-1", body: { content: "Pinned msg" } },
|
||||
},
|
||||
{
|
||||
id: "pinned-2",
|
||||
message: { id: "msg-2", body: { content: "Another pin" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listPinsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
});
|
||||
|
||||
expect(result.pins).toEqual([
|
||||
{ id: "pinned-1", pinnedMessageId: "pinned-1", messageId: "msg-1", text: "Pinned msg" },
|
||||
{ id: "pinned-2", pinnedMessageId: "pinned-2", messageId: "msg-2", text: "Another pin" },
|
||||
]);
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/pinnedMessages?$expand=message`,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no pins exist", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await listPinsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
});
|
||||
|
||||
expect(result.pins).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("sets a like reaction on a chat message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "like",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
|
||||
body: { reactionType: "like" },
|
||||
});
|
||||
});
|
||||
|
||||
it("sets a reaction on a channel message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
reactionType: "heart",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/setReaction",
|
||||
body: { reactionType: "heart" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes reaction type to lowercase", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "LAUGH",
|
||||
});
|
||||
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`,
|
||||
body: { reactionType: "laugh" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid reaction type", async () => {
|
||||
await expect(
|
||||
reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "thumbsup",
|
||||
}),
|
||||
).rejects.toThrow('Invalid reaction type "thumbsup"');
|
||||
});
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
await reactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-user-1",
|
||||
messageId: "msg-1",
|
||||
reactionType: "like",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages/msg-1/setReaction`,
|
||||
body: { reactionType: "like" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreactMessageMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("removes a reaction from a chat message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "sad",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`,
|
||||
body: { reactionType: "sad" },
|
||||
});
|
||||
});
|
||||
|
||||
it("removes a reaction from a channel message", async () => {
|
||||
mockState.postGraphBetaJson.mockResolvedValue(undefined);
|
||||
|
||||
const result = await unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
reactionType: "angry",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/unsetReaction",
|
||||
body: { reactionType: "angry" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid reaction type", async () => {
|
||||
await expect(
|
||||
unreactMessageMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
reactionType: "clap",
|
||||
}),
|
||||
).rejects.toThrow('Invalid reaction type "clap"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("listReactionsMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("lists reactions grouped by type with user details", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "Hello" },
|
||||
reactions: [
|
||||
{ reactionType: "like", user: { id: "u1", displayName: "Alice" } },
|
||||
{ reactionType: "like", user: { id: "u2", displayName: "Bob" } },
|
||||
{ reactionType: "heart", user: { id: "u1", displayName: "Alice" } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await listReactionsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result.reactions).toEqual([
|
||||
{
|
||||
reactionType: "like",
|
||||
count: 2,
|
||||
users: [
|
||||
{ id: "u1", displayName: "Alice" },
|
||||
{ id: "u2", displayName: "Bob" },
|
||||
],
|
||||
},
|
||||
{
|
||||
reactionType: "heart",
|
||||
count: 1,
|
||||
users: [{ id: "u1", displayName: "Alice" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when message has no reactions", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-1",
|
||||
body: { content: "No reactions" },
|
||||
});
|
||||
|
||||
const result = await listReactionsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(result.reactions).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches from channel path for channel targets", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
id: "msg-2",
|
||||
body: { content: "Channel msg" },
|
||||
reactions: [{ reactionType: "surprised", user: { id: "u3", displayName: "Carol" } }],
|
||||
});
|
||||
|
||||
const result = await listReactionsMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
messageId: "msg-2",
|
||||
});
|
||||
|
||||
expect(result.reactions).toEqual([
|
||||
{ reactionType: "surprised", count: 1, users: [{ id: "u3", displayName: "Carol" }] },
|
||||
]);
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith({
|
||||
token: TOKEN,
|
||||
path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchMessagesMSTeams", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.resolveGraphToken.mockResolvedValue(TOKEN);
|
||||
});
|
||||
|
||||
it("searches chat messages with query string", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "msg-1",
|
||||
body: { content: "Meeting notes from Monday" },
|
||||
from: { user: { id: "u1", displayName: "Alice" } },
|
||||
createdDateTime: "2026-03-25T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "meeting notes",
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "Meeting notes from Monday",
|
||||
from: { user: { id: "u1", displayName: "Alice" } },
|
||||
createdAt: "2026-03-25T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain(`/chats/${encodeURIComponent(CHAT_ID)}/messages?`);
|
||||
expect(calledPath).toContain("$search=");
|
||||
expect(calledPath).toContain("$top=25");
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain('$search="meeting notes"');
|
||||
});
|
||||
|
||||
it("searches channel messages", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({
|
||||
value: [
|
||||
{
|
||||
id: "msg-2",
|
||||
body: { content: "Sprint review" },
|
||||
from: { user: { id: "u2", displayName: "Bob" } },
|
||||
createdDateTime: "2026-03-25T11:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHANNEL_TO,
|
||||
query: "sprint",
|
||||
});
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("/teams/team-id-1/channels/channel-id-1/messages?");
|
||||
});
|
||||
|
||||
it("applies limit parameter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=10");
|
||||
});
|
||||
|
||||
it("clamps limit to max 50", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=50");
|
||||
});
|
||||
|
||||
it("clamps limit to min 1", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$top=1");
|
||||
});
|
||||
|
||||
it("applies from filter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "budget",
|
||||
from: "Alice",
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain("$filter=");
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain("from/user/displayName eq 'Alice'");
|
||||
});
|
||||
|
||||
it("escapes single quotes in from filter", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
from: "O'Brien",
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain("O''Brien");
|
||||
});
|
||||
|
||||
it("strips double quotes from query to prevent injection", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: 'say "hello" world',
|
||||
});
|
||||
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
const decoded = decodeURIComponent(calledPath);
|
||||
expect(decoded).toContain('$search="say hello world"');
|
||||
// No unbalanced/injected quotes
|
||||
expect(decoded).not.toContain('""');
|
||||
});
|
||||
|
||||
it("passes ConsistencyLevel: eventual header", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "test",
|
||||
});
|
||||
|
||||
expect(mockState.fetchGraphJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: { ConsistencyLevel: "eventual" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array when no messages match", async () => {
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
const result = await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: CHAT_ID,
|
||||
query: "nonexistent",
|
||||
});
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves user: target through conversation store", async () => {
|
||||
mockState.findPreferredDmByUserId.mockResolvedValue({
|
||||
conversationId: "a:bot-id",
|
||||
reference: { graphChatId: "19:dm-chat@thread.tacv2" },
|
||||
});
|
||||
mockState.fetchGraphJson.mockResolvedValue({ value: [] });
|
||||
|
||||
await searchMessagesMSTeams({
|
||||
cfg: {} as OpenClawConfig,
|
||||
to: "user:aad-user-1",
|
||||
query: "hello",
|
||||
});
|
||||
|
||||
expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1");
|
||||
const calledPath = mockState.fetchGraphJson.mock.calls[0][0].path as string;
|
||||
expect(calledPath).toContain(
|
||||
`/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user