import { afterEach, beforeEach, describe, expect, it, vi } from
"vitest" ;
const {
loadMSTeamsSdkWithAuthMock,
createMSTeamsTokenProviderMock,
readAccessTokenMock,
resolveMSTeamsCredentialsMock,
} = vi.hoisted(() => {
return {
loadMSTeamsSdkWithAuthMock: vi.fn(),
createMSTeamsTokenProviderMock: vi.fn(),
readAccessTokenMock: vi.fn(),
resolveMSTeamsCredentialsMock: vi.fn(),
};
});
vi.mock(
"./sdk.js" , () => ({
loadMSTeamsSdkWithAuth: loadMSTeamsSdkWithAuthMock,
createMSTeamsTokenProvider: createMSTeamsTokenProviderMock,
}));
vi.mock(
"./token-response.js" , () => ({
readAccessToken: readAccessTokenMock,
}));
vi.mock(
"./token.js" , () => ({
resolveMSTeamsCredentials: resolveMSTeamsCredentialsMock,
}));
import { searchGraphUsers } from
"./graph-users.js" ;
import {
deleteGraphRequest,
escapeOData,
fetchAllGraphPages,
fetchGraphJson,
listChannelsForTeam,
listTeamsByName,
normalizeQuery,
postGraphBetaJson,
postGraphJson,
resolveGraphToken,
} from
"./graph.js" ;
const originalFetch = globalThis.fetch;
const graphToken =
"graph-token" ;
const mockCredentials = {
appId:
"app-id" ,
appPassword:
"app-password" ,
tenantId:
"tenant-id" ,
};
const mockApp = { id:
"mock-app" };
const groupOne = { id:
"group-1" };
const opsTeam = { id:
"team-1" , displayName:
"Ops" };
const deploymentsChannel = { id:
"chan-1" , displayName:
"Deployments" };
const userOne = { id:
"user-1" , displayName:
"User One" };
const bobUser = { id:
"user-2" , displayName:
"Bob" };
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status:
200 ,
headers: {
"content-type" :
"application/json" },
...init,
});
}
function textResponse(body: string, init?: ResponseInit): Response {
return new Response(body, init);
}
function mockFetch(handler: Parameters<
typeof vi.fn>[
0 ]) {
globalThis.fetch = vi.fn(handler) as unknown as
typeof fetch;
}
function mockJsonFetchResponse(body: unknown, init?: ResponseInit) {
mockFetch(async () => jsonResponse(body, init));
}
function mockTextFetchResponse(body: string, init?: ResponseInit) {
mockFetch(async () => textResponse(body, init));
}
function graphCollection<T>(...items: T[]) {
return { value: items };
}
function mockGraphCollection(...items: unknown[]) {
mockJsonFetchResponse(graphCollection(...items));
}
function requestUrl(input: string | URL | Request) {
if (
typeof input ===
"string" ) {
return input;
}
if (input
instanceof URL) {
return input.toString();
}
return input.url;
}
function fetchCallUrl(index: number) {
const input = vi.mocked(globalThis.fetch).mock.calls[index]?.[
0 ];
if (!input) {
return "" ;
}
return requestUrl(input);
}
function expectFetchPathContains(index: number, expectedPath: string) {
expect(fetchCallUrl(index)).toContain(expectedPath);
}
async
function expectSearchGraphUsers(
query: string,
expected: Array<Record<string, unknown>>,
options?: { token?: string; top?: number },
) {
await expect(
searchGraphUsers({
token: options?.token ?? graphToken,
query,
top: options?.top,
}),
).resolves.toEqual(expected);
}
async
function expectRejectsToThrow(promise: Promise<unknown>, message: string) {
await expect(promise).rejects.toThrow(message);
}
function mockGraphTokenResolution(options?: {
rawToken?: string |
null ;
resolvedToken?: string |
null ;
}) {
const rawToken = options &&
"rawToken" in options ? options.rawToken :
"raw-graph-token" ;
const resolvedToken =
options &&
"resolvedToken" in options ? options.resolvedToken :
"resolved-token" ;
const getAccessToken = vi.fn(async () => rawToken);
loadMSTeamsSdkWithAuthMock.mockResolvedValue({ app: mockApp });
createMSTeamsTokenProviderMock.mockReturnValue({ getAccessToken });
resolveMSTeamsCredentialsMock.mockReturnValue(mockCredentials);
readAccessTokenMock.mockReturnValue(resolvedToken);
return { getAccessToken };
}
describe(
"msteams graph helpers" , () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it(
"normalizes queries and escapes OData apostrophes" , () => {
expect(normalizeQuery(
" Team Alpha " )).toBe(
"Team Alpha" );
expect(normalizeQuery(
" " )).toBe(
"" );
expect(escapeOData(
"alice.o'hara" )).toBe(
"alice.o''hara" );
});
it(
"fetches Graph JSON and surfaces Graph errors with response text" , async () => {
mockGraphCollection(groupOne);
await expect(
fetchGraphJson<{ value: Array<{ id: string }> }>({
token: graphToken,
path:
"/groups?$select=id" ,
headers: { ConsistencyLevel:
"eventual" },
}),
).resolves.toEqual(graphCollection(groupOne));
expect(globalThis.fetch).toHaveBeenCalledWith(
"https://graph.microsoft.com/v1.0/groups?$select=id ",
{
headers: expect.objectContaining({
Authorization: `Bearer ${graphToken}`,
ConsistencyLevel:
"eventual" ,
}),
},
);
mockTextFetchResponse(
"forbidden" , { status:
403 });
await expectRejectsToThrow(
fetchGraphJson({
token: graphToken,
path:
"/teams/team-1/channels" ,
}),
"Graph /teams/team-1/channels failed (403): forbidden" ,
);
});
it(
"posts Graph JSON to v1 and beta roots and treats empty mutation responses as undefined" , as
ync () => {
mockFetch(async (input) => {
if (requestUrl(input).startsWith("https://graph.microsoft.com/beta ")) {
return new Response(null , { status: 204 });
}
return jsonResponse({ id: "created-1" });
});
await expect(
postGraphJson<{ id: string }>({
token: graphToken,
path: "/chats/chat-1/pinnedMessages" ,
body: { messageId: "msg-1" },
}),
).resolves.toEqual({ id: "created-1" });
await expect(
postGraphBetaJson<undefined>({
token: graphToken,
path: "/chats/chat-1/messages/msg-1/setReaction" ,
body: { reactionType: "like" },
}),
).resolves.toBeUndefined();
expect(globalThis.fetch).toHaveBeenNthCalledWith(
1 ,
"https://graph.microsoft.com/v1.0/chats/chat-1/pinnedMessages ",
expect.objectContaining({
method: "POST" ,
body: JSON.stringify({ messageId: "msg-1" }),
headers: expect.objectContaining({
Authorization: `Bearer ${graphToken}`,
"Content-Type" : "application/json" ,
}),
}),
);
expect(globalThis.fetch).toHaveBeenNthCalledWith(
2 ,
"https://graph.microsoft.com/beta/chats/chat-1/messages/msg-1/setReaction ",
expect.objectContaining({
method: "POST" ,
body: JSON.stringify({ reactionType: "like" }),
}),
);
});
it("surfaces POST and DELETE graph failures with method-specific labels" , async () => {
mockFetch(async (_input, init) => {
const method = init?.method ?? "GET" ;
if (method === "DELETE" ) {
return textResponse("not found" , { status: 404 });
}
return textResponse("denied" , { status: 403 });
});
await expectRejectsToThrow(
postGraphJson({
token: graphToken,
path: "/teams/team-1/channels" ,
body: { displayName: "Deployments" },
}),
"Graph POST /teams/team-1/channels failed (403): denied" ,
);
await expectRejectsToThrow(
deleteGraphRequest({
token: graphToken,
path: "/teams/team-1/channels/channel-1" ,
}),
"Graph DELETE /teams/team-1/channels/channel-1 failed (404): not found" ,
);
});
it("resolves Graph tokens through the SDK auth provider" , async () => {
const { getAccessToken } = mockGraphTokenResolution();
await expect(resolveGraphToken({ channels: { msteams: {} } })).resolves.toBe("resolved-token" );
expect(createMSTeamsTokenProviderMock).toHaveBeenCalledWith(mockApp);
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com ");
});
it("fails when credentials or access tokens are unavailable" , async () => {
resolveMSTeamsCredentialsMock.mockReturnValue(undefined);
await expectRejectsToThrow(resolveGraphToken({ channels: {} }), "MS Teams credentials missing" );
mockGraphTokenResolution({ rawToken: null , resolvedToken: null });
await expectRejectsToThrow(
resolveGraphToken({ channels: { msteams: {} } }),
"MS Teams graph token unavailable" ,
);
});
it("builds encoded Graph paths for teams and channels" , async () => {
mockFetch(async (input) => {
if (requestUrl(input).includes("/groups?" )) {
return jsonResponse(graphCollection(opsTeam));
}
return jsonResponse(graphCollection(deploymentsChannel));
});
await expect(listTeamsByName(graphToken, "Bob's Team" )).resolves.toEqual([opsTeam]);
await expect(listChannelsForTeam(graphToken, "team/ops" )).resolves.toEqual([
deploymentsChannel,
]);
expectFetchPathContains(
0 ,
"/groups?$filter=resourceProvisioningOptions%2FAny(x%3Ax%20eq%20'Team')%20and%20startsWith(displayName%2C'Bob''s%20Team')&$select=id,displayName" ,
);
expectFetchPathContains(1 , "/teams/team%2Fops/channels?$select=id,displayName" );
});
it("returns no graph users for blank queries" , async () => {
mockJsonFetchResponse({});
await expectSearchGraphUsers(" " , [], { token: "token-1" });
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("uses exact mail or UPN lookup for email-like graph user queries" , async () => {
mockGraphCollection(userOne);
await expectSearchGraphUsers("alice.o'hara@example.com" , [userOne], {
token: "token-2" ,
});
expectFetchPathContains(
0 ,
"/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName" ,
);
});
it("uses displayName search with eventual consistency and default top handling" , async () => {
mockFetch(async (input) => {
if (requestUrl(input).includes("displayName%3Abob" )) {
return jsonResponse(graphCollection(bobUser));
}
return jsonResponse({});
});
await expectSearchGraphUsers("bob" , [bobUser], {
token: "token-3" ,
top: 25 ,
});
await expectSearchGraphUsers("carol" , [], { token: "token-4" });
const calls = vi.mocked(globalThis.fetch).mock.calls;
expectFetchPathContains(
0 ,
"/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25" ,
);
expect(calls[0 ]?.[1 ]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({ ConsistencyLevel: "eventual" }),
}),
);
expectFetchPathContains(
1 ,
"/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10" ,
);
});
describe("fetchAllGraphPages" , () => {
type Item = { id: string; name: string };
/** Build a paged Graph response with optional nextLink. */
function pagedResponse(items: Item[], nextLink?: string) {
const body: Record<string, unknown> = { value: items };
if (nextLink) {
body["@odata.nextLink" ] = nextLink;
}
return body;
}
it("single page, no nextLink" , async () => {
const items = [{ id: "1" , name: "a" }];
mockJsonFetchResponse(pagedResponse(items));
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
});
expect(result).toEqual({ items, truncated: false });
expect(globalThis.fetch).toHaveBeenCalledTimes(1 );
});
it("multiple pages with nextLink chain" , async () => {
const page1Items = [{ id: "1" , name: "a" }];
const page2Items = [{ id: "2" , name: "b" }];
const page3Items = [{ id: "3" , name: "c" }];
let callCount = 0 ;
mockFetch(async () => {
callCount++;
if (callCount === 1 ) {
return jsonResponse(
pagedResponse(page1Items, "https://graph.microsoft.com/v1.0/items?$skiptoken=page2 "),
);
}
if (callCount === 2 ) {
return jsonResponse(
pagedResponse(page2Items, "https://graph.microsoft.com/v1.0/items?$skiptoken=page3 "),
);
}
return jsonResponse(pagedResponse(page3Items));
});
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
});
expect(result.items).toEqual([...page1Items, ...page2Items, ...page3Items]);
expect(result.truncated).toBe(false );
expect(globalThis.fetch).toHaveBeenCalledTimes(3 );
});
it("truncation at maxPages" , async () => {
mockFetch(async () =>
jsonResponse(
pagedResponse(
[{ id: "x" , name: "x" }],
"https://graph.microsoft.com/v1.0/items?$skiptoken=more ",
),
),
);
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
maxPages: 2 ,
});
expect(result.items).toHaveLength(2 );
expect(result.truncated).toBe(true );
expect(globalThis.fetch).toHaveBeenCalledTimes(2 );
});
it("findOne early exit" , async () => {
const target = { id: "target" , name: "found-it" };
let callCount = 0 ;
mockFetch(async () => {
callCount++;
if (callCount === 1 ) {
return jsonResponse(
pagedResponse(
[{ id: "1" , name: "a" }],
"https://graph.microsoft.com/v1.0/items?$skiptoken=p2 ",
),
);
}
// Page 2 contains the target; page 3 should never be fetched
return jsonResponse(
pagedResponse(
[{ id: "2" , name: "b" }, target],
"https://graph.microsoft.com/v1.0/items?$skiptoken=p3 ",
),
);
});
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
findOne: (item) => item.id === "target" ,
});
expect(result.found).toEqual(target);
expect(result.truncated).toBe(false );
// Page 1 items + page 2 items (where match was found)
expect(result.items).toEqual([{ id: "1" , name: "a" }, { id: "2" , name: "b" }, target]);
// Only 2 fetches; page 3 was never requested
expect(globalThis.fetch).toHaveBeenCalledTimes(2 );
});
it("findOne with no match (exhausted)" , async () => {
mockJsonFetchResponse(pagedResponse([{ id: "1" , name: "a" }]));
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
findOne: (item) => item.id === "missing" ,
});
expect(result.found).toBeUndefined();
expect(result.truncated).toBe(false );
expect(result.items).toEqual([{ id: "1" , name: "a" }]);
});
it("findOne with no match (truncated)" , async () => {
mockFetch(async () =>
jsonResponse(
pagedResponse(
[{ id: "x" , name: "x" }],
"https://graph.microsoft.com/v1.0/items?$skiptoken=more ",
),
),
);
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
maxPages: 2 ,
findOne: (item) => item.id === "missing" ,
});
expect(result.found).toBeUndefined();
expect(result.truncated).toBe(true );
expect(result.items).toHaveLength(2 );
});
it("empty first page" , async () => {
mockJsonFetchResponse(pagedResponse([]));
const result = await fetchAllGraphPages<Item>({
token: graphToken,
path: "/items" ,
});
expect(result).toEqual({ items: [], truncated: false });
expect(globalThis.fetch).toHaveBeenCalledTimes(1 );
});
});
});
Messung V0.5 in Prozent C=98 H=100 G=98
¤ Dauer der Verarbeitung: 0.18 Sekunden
(vorverarbeitet am 2026-05-26)
¤
*© Formatika GbR, Deutschland