@openclaw/msteams 2026.3.13 → 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +2 -0
- package/config-api.ts +4 -0
- package/contract-api.ts +4 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +553 -1
- package/package.json +46 -12
- package/runtime-api.ts +73 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/ai-entity.ts +7 -0
- package/src/approval-auth.ts +44 -0
- package/src/attachments/bot-framework.test.ts +461 -0
- package/src/attachments/bot-framework.ts +362 -0
- package/src/attachments/download.ts +63 -19
- package/src/attachments/graph.test.ts +416 -0
- package/src/attachments/graph.ts +163 -72
- package/src/attachments/html.ts +33 -1
- package/src/attachments/payload.ts +1 -1
- package/src/attachments/remote-media.test.ts +137 -0
- package/src/attachments/remote-media.ts +75 -8
- package/src/attachments/shared.test.ts +138 -1
- package/src/attachments/shared.ts +193 -26
- package/src/attachments/types.ts +10 -0
- package/src/attachments.graph.test.ts +342 -0
- package/src/attachments.helpers.test.ts +246 -0
- package/src/attachments.test-helpers.ts +17 -0
- package/src/attachments.test.ts +163 -418
- package/src/attachments.ts +5 -5
- package/src/block-streaming-config.test.ts +61 -0
- package/src/channel-api.ts +1 -0
- package/src/channel.actions.test.ts +742 -0
- package/src/channel.directory.test.ts +145 -4
- package/src/channel.runtime.ts +56 -0
- package/src/channel.setup.ts +77 -0
- package/src/channel.test.ts +128 -0
- package/src/channel.ts +1077 -395
- package/src/config-schema.ts +6 -0
- package/src/config-ui-hints.ts +12 -0
- package/src/conversation-store-fs.test.ts +4 -5
- package/src/conversation-store-fs.ts +35 -51
- package/src/conversation-store-helpers.test.ts +202 -0
- package/src/conversation-store-helpers.ts +105 -0
- package/src/conversation-store-memory.ts +27 -23
- package/src/conversation-store.shared.test.ts +225 -0
- package/src/conversation-store.ts +30 -0
- package/src/directory-live.test.ts +156 -0
- package/src/directory-live.ts +7 -4
- package/src/doctor.ts +27 -0
- package/src/errors.test.ts +64 -1
- package/src/errors.ts +50 -9
- package/src/feedback-reflection-prompt.ts +117 -0
- package/src/feedback-reflection-store.ts +114 -0
- package/src/feedback-reflection.test.ts +237 -0
- package/src/feedback-reflection.ts +283 -0
- package/src/file-consent-helpers.test.ts +83 -0
- package/src/file-consent-helpers.ts +64 -11
- package/src/file-consent-invoke.ts +150 -0
- package/src/file-consent.test.ts +363 -0
- package/src/file-consent.ts +165 -4
- package/src/graph-chat.ts +5 -3
- package/src/graph-group-management.test.ts +318 -0
- package/src/graph-group-management.ts +168 -0
- package/src/graph-members.test.ts +89 -0
- package/src/graph-members.ts +48 -0
- package/src/graph-messages.actions.test.ts +243 -0
- package/src/graph-messages.read.test.ts +391 -0
- package/src/graph-messages.search.test.ts +213 -0
- package/src/graph-messages.test-helpers.ts +50 -0
- package/src/graph-messages.ts +534 -0
- package/src/graph-teams.test.ts +215 -0
- package/src/graph-teams.ts +114 -0
- package/src/graph-thread.test.ts +246 -0
- package/src/graph-thread.ts +146 -0
- package/src/graph-upload.test.ts +161 -4
- package/src/graph-upload.ts +147 -56
- package/src/graph.test.ts +516 -0
- package/src/graph.ts +233 -21
- package/src/inbound.test.ts +156 -1
- package/src/inbound.ts +101 -1
- package/src/media-helpers.ts +1 -1
- package/src/mentions.test.ts +27 -18
- package/src/mentions.ts +2 -2
- package/src/messenger.test.ts +504 -23
- package/src/messenger.ts +133 -52
- package/src/monitor-handler/access.ts +125 -0
- package/src/monitor-handler/inbound-media.test.ts +289 -0
- package/src/monitor-handler/inbound-media.ts +57 -5
- package/src/monitor-handler/message-handler-mock-support.test-support.ts +28 -0
- package/src/monitor-handler/message-handler.authz.test.ts +588 -74
- package/src/monitor-handler/message-handler.dm-media.test.ts +54 -0
- package/src/monitor-handler/message-handler.test-support.ts +100 -0
- package/src/monitor-handler/message-handler.thread-parent.test.ts +223 -0
- package/src/monitor-handler/message-handler.thread-session.test.ts +77 -0
- package/src/monitor-handler/message-handler.ts +470 -164
- package/src/monitor-handler/reaction-handler.test.ts +267 -0
- package/src/monitor-handler/reaction-handler.ts +210 -0
- package/src/monitor-handler/thread-session.ts +17 -0
- package/src/monitor-handler.adaptive-card.test.ts +162 -0
- package/src/monitor-handler.feedback-authz.test.ts +314 -0
- package/src/monitor-handler.file-consent.test.ts +281 -79
- package/src/monitor-handler.sso.test.ts +563 -0
- package/src/monitor-handler.test-helpers.ts +180 -0
- package/src/monitor-handler.ts +459 -115
- package/src/monitor-handler.types.ts +27 -0
- package/src/monitor-types.ts +1 -0
- package/src/monitor.lifecycle.test.ts +74 -10
- package/src/monitor.test.ts +35 -1
- package/src/monitor.ts +143 -46
- package/src/oauth.flow.ts +77 -0
- package/src/oauth.shared.ts +37 -0
- package/src/oauth.test.ts +305 -0
- package/src/oauth.token.ts +158 -0
- package/src/oauth.ts +130 -0
- package/src/outbound.test.ts +10 -11
- package/src/outbound.ts +62 -44
- package/src/pending-uploads-fs.test.ts +246 -0
- package/src/pending-uploads-fs.ts +235 -0
- package/src/pending-uploads.test.ts +173 -0
- package/src/pending-uploads.ts +34 -2
- package/src/policy.test.ts +11 -5
- package/src/policy.ts +5 -5
- package/src/polls.test.ts +106 -5
- package/src/polls.ts +15 -7
- package/src/presentation.ts +68 -0
- package/src/probe.test.ts +27 -8
- package/src/probe.ts +43 -9
- package/src/reply-dispatcher.test.ts +437 -0
- package/src/reply-dispatcher.ts +259 -73
- package/src/reply-stream-controller.test.ts +235 -0
- package/src/reply-stream-controller.ts +147 -0
- package/src/resolve-allowlist.test.ts +105 -1
- package/src/resolve-allowlist.ts +112 -7
- package/src/runtime.ts +6 -3
- package/src/sdk-types.ts +43 -3
- package/src/sdk.test.ts +666 -0
- package/src/sdk.ts +867 -16
- package/src/secret-contract.ts +49 -0
- package/src/secret-input.ts +1 -1
- package/src/send-context.ts +76 -9
- package/src/send.test.ts +389 -5
- package/src/send.ts +140 -32
- package/src/sent-message-cache.ts +30 -18
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +160 -0
- package/src/setup-surface.test.ts +202 -0
- package/src/setup-surface.ts +320 -0
- package/src/sso-token-store.test.ts +72 -0
- package/src/sso-token-store.ts +166 -0
- package/src/sso.ts +300 -0
- package/src/storage.ts +1 -1
- package/src/store-fs.ts +2 -2
- package/src/streaming-message.test.ts +262 -0
- package/src/streaming-message.ts +297 -0
- package/src/test-runtime.ts +1 -1
- package/src/thread-parent-context.test.ts +224 -0
- package/src/thread-parent-context.ts +159 -0
- package/src/token.test.ts +237 -50
- package/src/token.ts +162 -7
- package/src/user-agent.test.ts +86 -0
- package/src/user-agent.ts +53 -0
- package/src/webhook-timeouts.ts +27 -0
- package/src/welcome-card.test.ts +81 -0
- package/src/welcome-card.ts +57 -0
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -107
- package/src/file-lock.ts +0 -1
- package/src/graph-users.test.ts +0 -66
- package/src/onboarding.ts +0 -381
- package/src/polls-store.test.ts +0 -38
- package/src/revoked-context.test.ts +0 -39
- package/src/token-response.test.ts +0 -23
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
loadMSTeamsSdkWithAuthMock,
|
|
5
|
+
createMSTeamsTokenProviderMock,
|
|
6
|
+
readAccessTokenMock,
|
|
7
|
+
resolveMSTeamsCredentialsMock,
|
|
8
|
+
} = vi.hoisted(() => {
|
|
9
|
+
return {
|
|
10
|
+
loadMSTeamsSdkWithAuthMock: vi.fn(),
|
|
11
|
+
createMSTeamsTokenProviderMock: vi.fn(),
|
|
12
|
+
readAccessTokenMock: vi.fn(),
|
|
13
|
+
resolveMSTeamsCredentialsMock: vi.fn(),
|
|
14
|
+
};
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
vi.mock("./sdk.js", () => ({
|
|
18
|
+
loadMSTeamsSdkWithAuth: loadMSTeamsSdkWithAuthMock,
|
|
19
|
+
createMSTeamsTokenProvider: createMSTeamsTokenProviderMock,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("./token-response.js", () => ({
|
|
23
|
+
readAccessToken: readAccessTokenMock,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./token.js", () => ({
|
|
27
|
+
resolveMSTeamsCredentials: resolveMSTeamsCredentialsMock,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
import { searchGraphUsers } from "./graph-users.js";
|
|
31
|
+
import {
|
|
32
|
+
deleteGraphRequest,
|
|
33
|
+
escapeOData,
|
|
34
|
+
fetchAllGraphPages,
|
|
35
|
+
fetchGraphJson,
|
|
36
|
+
listChannelsForTeam,
|
|
37
|
+
listTeamsByName,
|
|
38
|
+
normalizeQuery,
|
|
39
|
+
postGraphBetaJson,
|
|
40
|
+
postGraphJson,
|
|
41
|
+
resolveGraphToken,
|
|
42
|
+
} from "./graph.js";
|
|
43
|
+
|
|
44
|
+
const originalFetch = globalThis.fetch;
|
|
45
|
+
const graphToken = "graph-token";
|
|
46
|
+
const mockCredentials = {
|
|
47
|
+
appId: "app-id",
|
|
48
|
+
appPassword: "app-password",
|
|
49
|
+
tenantId: "tenant-id",
|
|
50
|
+
};
|
|
51
|
+
const mockApp = { id: "mock-app" };
|
|
52
|
+
const groupOne = { id: "group-1" };
|
|
53
|
+
const opsTeam = { id: "team-1", displayName: "Ops" };
|
|
54
|
+
const deploymentsChannel = { id: "chan-1", displayName: "Deployments" };
|
|
55
|
+
const userOne = { id: "user-1", displayName: "User One" };
|
|
56
|
+
const bobUser = { id: "user-2", displayName: "Bob" };
|
|
57
|
+
|
|
58
|
+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
59
|
+
return new Response(JSON.stringify(body), {
|
|
60
|
+
status: 200,
|
|
61
|
+
headers: { "content-type": "application/json" },
|
|
62
|
+
...init,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function textResponse(body: string, init?: ResponseInit): Response {
|
|
67
|
+
return new Response(body, init);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function mockFetch(handler: Parameters<typeof vi.fn>[0]) {
|
|
71
|
+
globalThis.fetch = vi.fn(handler) as unknown as typeof fetch;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mockJsonFetchResponse(body: unknown, init?: ResponseInit) {
|
|
75
|
+
mockFetch(async () => jsonResponse(body, init));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mockTextFetchResponse(body: string, init?: ResponseInit) {
|
|
79
|
+
mockFetch(async () => textResponse(body, init));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function graphCollection<T>(...items: T[]) {
|
|
83
|
+
return { value: items };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mockGraphCollection(...items: unknown[]) {
|
|
87
|
+
mockJsonFetchResponse(graphCollection(...items));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requestUrl(input: string | URL | Request) {
|
|
91
|
+
if (typeof input === "string") {
|
|
92
|
+
return input;
|
|
93
|
+
}
|
|
94
|
+
if (input instanceof URL) {
|
|
95
|
+
return input.toString();
|
|
96
|
+
}
|
|
97
|
+
return input.url;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fetchCallUrl(index: number) {
|
|
101
|
+
const input = vi.mocked(globalThis.fetch).mock.calls[index]?.[0];
|
|
102
|
+
if (!input) {
|
|
103
|
+
return "";
|
|
104
|
+
}
|
|
105
|
+
return requestUrl(input);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function expectFetchPathContains(index: number, expectedPath: string) {
|
|
109
|
+
expect(fetchCallUrl(index)).toContain(expectedPath);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function expectSearchGraphUsers(
|
|
113
|
+
query: string,
|
|
114
|
+
expected: Array<Record<string, unknown>>,
|
|
115
|
+
options?: { token?: string; top?: number },
|
|
116
|
+
) {
|
|
117
|
+
await expect(
|
|
118
|
+
searchGraphUsers({
|
|
119
|
+
token: options?.token ?? graphToken,
|
|
120
|
+
query,
|
|
121
|
+
top: options?.top,
|
|
122
|
+
}),
|
|
123
|
+
).resolves.toEqual(expected);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function expectRejectsToThrow(promise: Promise<unknown>, message: string) {
|
|
127
|
+
await expect(promise).rejects.toThrow(message);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mockGraphTokenResolution(options?: {
|
|
131
|
+
rawToken?: string | null;
|
|
132
|
+
resolvedToken?: string | null;
|
|
133
|
+
}) {
|
|
134
|
+
const rawToken = options && "rawToken" in options ? options.rawToken : "raw-graph-token";
|
|
135
|
+
const resolvedToken =
|
|
136
|
+
options && "resolvedToken" in options ? options.resolvedToken : "resolved-token";
|
|
137
|
+
const getAccessToken = vi.fn(async () => rawToken);
|
|
138
|
+
loadMSTeamsSdkWithAuthMock.mockResolvedValue({ app: mockApp });
|
|
139
|
+
createMSTeamsTokenProviderMock.mockReturnValue({ getAccessToken });
|
|
140
|
+
resolveMSTeamsCredentialsMock.mockReturnValue(mockCredentials);
|
|
141
|
+
readAccessTokenMock.mockReturnValue(resolvedToken);
|
|
142
|
+
return { getAccessToken };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
describe("msteams graph helpers", () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
vi.clearAllMocks();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
globalThis.fetch = originalFetch;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("normalizes queries and escapes OData apostrophes", () => {
|
|
155
|
+
expect(normalizeQuery(" Team Alpha ")).toBe("Team Alpha");
|
|
156
|
+
expect(normalizeQuery(" ")).toBe("");
|
|
157
|
+
expect(escapeOData("alice.o'hara")).toBe("alice.o''hara");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("fetches Graph JSON and surfaces Graph errors with response text", async () => {
|
|
161
|
+
mockGraphCollection(groupOne);
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
fetchGraphJson<{ value: Array<{ id: string }> }>({
|
|
165
|
+
token: graphToken,
|
|
166
|
+
path: "/groups?$select=id",
|
|
167
|
+
headers: { ConsistencyLevel: "eventual" },
|
|
168
|
+
}),
|
|
169
|
+
).resolves.toEqual(graphCollection(groupOne));
|
|
170
|
+
|
|
171
|
+
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
172
|
+
"https://graph.microsoft.com/v1.0/groups?$select=id",
|
|
173
|
+
{
|
|
174
|
+
headers: expect.objectContaining({
|
|
175
|
+
Authorization: `Bearer ${graphToken}`,
|
|
176
|
+
ConsistencyLevel: "eventual",
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
mockTextFetchResponse("forbidden", { status: 403 });
|
|
182
|
+
|
|
183
|
+
await expectRejectsToThrow(
|
|
184
|
+
fetchGraphJson({
|
|
185
|
+
token: graphToken,
|
|
186
|
+
path: "/teams/team-1/channels",
|
|
187
|
+
}),
|
|
188
|
+
"Graph /teams/team-1/channels failed (403): forbidden",
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("posts Graph JSON to v1 and beta roots and treats empty mutation responses as undefined", async () => {
|
|
193
|
+
mockFetch(async (input) => {
|
|
194
|
+
if (requestUrl(input).startsWith("https://graph.microsoft.com/beta")) {
|
|
195
|
+
return new Response(null, { status: 204 });
|
|
196
|
+
}
|
|
197
|
+
return jsonResponse({ id: "created-1" });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
postGraphJson<{ id: string }>({
|
|
202
|
+
token: graphToken,
|
|
203
|
+
path: "/chats/chat-1/pinnedMessages",
|
|
204
|
+
body: { messageId: "msg-1" },
|
|
205
|
+
}),
|
|
206
|
+
).resolves.toEqual({ id: "created-1" });
|
|
207
|
+
|
|
208
|
+
await expect(
|
|
209
|
+
postGraphBetaJson<undefined>({
|
|
210
|
+
token: graphToken,
|
|
211
|
+
path: "/chats/chat-1/messages/msg-1/setReaction",
|
|
212
|
+
body: { reactionType: "like" },
|
|
213
|
+
}),
|
|
214
|
+
).resolves.toBeUndefined();
|
|
215
|
+
|
|
216
|
+
expect(globalThis.fetch).toHaveBeenNthCalledWith(
|
|
217
|
+
1,
|
|
218
|
+
"https://graph.microsoft.com/v1.0/chats/chat-1/pinnedMessages",
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: JSON.stringify({ messageId: "msg-1" }),
|
|
222
|
+
headers: expect.objectContaining({
|
|
223
|
+
Authorization: `Bearer ${graphToken}`,
|
|
224
|
+
"Content-Type": "application/json",
|
|
225
|
+
}),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
expect(globalThis.fetch).toHaveBeenNthCalledWith(
|
|
229
|
+
2,
|
|
230
|
+
"https://graph.microsoft.com/beta/chats/chat-1/messages/msg-1/setReaction",
|
|
231
|
+
expect.objectContaining({
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: JSON.stringify({ reactionType: "like" }),
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("surfaces POST and DELETE graph failures with method-specific labels", async () => {
|
|
239
|
+
mockFetch(async (_input, init) => {
|
|
240
|
+
const method = init?.method ?? "GET";
|
|
241
|
+
if (method === "DELETE") {
|
|
242
|
+
return textResponse("not found", { status: 404 });
|
|
243
|
+
}
|
|
244
|
+
return textResponse("denied", { status: 403 });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
await expectRejectsToThrow(
|
|
248
|
+
postGraphJson({
|
|
249
|
+
token: graphToken,
|
|
250
|
+
path: "/teams/team-1/channels",
|
|
251
|
+
body: { displayName: "Deployments" },
|
|
252
|
+
}),
|
|
253
|
+
"Graph POST /teams/team-1/channels failed (403): denied",
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
await expectRejectsToThrow(
|
|
257
|
+
deleteGraphRequest({
|
|
258
|
+
token: graphToken,
|
|
259
|
+
path: "/teams/team-1/channels/channel-1",
|
|
260
|
+
}),
|
|
261
|
+
"Graph DELETE /teams/team-1/channels/channel-1 failed (404): not found",
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("resolves Graph tokens through the SDK auth provider", async () => {
|
|
266
|
+
const { getAccessToken } = mockGraphTokenResolution();
|
|
267
|
+
|
|
268
|
+
await expect(resolveGraphToken({ channels: { msteams: {} } })).resolves.toBe("resolved-token");
|
|
269
|
+
|
|
270
|
+
expect(createMSTeamsTokenProviderMock).toHaveBeenCalledWith(mockApp);
|
|
271
|
+
expect(getAccessToken).toHaveBeenCalledWith("https://graph.microsoft.com");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("fails when credentials or access tokens are unavailable", async () => {
|
|
275
|
+
resolveMSTeamsCredentialsMock.mockReturnValue(undefined);
|
|
276
|
+
await expectRejectsToThrow(resolveGraphToken({ channels: {} }), "MS Teams credentials missing");
|
|
277
|
+
|
|
278
|
+
mockGraphTokenResolution({ rawToken: null, resolvedToken: null });
|
|
279
|
+
|
|
280
|
+
await expectRejectsToThrow(
|
|
281
|
+
resolveGraphToken({ channels: { msteams: {} } }),
|
|
282
|
+
"MS Teams graph token unavailable",
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("builds encoded Graph paths for teams and channels", async () => {
|
|
287
|
+
mockFetch(async (input) => {
|
|
288
|
+
if (requestUrl(input).includes("/groups?")) {
|
|
289
|
+
return jsonResponse(graphCollection(opsTeam));
|
|
290
|
+
}
|
|
291
|
+
return jsonResponse(graphCollection(deploymentsChannel));
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await expect(listTeamsByName(graphToken, "Bob's Team")).resolves.toEqual([opsTeam]);
|
|
295
|
+
await expect(listChannelsForTeam(graphToken, "team/ops")).resolves.toEqual([
|
|
296
|
+
deploymentsChannel,
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
expectFetchPathContains(
|
|
300
|
+
0,
|
|
301
|
+
"/groups?$filter=resourceProvisioningOptions%2FAny(x%3Ax%20eq%20'Team')%20and%20startsWith(displayName%2C'Bob''s%20Team')&$select=id,displayName",
|
|
302
|
+
);
|
|
303
|
+
expectFetchPathContains(1, "/teams/team%2Fops/channels?$select=id,displayName");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("returns no graph users for blank queries", async () => {
|
|
307
|
+
mockJsonFetchResponse({});
|
|
308
|
+
await expectSearchGraphUsers(" ", [], { token: "token-1" });
|
|
309
|
+
expect(globalThis.fetch).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("uses exact mail or UPN lookup for email-like graph user queries", async () => {
|
|
313
|
+
mockGraphCollection(userOne);
|
|
314
|
+
|
|
315
|
+
await expectSearchGraphUsers("alice.o'hara@example.com", [userOne], {
|
|
316
|
+
token: "token-2",
|
|
317
|
+
});
|
|
318
|
+
expectFetchPathContains(
|
|
319
|
+
0,
|
|
320
|
+
"/users?$filter=(mail%20eq%20'alice.o''hara%40example.com'%20or%20userPrincipalName%20eq%20'alice.o''hara%40example.com')&$select=id,displayName,mail,userPrincipalName",
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("uses displayName search with eventual consistency and default top handling", async () => {
|
|
325
|
+
mockFetch(async (input) => {
|
|
326
|
+
if (requestUrl(input).includes("displayName%3Abob")) {
|
|
327
|
+
return jsonResponse(graphCollection(bobUser));
|
|
328
|
+
}
|
|
329
|
+
return jsonResponse({});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
await expectSearchGraphUsers("bob", [bobUser], {
|
|
333
|
+
token: "token-3",
|
|
334
|
+
top: 25,
|
|
335
|
+
});
|
|
336
|
+
await expectSearchGraphUsers("carol", [], { token: "token-4" });
|
|
337
|
+
|
|
338
|
+
const calls = vi.mocked(globalThis.fetch).mock.calls;
|
|
339
|
+
expectFetchPathContains(
|
|
340
|
+
0,
|
|
341
|
+
"/users?$search=%22displayName%3Abob%22&$select=id,displayName,mail,userPrincipalName&$top=25",
|
|
342
|
+
);
|
|
343
|
+
expect(calls[0]?.[1]).toEqual(
|
|
344
|
+
expect.objectContaining({
|
|
345
|
+
headers: expect.objectContaining({ ConsistencyLevel: "eventual" }),
|
|
346
|
+
}),
|
|
347
|
+
);
|
|
348
|
+
expectFetchPathContains(
|
|
349
|
+
1,
|
|
350
|
+
"/users?$search=%22displayName%3Acarol%22&$select=id,displayName,mail,userPrincipalName&$top=10",
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("fetchAllGraphPages", () => {
|
|
355
|
+
type Item = { id: string; name: string };
|
|
356
|
+
|
|
357
|
+
/** Build a paged Graph response with optional nextLink. */
|
|
358
|
+
function pagedResponse(items: Item[], nextLink?: string) {
|
|
359
|
+
const body: Record<string, unknown> = { value: items };
|
|
360
|
+
if (nextLink) {
|
|
361
|
+
body["@odata.nextLink"] = nextLink;
|
|
362
|
+
}
|
|
363
|
+
return body;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
it("single page, no nextLink", async () => {
|
|
367
|
+
const items = [{ id: "1", name: "a" }];
|
|
368
|
+
mockJsonFetchResponse(pagedResponse(items));
|
|
369
|
+
|
|
370
|
+
const result = await fetchAllGraphPages<Item>({
|
|
371
|
+
token: graphToken,
|
|
372
|
+
path: "/items",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(result).toEqual({ items, truncated: false });
|
|
376
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("multiple pages with nextLink chain", async () => {
|
|
380
|
+
const page1Items = [{ id: "1", name: "a" }];
|
|
381
|
+
const page2Items = [{ id: "2", name: "b" }];
|
|
382
|
+
const page3Items = [{ id: "3", name: "c" }];
|
|
383
|
+
let callCount = 0;
|
|
384
|
+
|
|
385
|
+
mockFetch(async () => {
|
|
386
|
+
callCount++;
|
|
387
|
+
if (callCount === 1) {
|
|
388
|
+
return jsonResponse(
|
|
389
|
+
pagedResponse(page1Items, "https://graph.microsoft.com/v1.0/items?$skiptoken=page2"),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (callCount === 2) {
|
|
393
|
+
return jsonResponse(
|
|
394
|
+
pagedResponse(page2Items, "https://graph.microsoft.com/v1.0/items?$skiptoken=page3"),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
return jsonResponse(pagedResponse(page3Items));
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const result = await fetchAllGraphPages<Item>({
|
|
401
|
+
token: graphToken,
|
|
402
|
+
path: "/items",
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(result.items).toEqual([...page1Items, ...page2Items, ...page3Items]);
|
|
406
|
+
expect(result.truncated).toBe(false);
|
|
407
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(3);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("truncation at maxPages", async () => {
|
|
411
|
+
mockFetch(async () =>
|
|
412
|
+
jsonResponse(
|
|
413
|
+
pagedResponse(
|
|
414
|
+
[{ id: "x", name: "x" }],
|
|
415
|
+
"https://graph.microsoft.com/v1.0/items?$skiptoken=more",
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const result = await fetchAllGraphPages<Item>({
|
|
421
|
+
token: graphToken,
|
|
422
|
+
path: "/items",
|
|
423
|
+
maxPages: 2,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(result.items).toHaveLength(2);
|
|
427
|
+
expect(result.truncated).toBe(true);
|
|
428
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("findOne early exit", async () => {
|
|
432
|
+
const target = { id: "target", name: "found-it" };
|
|
433
|
+
let callCount = 0;
|
|
434
|
+
|
|
435
|
+
mockFetch(async () => {
|
|
436
|
+
callCount++;
|
|
437
|
+
if (callCount === 1) {
|
|
438
|
+
return jsonResponse(
|
|
439
|
+
pagedResponse(
|
|
440
|
+
[{ id: "1", name: "a" }],
|
|
441
|
+
"https://graph.microsoft.com/v1.0/items?$skiptoken=p2",
|
|
442
|
+
),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
// Page 2 contains the target; page 3 should never be fetched
|
|
446
|
+
return jsonResponse(
|
|
447
|
+
pagedResponse(
|
|
448
|
+
[{ id: "2", name: "b" }, target],
|
|
449
|
+
"https://graph.microsoft.com/v1.0/items?$skiptoken=p3",
|
|
450
|
+
),
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const result = await fetchAllGraphPages<Item>({
|
|
455
|
+
token: graphToken,
|
|
456
|
+
path: "/items",
|
|
457
|
+
findOne: (item) => item.id === "target",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(result.found).toEqual(target);
|
|
461
|
+
expect(result.truncated).toBe(false);
|
|
462
|
+
// Page 1 items + page 2 items (where match was found)
|
|
463
|
+
expect(result.items).toEqual([{ id: "1", name: "a" }, { id: "2", name: "b" }, target]);
|
|
464
|
+
// Only 2 fetches; page 3 was never requested
|
|
465
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("findOne with no match (exhausted)", async () => {
|
|
469
|
+
mockJsonFetchResponse(pagedResponse([{ id: "1", name: "a" }]));
|
|
470
|
+
|
|
471
|
+
const result = await fetchAllGraphPages<Item>({
|
|
472
|
+
token: graphToken,
|
|
473
|
+
path: "/items",
|
|
474
|
+
findOne: (item) => item.id === "missing",
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
expect(result.found).toBeUndefined();
|
|
478
|
+
expect(result.truncated).toBe(false);
|
|
479
|
+
expect(result.items).toEqual([{ id: "1", name: "a" }]);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("findOne with no match (truncated)", async () => {
|
|
483
|
+
mockFetch(async () =>
|
|
484
|
+
jsonResponse(
|
|
485
|
+
pagedResponse(
|
|
486
|
+
[{ id: "x", name: "x" }],
|
|
487
|
+
"https://graph.microsoft.com/v1.0/items?$skiptoken=more",
|
|
488
|
+
),
|
|
489
|
+
),
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const result = await fetchAllGraphPages<Item>({
|
|
493
|
+
token: graphToken,
|
|
494
|
+
path: "/items",
|
|
495
|
+
maxPages: 2,
|
|
496
|
+
findOne: (item) => item.id === "missing",
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(result.found).toBeUndefined();
|
|
500
|
+
expect(result.truncated).toBe(true);
|
|
501
|
+
expect(result.items).toHaveLength(2);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("empty first page", async () => {
|
|
505
|
+
mockJsonFetchResponse(pagedResponse([]));
|
|
506
|
+
|
|
507
|
+
const result = await fetchAllGraphPages<Item>({
|
|
508
|
+
token: graphToken,
|
|
509
|
+
path: "/items",
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
expect(result).toEqual({ items: [], truncated: false });
|
|
513
|
+
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
});
|