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("" );
});
});
Messung V0.5 in Prozent C=99 H=98 G=98
¤ Dauer der Verarbeitung: 0.20 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland