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 tags", () => { expect(stripHtmlFromTeamsMessage("Alice hello")).toBe("@Alice hello"); }); it("strips other HTML tags", () => { expect(stripHtmlFromTeamsMessage("

Hello world

")).toBe("Hello world"); }); it("decodes common HTML entities", () => { expect(stripHtmlFromTeamsMessage("& <b> "x" 'y'  z")).toBe( "& \"x\" 'y' z", ); }); it("normalizes multiple whitespace to single space", () => { expect(stripHtmlFromTeamsMessage("hello world")).toBe("hello world"); }); it("handles tags with attributes", () => { expect(stripHtmlFromTeamsMessage('Bob 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: "

Hello world

", 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(""); }); });