mirror of https://github.com/openclaw/openclaw.git
247 lines
7.5 KiB
TypeScript
247 lines
7.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
_teamGroupIdCacheForTest,
|
|
fetchChannelMessage,
|
|
fetchThreadReplies,
|
|
formatThreadContext,
|
|
resolveTeamGroupId,
|
|
stripHtmlFromTeamsMessage,
|
|
} from "./graph-thread.js";
|
|
import { fetchGraphJson } from "./graph.js";
|
|
|
|
vi.mock("./graph.js", () => ({
|
|
fetchGraphJson: vi.fn(),
|
|
}));
|
|
|
|
describe("stripHtmlFromTeamsMessage", () => {
|
|
it("preserves @mention display names from <at> tags", () => {
|
|
expect(stripHtmlFromTeamsMessage("<at>Alice</at> hello")).toBe("@Alice hello");
|
|
});
|
|
|
|
it("strips other HTML tags", () => {
|
|
expect(stripHtmlFromTeamsMessage("<p>Hello <b>world</b></p>")).toBe("Hello world");
|
|
});
|
|
|
|
it("decodes common HTML entities", () => {
|
|
expect(stripHtmlFromTeamsMessage("& <b> "x" 'y' z")).toBe(
|
|
"& <b> \"x\" 'y' z",
|
|
);
|
|
});
|
|
|
|
it("normalizes multiple whitespace to single space", () => {
|
|
expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world");
|
|
});
|
|
|
|
it("handles <at> tags with attributes", () => {
|
|
expect(stripHtmlFromTeamsMessage('<at id="123">Bob</at> please review')).toBe(
|
|
"@Bob please review",
|
|
);
|
|
});
|
|
|
|
it("returns empty string for empty input", () => {
|
|
expect(stripHtmlFromTeamsMessage("")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("resolveTeamGroupId", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(fetchGraphJson).mockReset();
|
|
_teamGroupIdCacheForTest.clear();
|
|
});
|
|
|
|
it("fetches team id from Graph and caches it", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-1" } as never);
|
|
|
|
const result = await resolveTeamGroupId("tok", "team-123");
|
|
expect(result).toBe("group-guid-1");
|
|
expect(fetchGraphJson).toHaveBeenCalledWith({
|
|
token: "tok",
|
|
path: "/teams/team-123?$select=id",
|
|
});
|
|
});
|
|
|
|
it("returns cached value without calling Graph again", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ id: "group-guid-2" } as never);
|
|
|
|
await resolveTeamGroupId("tok", "team-456");
|
|
await resolveTeamGroupId("tok", "team-456");
|
|
|
|
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("falls back to conversationTeamId when Graph returns no id", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
|
|
|
const result = await resolveTeamGroupId("tok", "team-fallback");
|
|
expect(result).toBe("team-fallback");
|
|
});
|
|
});
|
|
|
|
describe("fetchChannelMessage", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(fetchGraphJson).mockReset();
|
|
});
|
|
|
|
it("fetches the parent message with correct path", async () => {
|
|
const mockMsg = { id: "msg-1", body: { content: "hello", contentType: "text" } };
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce(mockMsg as never);
|
|
|
|
const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");
|
|
|
|
expect(result).toEqual(mockMsg);
|
|
expect(fetchGraphJson).toHaveBeenCalledWith({
|
|
token: "tok",
|
|
path: "/teams/group-1/channels/channel-1/messages/msg-1?$select=id,from,body,createdDateTime",
|
|
});
|
|
});
|
|
|
|
it("returns undefined on fetch error", async () => {
|
|
vi.mocked(fetchGraphJson).mockRejectedValueOnce(new Error("forbidden") as never);
|
|
|
|
const result = await fetchChannelMessage("tok", "group-1", "channel-1", "msg-1");
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("URL-encodes group, channel, and message IDs", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
|
|
|
await fetchChannelMessage("tok", "g/1", "c/2", "m/3");
|
|
|
|
expect(fetchGraphJson).toHaveBeenCalledWith({
|
|
token: "tok",
|
|
path: "/teams/g%2F1/channels/c%2F2/messages/m%2F3?$select=id,from,body,createdDateTime",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("fetchThreadReplies", () => {
|
|
beforeEach(() => {
|
|
vi.mocked(fetchGraphJson).mockReset();
|
|
});
|
|
|
|
it("fetches replies with correct path and default limit", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({
|
|
value: [{ id: "reply-1" }, { id: "reply-2" }],
|
|
} as never);
|
|
|
|
const result = await fetchThreadReplies("tok", "group-1", "channel-1", "msg-1");
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(fetchGraphJson).toHaveBeenCalledWith({
|
|
token: "tok",
|
|
path: "/teams/group-1/channels/channel-1/messages/msg-1/replies?$top=50&$select=id,from,body,createdDateTime",
|
|
});
|
|
});
|
|
|
|
it("clamps limit to 50 maximum", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never);
|
|
|
|
await fetchThreadReplies("tok", "g", "c", "m", 200);
|
|
|
|
const path = vi.mocked(fetchGraphJson).mock.calls[0]?.[0]?.path ?? "";
|
|
expect(path).toContain("$top=50");
|
|
});
|
|
|
|
it("clamps limit to 1 minimum", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({ value: [] } as never);
|
|
|
|
await fetchThreadReplies("tok", "g", "c", "m", 0);
|
|
|
|
const path = vi.mocked(fetchGraphJson).mock.calls[0]?.[0]?.path ?? "";
|
|
expect(path).toContain("$top=1");
|
|
});
|
|
|
|
it("returns empty array when value is missing", async () => {
|
|
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
|
|
|
const result = await fetchThreadReplies("tok", "g", "c", "m");
|
|
expect(result).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("formatThreadContext", () => {
|
|
it("formats messages as sender: content lines", () => {
|
|
const messages = [
|
|
{
|
|
id: "m1",
|
|
from: { user: { displayName: "Alice" } },
|
|
body: { content: "Hello!", contentType: "text" },
|
|
},
|
|
{
|
|
id: "m2",
|
|
from: { user: { displayName: "Bob" } },
|
|
body: { content: "World!", contentType: "text" },
|
|
},
|
|
];
|
|
expect(formatThreadContext(messages)).toBe("Alice: Hello!\nBob: World!");
|
|
});
|
|
|
|
it("skips the current message by id", () => {
|
|
const messages = [
|
|
{
|
|
id: "m1",
|
|
from: { user: { displayName: "Alice" } },
|
|
body: { content: "Hello!", contentType: "text" },
|
|
},
|
|
{
|
|
id: "m2",
|
|
from: { user: { displayName: "Bob" } },
|
|
body: { content: "Current", contentType: "text" },
|
|
},
|
|
];
|
|
expect(formatThreadContext(messages, "m2")).toBe("Alice: Hello!");
|
|
});
|
|
|
|
it("strips HTML from html contentType messages", () => {
|
|
const messages = [
|
|
{
|
|
id: "m1",
|
|
from: { user: { displayName: "Carol" } },
|
|
body: { content: "<p>Hello <b>world</b></p>", contentType: "html" },
|
|
},
|
|
];
|
|
expect(formatThreadContext(messages)).toBe("Carol: Hello world");
|
|
});
|
|
|
|
it("uses application displayName when user is absent", () => {
|
|
const messages = [
|
|
{
|
|
id: "m1",
|
|
from: { application: { displayName: "BotApp" } },
|
|
body: { content: "automated msg", contentType: "text" },
|
|
},
|
|
];
|
|
expect(formatThreadContext(messages)).toBe("BotApp: automated msg");
|
|
});
|
|
|
|
it("skips messages with empty content", () => {
|
|
const messages = [
|
|
{
|
|
id: "m1",
|
|
from: { user: { displayName: "Alice" } },
|
|
body: { content: "", contentType: "text" },
|
|
},
|
|
{
|
|
id: "m2",
|
|
from: { user: { displayName: "Bob" } },
|
|
body: { content: "actual content", contentType: "text" },
|
|
},
|
|
];
|
|
expect(formatThreadContext(messages)).toBe("Bob: actual content");
|
|
});
|
|
|
|
it("falls back to 'unknown' sender when from is missing", () => {
|
|
const messages = [
|
|
{
|
|
id: "m1",
|
|
body: { content: "orphan msg", contentType: "text" },
|
|
},
|
|
];
|
|
expect(formatThreadContext(messages)).toBe("unknown: orphan msg");
|
|
});
|
|
|
|
it("returns empty string for empty messages array", () => {
|
|
expect(formatThreadContext([])).toBe("");
|
|
});
|
|
});
|