@access-mcp/announcements 0.1.0 → 0.3.0
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/README.md +96 -200
- package/dist/index.js +2 -17
- package/dist/server.d.ts +73 -149
- package/dist/server.js +843 -161
- package/package.json +3 -3
- package/src/index.ts +2 -18
- package/src/server.integration.test.ts +119 -87
- package/src/server.test.ts +1032 -105
- package/src/server.ts +995 -178
package/src/server.test.ts
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from "vitest";
|
|
2
2
|
import { AnnouncementsServer } from "./server.js";
|
|
3
|
+
import { DrupalAuthProvider } from "@access-mcp/shared";
|
|
4
|
+
|
|
5
|
+
// Mock the DrupalAuthProvider
|
|
6
|
+
vi.mock("@access-mcp/shared", async () => {
|
|
7
|
+
const actual = await vi.importActual("@access-mcp/shared");
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
DrupalAuthProvider: vi.fn().mockImplementation(() => ({
|
|
11
|
+
ensureAuthenticated: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
getUserUuid: vi.fn().mockReturnValue("user-uuid-123"),
|
|
13
|
+
get: vi.fn(),
|
|
14
|
+
post: vi.fn(),
|
|
15
|
+
patch: vi.fn(),
|
|
16
|
+
delete: vi.fn(),
|
|
17
|
+
})),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
interface MockHttpClient {
|
|
22
|
+
get: Mock<(url: string) => Promise<{ status: number; data?: unknown; statusText?: string }>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TextContent {
|
|
26
|
+
type: "text";
|
|
27
|
+
text: string;
|
|
28
|
+
}
|
|
3
29
|
|
|
4
30
|
describe("AnnouncementsServer", () => {
|
|
5
31
|
let server: AnnouncementsServer;
|
|
6
|
-
let mockHttpClient:
|
|
32
|
+
let mockHttpClient: MockHttpClient;
|
|
7
33
|
|
|
8
34
|
beforeEach(() => {
|
|
9
35
|
server = new AnnouncementsServer();
|
|
@@ -21,7 +47,7 @@ describe("AnnouncementsServer", () => {
|
|
|
21
47
|
});
|
|
22
48
|
|
|
23
49
|
describe("Tool Methods", () => {
|
|
24
|
-
describe("
|
|
50
|
+
describe("search_announcements", () => {
|
|
25
51
|
it("should fetch announcements with filters", async () => {
|
|
26
52
|
const mockResponse = {
|
|
27
53
|
status: 200,
|
|
@@ -29,18 +55,18 @@ describe("AnnouncementsServer", () => {
|
|
|
29
55
|
{
|
|
30
56
|
title: "Scheduled Maintenance",
|
|
31
57
|
body: "System will be down for maintenance",
|
|
32
|
-
|
|
58
|
+
published_date: "2024-03-15",
|
|
33
59
|
author: "ACCESS Support",
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
tags: ["maintenance", "scheduled"],
|
|
61
|
+
affinity_group: ["123", "456"],
|
|
36
62
|
},
|
|
37
63
|
{
|
|
38
64
|
title: "New GPU Nodes Available",
|
|
39
65
|
body: "Additional GPU resources added",
|
|
40
|
-
|
|
66
|
+
published_date: "2024-03-10",
|
|
41
67
|
author: "Resource Team",
|
|
42
|
-
|
|
43
|
-
|
|
68
|
+
tags: ["gpu", "hardware"],
|
|
69
|
+
affinity_group: ["789"],
|
|
44
70
|
},
|
|
45
71
|
],
|
|
46
72
|
};
|
|
@@ -48,24 +74,25 @@ describe("AnnouncementsServer", () => {
|
|
|
48
74
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
49
75
|
|
|
50
76
|
const result = await server["handleToolCall"]({
|
|
77
|
+
method: "tools/call",
|
|
51
78
|
params: {
|
|
52
|
-
name: "
|
|
79
|
+
name: "search_announcements",
|
|
53
80
|
arguments: {
|
|
54
81
|
tags: "maintenance",
|
|
55
82
|
limit: 10,
|
|
56
83
|
},
|
|
57
84
|
},
|
|
58
|
-
}
|
|
85
|
+
});
|
|
59
86
|
|
|
60
87
|
expect(mockHttpClient.get).toHaveBeenCalled();
|
|
61
88
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
62
|
-
expect(url).toContain("/api/2.
|
|
89
|
+
expect(url).toContain("/api/2.2/announcements");
|
|
63
90
|
expect(url).toContain("tags=maintenance");
|
|
64
91
|
|
|
65
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
66
|
-
expect(responseData.
|
|
67
|
-
expect(responseData.
|
|
68
|
-
expect(responseData.
|
|
92
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
93
|
+
expect(responseData.total).toBe(2);
|
|
94
|
+
expect(responseData.items).toHaveLength(2);
|
|
95
|
+
expect(responseData.items[0].tags).toEqual(["maintenance", "scheduled"]);
|
|
69
96
|
});
|
|
70
97
|
|
|
71
98
|
it("should handle empty results", async () => {
|
|
@@ -75,17 +102,18 @@ describe("AnnouncementsServer", () => {
|
|
|
75
102
|
});
|
|
76
103
|
|
|
77
104
|
const result = await server["handleToolCall"]({
|
|
105
|
+
method: "tools/call",
|
|
78
106
|
params: {
|
|
79
|
-
name: "
|
|
107
|
+
name: "search_announcements",
|
|
80
108
|
arguments: {
|
|
81
109
|
tags: "nonexistent",
|
|
82
110
|
},
|
|
83
111
|
},
|
|
84
|
-
}
|
|
112
|
+
});
|
|
85
113
|
|
|
86
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
87
|
-
expect(responseData.
|
|
88
|
-
expect(responseData.
|
|
114
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
115
|
+
expect(responseData.total).toBe(0);
|
|
116
|
+
expect(responseData.items).toEqual([]);
|
|
89
117
|
});
|
|
90
118
|
|
|
91
119
|
it("should handle API errors", async () => {
|
|
@@ -95,18 +123,65 @@ describe("AnnouncementsServer", () => {
|
|
|
95
123
|
});
|
|
96
124
|
|
|
97
125
|
const result = await server["handleToolCall"]({
|
|
126
|
+
method: "tools/call",
|
|
98
127
|
params: {
|
|
99
|
-
name: "
|
|
128
|
+
name: "search_announcements",
|
|
100
129
|
arguments: {},
|
|
101
130
|
},
|
|
102
|
-
}
|
|
131
|
+
});
|
|
103
132
|
|
|
104
133
|
// Server handles errors and returns them in content, not as isError
|
|
105
|
-
expect(result.content[0].text).toContain("500");
|
|
134
|
+
expect((result.content[0] as TextContent).text).toContain("500");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("search with query parameter", () => {
|
|
139
|
+
it("should include search_api_fulltext in URL", async () => {
|
|
140
|
+
mockHttpClient.get.mockResolvedValue({
|
|
141
|
+
status: 200,
|
|
142
|
+
data: [],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await server["handleToolCall"]({
|
|
146
|
+
method: "tools/call",
|
|
147
|
+
params: {
|
|
148
|
+
name: "search_announcements",
|
|
149
|
+
arguments: {
|
|
150
|
+
query: "GPU computing",
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const url = mockHttpClient.get.mock.calls[0][0];
|
|
156
|
+
expect(url).toContain("search_api_fulltext=GPU+computing");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("should combine query with other filters", async () => {
|
|
160
|
+
mockHttpClient.get.mockResolvedValue({
|
|
161
|
+
status: 200,
|
|
162
|
+
data: [],
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await server["handleToolCall"]({
|
|
166
|
+
method: "tools/call",
|
|
167
|
+
params: {
|
|
168
|
+
name: "search_announcements",
|
|
169
|
+
arguments: {
|
|
170
|
+
query: "workshop",
|
|
171
|
+
tags: "training",
|
|
172
|
+
date: "this_month",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const url = mockHttpClient.get.mock.calls[0][0];
|
|
178
|
+
expect(url).toContain("search_api_fulltext=workshop");
|
|
179
|
+
expect(url).toContain("tags=training");
|
|
180
|
+
expect(url).toContain("relative_start_date=-1+month");
|
|
106
181
|
});
|
|
107
182
|
});
|
|
108
183
|
|
|
109
|
-
describe("
|
|
184
|
+
describe("search by tags", () => {
|
|
110
185
|
it("should fetch announcements by specific tags", async () => {
|
|
111
186
|
const mockResponse = {
|
|
112
187
|
status: 200,
|
|
@@ -114,10 +189,10 @@ describe("AnnouncementsServer", () => {
|
|
|
114
189
|
{
|
|
115
190
|
title: "GPU Maintenance",
|
|
116
191
|
body: "GPU nodes maintenance",
|
|
117
|
-
|
|
192
|
+
published_date: "2024-03-15",
|
|
118
193
|
author: "Support",
|
|
119
|
-
|
|
120
|
-
|
|
194
|
+
tags: ["gpu", "maintenance"],
|
|
195
|
+
affinity_group: [],
|
|
121
196
|
},
|
|
122
197
|
],
|
|
123
198
|
};
|
|
@@ -125,37 +200,36 @@ describe("AnnouncementsServer", () => {
|
|
|
125
200
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
126
201
|
|
|
127
202
|
const result = await server["handleToolCall"]({
|
|
203
|
+
method: "tools/call",
|
|
128
204
|
params: {
|
|
129
|
-
name: "
|
|
205
|
+
name: "search_announcements",
|
|
130
206
|
arguments: {
|
|
131
207
|
tags: "gpu,maintenance",
|
|
132
208
|
limit: 20,
|
|
133
209
|
},
|
|
134
210
|
},
|
|
135
|
-
}
|
|
211
|
+
});
|
|
136
212
|
|
|
137
213
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
138
214
|
expect(url).toContain("tags=gpu%2Cmaintenance");
|
|
139
|
-
// Note: exact_match is not implemented in the server
|
|
140
215
|
|
|
141
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
142
|
-
expect(responseData.
|
|
143
|
-
expect(responseData.
|
|
216
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
217
|
+
expect(responseData.items[0].tags).toContain("gpu");
|
|
218
|
+
expect(responseData.items[0].tags).toContain("maintenance");
|
|
144
219
|
});
|
|
145
220
|
});
|
|
146
221
|
|
|
147
|
-
describe("
|
|
148
|
-
it("should
|
|
222
|
+
describe("search with limit", () => {
|
|
223
|
+
it("should respect limit parameter", async () => {
|
|
149
224
|
const mockResponse = {
|
|
150
225
|
status: 200,
|
|
151
226
|
data: [
|
|
152
227
|
{
|
|
153
|
-
title: "
|
|
154
|
-
body: "
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
custom_announcement_ag: "ai-ml-123",
|
|
228
|
+
title: "Update 1",
|
|
229
|
+
body: "Body 1",
|
|
230
|
+
published_date: "2024-03-14",
|
|
231
|
+
tags: ["ai"],
|
|
232
|
+
affinity_group: [],
|
|
159
233
|
},
|
|
160
234
|
],
|
|
161
235
|
};
|
|
@@ -163,37 +237,35 @@ describe("AnnouncementsServer", () => {
|
|
|
163
237
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
164
238
|
|
|
165
239
|
const result = await server["handleToolCall"]({
|
|
240
|
+
method: "tools/call",
|
|
166
241
|
params: {
|
|
167
|
-
name: "
|
|
242
|
+
name: "search_announcements",
|
|
168
243
|
arguments: {
|
|
169
|
-
ag: "ai-ml-123",
|
|
170
244
|
limit: 5,
|
|
171
245
|
},
|
|
172
246
|
},
|
|
173
|
-
}
|
|
247
|
+
});
|
|
174
248
|
|
|
175
249
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
176
|
-
expect(url).toContain("
|
|
177
|
-
// Note: limit is handled in JavaScript, not in URL
|
|
250
|
+
expect(url).toContain("items_per_page=5");
|
|
178
251
|
|
|
179
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
180
|
-
expect(responseData.
|
|
181
|
-
expect(responseData.announcements[0].affinity_groups).toContain("ai-ml-123");
|
|
252
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
253
|
+
expect(responseData.items).toHaveLength(1);
|
|
182
254
|
});
|
|
183
255
|
});
|
|
184
256
|
|
|
185
|
-
describe("
|
|
186
|
-
it("should fetch
|
|
257
|
+
describe("recent announcements with date filters", () => {
|
|
258
|
+
it("should fetch announcements with date filter", async () => {
|
|
187
259
|
const mockResponse = {
|
|
188
260
|
status: 200,
|
|
189
261
|
data: [
|
|
190
262
|
{
|
|
191
263
|
title: "Today's Update",
|
|
192
264
|
body: "Important update",
|
|
193
|
-
|
|
265
|
+
published_date: new Date().toISOString(),
|
|
194
266
|
author: "Admin",
|
|
195
|
-
|
|
196
|
-
|
|
267
|
+
tags: ["urgent"],
|
|
268
|
+
affinity_group: [],
|
|
197
269
|
},
|
|
198
270
|
],
|
|
199
271
|
};
|
|
@@ -201,36 +273,39 @@ describe("AnnouncementsServer", () => {
|
|
|
201
273
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
202
274
|
|
|
203
275
|
const result = await server["handleToolCall"]({
|
|
276
|
+
method: "tools/call",
|
|
204
277
|
params: {
|
|
205
|
-
name: "
|
|
278
|
+
name: "search_announcements",
|
|
206
279
|
arguments: {
|
|
207
|
-
|
|
280
|
+
date: "this_week",
|
|
208
281
|
},
|
|
209
282
|
},
|
|
210
|
-
}
|
|
283
|
+
});
|
|
211
284
|
|
|
212
285
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
213
286
|
expect(url).toContain("relative_start_date=-1+week");
|
|
214
287
|
|
|
215
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
216
|
-
expect(responseData.
|
|
288
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
289
|
+
expect(responseData.items).toHaveLength(1);
|
|
217
290
|
});
|
|
218
291
|
|
|
219
|
-
it("should
|
|
292
|
+
it("should handle search with no date filter", async () => {
|
|
220
293
|
mockHttpClient.get.mockResolvedValue({
|
|
221
294
|
status: 200,
|
|
222
295
|
data: [],
|
|
223
296
|
});
|
|
224
297
|
|
|
225
298
|
await server["handleToolCall"]({
|
|
299
|
+
method: "tools/call",
|
|
226
300
|
params: {
|
|
227
|
-
name: "
|
|
301
|
+
name: "search_announcements",
|
|
228
302
|
arguments: {},
|
|
229
303
|
},
|
|
230
|
-
}
|
|
304
|
+
});
|
|
231
305
|
|
|
306
|
+
expect(mockHttpClient.get).toHaveBeenCalled();
|
|
232
307
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
233
|
-
expect(url).toContain("
|
|
308
|
+
expect(url).toContain("/api/2.2/announcements");
|
|
234
309
|
});
|
|
235
310
|
});
|
|
236
311
|
});
|
|
@@ -243,58 +318,53 @@ describe("AnnouncementsServer", () => {
|
|
|
243
318
|
});
|
|
244
319
|
|
|
245
320
|
await server["handleToolCall"]({
|
|
321
|
+
method: "tools/call",
|
|
246
322
|
params: {
|
|
247
|
-
name: "
|
|
323
|
+
name: "search_announcements",
|
|
248
324
|
arguments: {
|
|
249
325
|
tags: "gpu,maintenance",
|
|
250
|
-
|
|
251
|
-
start_date: "2024-01-01",
|
|
252
|
-
end_date: "2024-12-31",
|
|
326
|
+
date: "this_month",
|
|
253
327
|
limit: 20,
|
|
254
328
|
},
|
|
255
329
|
},
|
|
256
|
-
}
|
|
330
|
+
});
|
|
257
331
|
|
|
258
332
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
259
333
|
expect(url).toContain("tags=gpu%2Cmaintenance");
|
|
260
|
-
expect(url).toContain("
|
|
261
|
-
expect(url).toContain("start_date=2024-01-01");
|
|
262
|
-
expect(url).toContain("end_date=2024-12-31");
|
|
263
|
-
// Note: exact_match and limit are not URL parameters
|
|
334
|
+
expect(url).toContain("relative_start_date=-1+month");
|
|
264
335
|
});
|
|
265
336
|
|
|
266
|
-
it("should handle
|
|
337
|
+
it("should handle date filters", async () => {
|
|
267
338
|
mockHttpClient.get.mockResolvedValue({
|
|
268
339
|
status: 200,
|
|
269
340
|
data: [],
|
|
270
341
|
});
|
|
271
342
|
|
|
272
343
|
await server["handleToolCall"]({
|
|
344
|
+
method: "tools/call",
|
|
273
345
|
params: {
|
|
274
|
-
name: "
|
|
346
|
+
name: "search_announcements",
|
|
275
347
|
arguments: {
|
|
276
|
-
|
|
277
|
-
relative_end_date: "+1month",
|
|
348
|
+
date: "today",
|
|
278
349
|
},
|
|
279
350
|
},
|
|
280
|
-
}
|
|
351
|
+
});
|
|
281
352
|
|
|
282
353
|
const url = mockHttpClient.get.mock.calls[0][0];
|
|
283
354
|
expect(url).toContain("relative_start_date=today");
|
|
284
|
-
expect(url).toContain("relative_end_date=%2B1month");
|
|
285
355
|
});
|
|
286
356
|
});
|
|
287
357
|
|
|
288
358
|
describe("Data Enhancement", () => {
|
|
289
|
-
it("should parse tags correctly", async () => {
|
|
359
|
+
it("should parse tags array correctly", async () => {
|
|
290
360
|
const mockResponse = {
|
|
291
361
|
status: 200,
|
|
292
362
|
data: [
|
|
293
363
|
{
|
|
294
364
|
title: "Test",
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
365
|
+
published_date: "2024-03-15",
|
|
366
|
+
tags: ["tag1", "tag2", "tag3"],
|
|
367
|
+
affinity_group: [],
|
|
298
368
|
},
|
|
299
369
|
],
|
|
300
370
|
};
|
|
@@ -302,50 +372,106 @@ describe("AnnouncementsServer", () => {
|
|
|
302
372
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
303
373
|
|
|
304
374
|
const result = await server["handleToolCall"]({
|
|
375
|
+
method: "tools/call",
|
|
305
376
|
params: {
|
|
306
|
-
name: "
|
|
377
|
+
name: "search_announcements",
|
|
307
378
|
arguments: {},
|
|
308
379
|
},
|
|
309
|
-
}
|
|
380
|
+
});
|
|
310
381
|
|
|
311
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
312
|
-
expect(responseData.
|
|
382
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
383
|
+
expect(responseData.items[0].tags).toEqual(["tag1", "tag2", "tag3"]);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("should parse tags from comma-separated string", async () => {
|
|
387
|
+
const mockResponse = {
|
|
388
|
+
status: 200,
|
|
389
|
+
data: [
|
|
390
|
+
{
|
|
391
|
+
title: "Test",
|
|
392
|
+
published_date: "2024-03-15",
|
|
393
|
+
tags: "gpu, machine-learning, hpc",
|
|
394
|
+
affinity_group: [],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
400
|
+
|
|
401
|
+
const result = await server["handleToolCall"]({
|
|
402
|
+
method: "tools/call",
|
|
403
|
+
params: {
|
|
404
|
+
name: "search_announcements",
|
|
405
|
+
arguments: {},
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
410
|
+
expect(responseData.items[0].tags).toEqual(["gpu", "machine-learning", "hpc"]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("should handle empty tags string", async () => {
|
|
414
|
+
const mockResponse = {
|
|
415
|
+
status: 200,
|
|
416
|
+
data: [
|
|
417
|
+
{
|
|
418
|
+
title: "Test",
|
|
419
|
+
published_date: "2024-03-15",
|
|
420
|
+
tags: "",
|
|
421
|
+
affinity_group: [],
|
|
422
|
+
},
|
|
423
|
+
],
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
427
|
+
|
|
428
|
+
const result = await server["handleToolCall"]({
|
|
429
|
+
method: "tools/call",
|
|
430
|
+
params: {
|
|
431
|
+
name: "search_announcements",
|
|
432
|
+
arguments: {},
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
437
|
+
expect(responseData.items[0].tags).toEqual([]);
|
|
313
438
|
});
|
|
314
439
|
|
|
315
440
|
it("should extract popular tags", async () => {
|
|
316
441
|
const mockResponse = {
|
|
317
442
|
status: 200,
|
|
318
443
|
data: [
|
|
319
|
-
{ title: "1",
|
|
320
|
-
{ title: "2",
|
|
321
|
-
{ title: "3",
|
|
322
|
-
{ title: "4",
|
|
444
|
+
{ title: "1", published_date: "2024-03-15", tags: ["gpu", "maintenance"], affinity_group: [] },
|
|
445
|
+
{ title: "2", published_date: "2024-03-14", tags: ["gpu", "network"], affinity_group: [] },
|
|
446
|
+
{ title: "3", published_date: "2024-03-13", tags: ["gpu", "storage"], affinity_group: [] },
|
|
447
|
+
{ title: "4", published_date: "2024-03-12", tags: ["maintenance"], affinity_group: [] },
|
|
323
448
|
],
|
|
324
449
|
};
|
|
325
450
|
|
|
326
451
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
327
452
|
|
|
328
453
|
const result = await server["handleToolCall"]({
|
|
454
|
+
method: "tools/call",
|
|
329
455
|
params: {
|
|
330
|
-
name: "
|
|
456
|
+
name: "search_announcements",
|
|
331
457
|
arguments: {},
|
|
332
458
|
},
|
|
333
|
-
}
|
|
459
|
+
});
|
|
334
460
|
|
|
335
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
336
|
-
|
|
337
|
-
expect(responseData.
|
|
461
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
462
|
+
// Popular tags are in metadata, not in the universal {total, items} format
|
|
463
|
+
expect(responseData.items).toHaveLength(4);
|
|
338
464
|
});
|
|
339
465
|
|
|
340
|
-
it("should
|
|
466
|
+
it("should include published_date in items", async () => {
|
|
341
467
|
const mockResponse = {
|
|
342
468
|
status: 200,
|
|
343
469
|
data: [
|
|
344
470
|
{
|
|
345
471
|
title: "Test",
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
472
|
+
published_date: "2024-03-15",
|
|
473
|
+
tags: [],
|
|
474
|
+
affinity_group: [],
|
|
349
475
|
},
|
|
350
476
|
],
|
|
351
477
|
};
|
|
@@ -353,15 +479,816 @@ describe("AnnouncementsServer", () => {
|
|
|
353
479
|
mockHttpClient.get.mockResolvedValue(mockResponse);
|
|
354
480
|
|
|
355
481
|
const result = await server["handleToolCall"]({
|
|
482
|
+
method: "tools/call",
|
|
483
|
+
params: {
|
|
484
|
+
name: "search_announcements",
|
|
485
|
+
arguments: {},
|
|
486
|
+
},
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
490
|
+
expect(responseData.items[0].published_date).toBe("2024-03-15");
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("CRUD Operations", () => {
|
|
495
|
+
let mockDrupalAuth: {
|
|
496
|
+
ensureAuthenticated: Mock;
|
|
497
|
+
getUserUuid: Mock;
|
|
498
|
+
get: Mock;
|
|
499
|
+
post: Mock;
|
|
500
|
+
patch: Mock;
|
|
501
|
+
delete: Mock;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
beforeEach(() => {
|
|
505
|
+
// Set up environment variables for CRUD operations
|
|
506
|
+
process.env.DRUPAL_API_URL = "https://test.drupal.site";
|
|
507
|
+
process.env.DRUPAL_USERNAME = "test_user";
|
|
508
|
+
process.env.DRUPAL_PASSWORD = "test_password";
|
|
509
|
+
process.env.ACTING_USER_UID = "1985";
|
|
510
|
+
|
|
511
|
+
// Create a fresh mock for each test
|
|
512
|
+
mockDrupalAuth = {
|
|
513
|
+
ensureAuthenticated: vi.fn().mockResolvedValue(undefined),
|
|
514
|
+
getUserUuid: vi.fn().mockReturnValue("user-uuid-123"),
|
|
515
|
+
get: vi.fn(),
|
|
516
|
+
post: vi.fn(),
|
|
517
|
+
patch: vi.fn(),
|
|
518
|
+
delete: vi.fn(),
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Mock the DrupalAuthProvider constructor to return our mock
|
|
522
|
+
(DrupalAuthProvider as unknown as Mock).mockImplementation(() => mockDrupalAuth);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
afterEach(() => {
|
|
526
|
+
delete process.env.DRUPAL_API_URL;
|
|
527
|
+
delete process.env.DRUPAL_USERNAME;
|
|
528
|
+
delete process.env.DRUPAL_PASSWORD;
|
|
529
|
+
delete process.env.ACTING_USER_UID;
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
describe("create_announcement", () => {
|
|
533
|
+
it("should create an announcement with required fields", async () => {
|
|
534
|
+
// Mock user UUID lookup for ACTING_USER_UID
|
|
535
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
536
|
+
data: [{ id: "acting-user-uuid-123" }],
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
mockDrupalAuth.post.mockResolvedValue({
|
|
540
|
+
data: {
|
|
541
|
+
id: "new-announcement-uuid",
|
|
542
|
+
attributes: {
|
|
543
|
+
title: "Test Announcement",
|
|
544
|
+
drupal_internal__nid: 12345,
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const result = await server["handleToolCall"]({
|
|
550
|
+
method: "tools/call",
|
|
551
|
+
params: {
|
|
552
|
+
name: "create_announcement",
|
|
553
|
+
arguments: {
|
|
554
|
+
title: "Test Announcement",
|
|
555
|
+
body: "<p>This is a test</p>",
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Should have looked up the acting user UUID
|
|
561
|
+
expect(mockDrupalAuth.get).toHaveBeenCalledWith(
|
|
562
|
+
"/jsonapi/user/user?filter[drupal_internal__uid]=1985"
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
expect(mockDrupalAuth.post).toHaveBeenCalledWith(
|
|
566
|
+
"/jsonapi/node/access_news",
|
|
567
|
+
expect.objectContaining({
|
|
568
|
+
data: expect.objectContaining({
|
|
569
|
+
type: "node--access_news",
|
|
570
|
+
attributes: expect.objectContaining({
|
|
571
|
+
title: "Test Announcement",
|
|
572
|
+
moderation_state: "draft",
|
|
573
|
+
body: expect.objectContaining({
|
|
574
|
+
value: "<p>This is a test</p>",
|
|
575
|
+
format: "basic_html",
|
|
576
|
+
}),
|
|
577
|
+
}),
|
|
578
|
+
relationships: expect.objectContaining({
|
|
579
|
+
uid: {
|
|
580
|
+
data: {
|
|
581
|
+
type: "user--user",
|
|
582
|
+
id: "acting-user-uuid-123",
|
|
583
|
+
},
|
|
584
|
+
},
|
|
585
|
+
}),
|
|
586
|
+
}),
|
|
587
|
+
})
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
591
|
+
expect(responseData.success).toBe(true);
|
|
592
|
+
expect(responseData.uuid).toBe("new-announcement-uuid");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("should look up tags by name when provided (with caching)", async () => {
|
|
596
|
+
// Mock user lookup first, then bulk tag fetch for cache
|
|
597
|
+
mockDrupalAuth.get
|
|
598
|
+
.mockResolvedValueOnce({ data: [{ id: "acting-user-uuid" }] }) // user lookup
|
|
599
|
+
.mockResolvedValueOnce({
|
|
600
|
+
data: [
|
|
601
|
+
{ id: "tag-uuid-1", attributes: { name: "gpu" } },
|
|
602
|
+
{ id: "tag-uuid-2", attributes: { name: "maintenance" } },
|
|
603
|
+
{ id: "tag-uuid-3", attributes: { name: "hpc" } },
|
|
604
|
+
]
|
|
605
|
+
}); // bulk tag cache fetch
|
|
606
|
+
|
|
607
|
+
mockDrupalAuth.post.mockResolvedValue({
|
|
608
|
+
data: {
|
|
609
|
+
id: "new-announcement-uuid",
|
|
610
|
+
attributes: { title: "Test" },
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
await server["handleToolCall"]({
|
|
615
|
+
method: "tools/call",
|
|
616
|
+
params: {
|
|
617
|
+
name: "create_announcement",
|
|
618
|
+
arguments: {
|
|
619
|
+
title: "Test",
|
|
620
|
+
body: "Body",
|
|
621
|
+
tags: ["gpu", "maintenance"],
|
|
622
|
+
},
|
|
623
|
+
},
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Should have looked up user, then fetched all tags for cache
|
|
627
|
+
expect(mockDrupalAuth.get).toHaveBeenCalledWith(
|
|
628
|
+
"/jsonapi/user/user?filter[drupal_internal__uid]=1985"
|
|
629
|
+
);
|
|
630
|
+
expect(mockDrupalAuth.get).toHaveBeenCalledWith(
|
|
631
|
+
"/jsonapi/taxonomy_term/tags?page[limit]=500"
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
// Should have created with the correct tag UUIDs
|
|
635
|
+
expect(mockDrupalAuth.post).toHaveBeenCalledWith(
|
|
636
|
+
"/jsonapi/node/access_news",
|
|
637
|
+
expect.objectContaining({
|
|
638
|
+
data: expect.objectContaining({
|
|
639
|
+
relationships: expect.objectContaining({
|
|
640
|
+
field_tags: {
|
|
641
|
+
data: [
|
|
642
|
+
{ type: "taxonomy_term--tags", id: "tag-uuid-1" },
|
|
643
|
+
{ type: "taxonomy_term--tags", id: "tag-uuid-2" },
|
|
644
|
+
],
|
|
645
|
+
},
|
|
646
|
+
}),
|
|
647
|
+
}),
|
|
648
|
+
})
|
|
649
|
+
);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("should fail without credentials", async () => {
|
|
653
|
+
delete process.env.DRUPAL_API_URL;
|
|
654
|
+
|
|
655
|
+
const result = await server["handleToolCall"]({
|
|
656
|
+
method: "tools/call",
|
|
657
|
+
params: {
|
|
658
|
+
name: "create_announcement",
|
|
659
|
+
arguments: {
|
|
660
|
+
title: "Test",
|
|
661
|
+
body: "Body",
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
667
|
+
expect(responseData.error).toContain("DRUPAL_API_URL");
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
it("should fail without ACTING_USER_UID", async () => {
|
|
671
|
+
delete process.env.ACTING_USER_UID;
|
|
672
|
+
|
|
673
|
+
const result = await server["handleToolCall"]({
|
|
674
|
+
method: "tools/call",
|
|
675
|
+
params: {
|
|
676
|
+
name: "create_announcement",
|
|
677
|
+
arguments: {
|
|
678
|
+
title: "Test",
|
|
679
|
+
body: "Body",
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
685
|
+
expect(responseData.error).toContain("ACTING_USER_UID");
|
|
686
|
+
expect(responseData.error).toContain("No acting user specified");
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("should create announcement with external link", async () => {
|
|
690
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
691
|
+
data: [{ id: "acting-user-uuid-123" }],
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
mockDrupalAuth.post.mockResolvedValue({
|
|
695
|
+
data: {
|
|
696
|
+
id: "new-announcement-uuid",
|
|
697
|
+
attributes: {
|
|
698
|
+
title: "Test with Link",
|
|
699
|
+
drupal_internal__nid: 12345,
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
await server["handleToolCall"]({
|
|
705
|
+
method: "tools/call",
|
|
706
|
+
params: {
|
|
707
|
+
name: "create_announcement",
|
|
708
|
+
arguments: {
|
|
709
|
+
title: "Test with Link",
|
|
710
|
+
body: "<p>Body content</p>",
|
|
711
|
+
summary: "Test summary",
|
|
712
|
+
external_link: {
|
|
713
|
+
uri: "https://example.com/resource",
|
|
714
|
+
title: "Learn more",
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
expect(mockDrupalAuth.post).toHaveBeenCalledWith(
|
|
721
|
+
"/jsonapi/node/access_news",
|
|
722
|
+
expect.objectContaining({
|
|
723
|
+
data: expect.objectContaining({
|
|
724
|
+
attributes: expect.objectContaining({
|
|
725
|
+
field_news_external_link: {
|
|
726
|
+
uri: "https://example.com/resource",
|
|
727
|
+
title: "Learn more",
|
|
728
|
+
},
|
|
729
|
+
}),
|
|
730
|
+
}),
|
|
731
|
+
})
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it("should create announcement with where_to_share", async () => {
|
|
736
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
737
|
+
data: [{ id: "acting-user-uuid-123" }],
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
mockDrupalAuth.post.mockResolvedValue({
|
|
741
|
+
data: {
|
|
742
|
+
id: "new-announcement-uuid",
|
|
743
|
+
attributes: {
|
|
744
|
+
title: "Test with sharing",
|
|
745
|
+
drupal_internal__nid: 12345,
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
await server["handleToolCall"]({
|
|
751
|
+
method: "tools/call",
|
|
752
|
+
params: {
|
|
753
|
+
name: "create_announcement",
|
|
754
|
+
arguments: {
|
|
755
|
+
title: "Test with sharing",
|
|
756
|
+
body: "<p>Body content</p>",
|
|
757
|
+
summary: "Test summary",
|
|
758
|
+
where_to_share: ["Announcements page", "Bi-Weekly Digest"],
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
expect(mockDrupalAuth.post).toHaveBeenCalledWith(
|
|
764
|
+
"/jsonapi/node/access_news",
|
|
765
|
+
expect.objectContaining({
|
|
766
|
+
data: expect.objectContaining({
|
|
767
|
+
attributes: expect.objectContaining({
|
|
768
|
+
field_choose_where_to_share_this: [
|
|
769
|
+
"on_the_announcements_page",
|
|
770
|
+
"in_the_access_support_bi_weekly_digest",
|
|
771
|
+
],
|
|
772
|
+
}),
|
|
773
|
+
}),
|
|
774
|
+
})
|
|
775
|
+
);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("should fail with invalid where_to_share value", async () => {
|
|
779
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
780
|
+
data: [{ id: "acting-user-uuid-123" }],
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
const result = await server["handleToolCall"]({
|
|
784
|
+
method: "tools/call",
|
|
785
|
+
params: {
|
|
786
|
+
name: "create_announcement",
|
|
787
|
+
arguments: {
|
|
788
|
+
title: "Test",
|
|
789
|
+
body: "Body",
|
|
790
|
+
summary: "Summary",
|
|
791
|
+
where_to_share: ["Invalid Option"],
|
|
792
|
+
},
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
797
|
+
expect(responseData.error).toContain("Invalid where_to_share value");
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("should create announcement with affinity group", async () => {
|
|
801
|
+
// User lookup
|
|
802
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
803
|
+
data: [{ id: "acting-user-uuid-123" }],
|
|
804
|
+
});
|
|
805
|
+
// Affinity group lookup by title
|
|
806
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
807
|
+
data: [],
|
|
808
|
+
});
|
|
809
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
810
|
+
data: [{ id: "group-uuid-456", attributes: { title: "Test Group" } }],
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
mockDrupalAuth.post.mockResolvedValue({
|
|
814
|
+
data: {
|
|
815
|
+
id: "new-announcement-uuid",
|
|
816
|
+
attributes: {
|
|
817
|
+
title: "Test with group",
|
|
818
|
+
drupal_internal__nid: 12345,
|
|
819
|
+
},
|
|
820
|
+
},
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
await server["handleToolCall"]({
|
|
824
|
+
method: "tools/call",
|
|
825
|
+
params: {
|
|
826
|
+
name: "create_announcement",
|
|
827
|
+
arguments: {
|
|
828
|
+
title: "Test with group",
|
|
829
|
+
body: "<p>Body content</p>",
|
|
830
|
+
summary: "Test summary",
|
|
831
|
+
affinity_group: "Test Group",
|
|
832
|
+
},
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
expect(mockDrupalAuth.post).toHaveBeenCalledWith(
|
|
837
|
+
"/jsonapi/node/access_news",
|
|
838
|
+
expect.objectContaining({
|
|
839
|
+
data: expect.objectContaining({
|
|
840
|
+
relationships: expect.objectContaining({
|
|
841
|
+
field_affinity_group_node: {
|
|
842
|
+
data: {
|
|
843
|
+
type: "node--affinity_group",
|
|
844
|
+
id: "group-uuid-456",
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
}),
|
|
848
|
+
}),
|
|
849
|
+
})
|
|
850
|
+
);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("should fail when affinity group not found", async () => {
|
|
854
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
855
|
+
data: [{ id: "acting-user-uuid-123" }],
|
|
856
|
+
});
|
|
857
|
+
// Both lookups return empty
|
|
858
|
+
mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
|
|
859
|
+
mockDrupalAuth.get.mockResolvedValueOnce({ data: [] });
|
|
860
|
+
|
|
861
|
+
const result = await server["handleToolCall"]({
|
|
862
|
+
method: "tools/call",
|
|
863
|
+
params: {
|
|
864
|
+
name: "create_announcement",
|
|
865
|
+
arguments: {
|
|
866
|
+
title: "Test",
|
|
867
|
+
body: "Body",
|
|
868
|
+
summary: "Summary",
|
|
869
|
+
affinity_group: "Nonexistent Group",
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
875
|
+
expect(responseData.error).toContain("Affinity group not found");
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
describe("update_announcement", () => {
|
|
880
|
+
it("should update an announcement", async () => {
|
|
881
|
+
mockDrupalAuth.patch.mockResolvedValue({
|
|
882
|
+
data: {
|
|
883
|
+
id: "announcement-uuid",
|
|
884
|
+
attributes: { title: "Updated Title" },
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
const result = await server["handleToolCall"]({
|
|
889
|
+
method: "tools/call",
|
|
890
|
+
params: {
|
|
891
|
+
name: "update_announcement",
|
|
892
|
+
arguments: {
|
|
893
|
+
uuid: "announcement-uuid",
|
|
894
|
+
title: "Updated Title",
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
expect(mockDrupalAuth.patch).toHaveBeenCalledWith(
|
|
900
|
+
"/jsonapi/node/access_news/announcement-uuid",
|
|
901
|
+
expect.objectContaining({
|
|
902
|
+
data: expect.objectContaining({
|
|
903
|
+
id: "announcement-uuid",
|
|
904
|
+
attributes: expect.objectContaining({
|
|
905
|
+
title: "Updated Title",
|
|
906
|
+
}),
|
|
907
|
+
}),
|
|
908
|
+
})
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
912
|
+
expect(responseData.success).toBe(true);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("should preserve existing body when updating summary only", async () => {
|
|
916
|
+
// First call: fetch existing announcement
|
|
917
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
918
|
+
data: {
|
|
919
|
+
attributes: {
|
|
920
|
+
body: {
|
|
921
|
+
value: "<p>Existing body content</p>",
|
|
922
|
+
summary: "Old summary",
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
},
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
mockDrupalAuth.patch.mockResolvedValue({
|
|
929
|
+
data: {
|
|
930
|
+
id: "announcement-uuid",
|
|
931
|
+
attributes: { title: "Test" },
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
|
|
935
|
+
await server["handleToolCall"]({
|
|
936
|
+
method: "tools/call",
|
|
937
|
+
params: {
|
|
938
|
+
name: "update_announcement",
|
|
939
|
+
arguments: {
|
|
940
|
+
uuid: "announcement-uuid",
|
|
941
|
+
summary: "New summary only",
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
expect(mockDrupalAuth.patch).toHaveBeenCalledWith(
|
|
947
|
+
"/jsonapi/node/access_news/announcement-uuid",
|
|
948
|
+
expect.objectContaining({
|
|
949
|
+
data: expect.objectContaining({
|
|
950
|
+
attributes: expect.objectContaining({
|
|
951
|
+
body: {
|
|
952
|
+
value: "<p>Existing body content</p>",
|
|
953
|
+
format: "basic_html",
|
|
954
|
+
summary: "New summary only",
|
|
955
|
+
},
|
|
956
|
+
}),
|
|
957
|
+
}),
|
|
958
|
+
})
|
|
959
|
+
);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("should update with tags", async () => {
|
|
963
|
+
// Tag cache fetch
|
|
964
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
965
|
+
data: [
|
|
966
|
+
{ id: "tag-uuid-1", attributes: { name: "gpu" } },
|
|
967
|
+
{ id: "tag-uuid-2", attributes: { name: "hpc" } },
|
|
968
|
+
],
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
mockDrupalAuth.patch.mockResolvedValue({
|
|
972
|
+
data: {
|
|
973
|
+
id: "announcement-uuid",
|
|
974
|
+
attributes: { title: "Test" },
|
|
975
|
+
},
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
await server["handleToolCall"]({
|
|
979
|
+
method: "tools/call",
|
|
980
|
+
params: {
|
|
981
|
+
name: "update_announcement",
|
|
982
|
+
arguments: {
|
|
983
|
+
uuid: "announcement-uuid",
|
|
984
|
+
tags: ["gpu", "hpc"],
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
expect(mockDrupalAuth.patch).toHaveBeenCalledWith(
|
|
990
|
+
"/jsonapi/node/access_news/announcement-uuid",
|
|
991
|
+
expect.objectContaining({
|
|
992
|
+
data: expect.objectContaining({
|
|
993
|
+
relationships: expect.objectContaining({
|
|
994
|
+
field_tags: {
|
|
995
|
+
data: [
|
|
996
|
+
{ type: "taxonomy_term--tags", id: "tag-uuid-1" },
|
|
997
|
+
{ type: "taxonomy_term--tags", id: "tag-uuid-2" },
|
|
998
|
+
],
|
|
999
|
+
},
|
|
1000
|
+
}),
|
|
1001
|
+
}),
|
|
1002
|
+
})
|
|
1003
|
+
);
|
|
1004
|
+
});
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
describe("delete_announcement", () => {
|
|
1008
|
+
it("should delete an announcement when confirmed", async () => {
|
|
1009
|
+
mockDrupalAuth.delete.mockResolvedValue({});
|
|
1010
|
+
|
|
1011
|
+
const result = await server["handleToolCall"]({
|
|
1012
|
+
method: "tools/call",
|
|
1013
|
+
params: {
|
|
1014
|
+
name: "delete_announcement",
|
|
1015
|
+
arguments: {
|
|
1016
|
+
uuid: "announcement-to-delete",
|
|
1017
|
+
confirmed: true,
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
expect(mockDrupalAuth.delete).toHaveBeenCalledWith(
|
|
1023
|
+
"/jsonapi/node/access_news/announcement-to-delete"
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
1027
|
+
expect(responseData.success).toBe(true);
|
|
1028
|
+
expect(responseData.uuid).toBe("announcement-to-delete");
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("should reject deletion without confirmation", async () => {
|
|
1032
|
+
const result = await server["handleToolCall"]({
|
|
1033
|
+
method: "tools/call",
|
|
1034
|
+
params: {
|
|
1035
|
+
name: "delete_announcement",
|
|
1036
|
+
arguments: {
|
|
1037
|
+
uuid: "announcement-to-delete",
|
|
1038
|
+
confirmed: false,
|
|
1039
|
+
},
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// Should not call delete
|
|
1044
|
+
expect(mockDrupalAuth.delete).not.toHaveBeenCalled();
|
|
1045
|
+
|
|
1046
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
1047
|
+
expect(responseData.error).toContain("explicit confirmation");
|
|
1048
|
+
});
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
describe("get_my_announcements", () => {
|
|
1052
|
+
it("should fetch announcements for acting user", async () => {
|
|
1053
|
+
// First call: user UUID lookup, Second call: announcements
|
|
1054
|
+
mockDrupalAuth.get
|
|
1055
|
+
.mockResolvedValueOnce({ data: [{ id: "acting-user-uuid-456" }] })
|
|
1056
|
+
.mockResolvedValueOnce({
|
|
1057
|
+
data: [
|
|
1058
|
+
{
|
|
1059
|
+
id: "announcement-1",
|
|
1060
|
+
attributes: {
|
|
1061
|
+
title: "My First Announcement",
|
|
1062
|
+
status: false,
|
|
1063
|
+
created: "2024-03-15T10:00:00Z",
|
|
1064
|
+
body: { value: "<p>Content</p>", summary: "Summary" },
|
|
1065
|
+
},
|
|
1066
|
+
},
|
|
1067
|
+
],
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const result = await server["handleToolCall"]({
|
|
1071
|
+
method: "tools/call",
|
|
1072
|
+
params: {
|
|
1073
|
+
name: "get_my_announcements",
|
|
1074
|
+
arguments: { limit: 10 },
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
// Should look up acting user UUID first
|
|
1079
|
+
expect(mockDrupalAuth.get).toHaveBeenCalledWith(
|
|
1080
|
+
"/jsonapi/user/user?filter[drupal_internal__uid]=1985"
|
|
1081
|
+
);
|
|
1082
|
+
// Then fetch announcements for that user
|
|
1083
|
+
expect(mockDrupalAuth.get).toHaveBeenCalledWith(
|
|
1084
|
+
expect.stringContaining("filter[uid.id]=acting-user-uuid-456")
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
1088
|
+
expect(responseData.items).toHaveLength(1);
|
|
1089
|
+
expect(responseData.items[0].title).toBe("My First Announcement");
|
|
1090
|
+
expect(responseData.items[0].status).toBe("draft");
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
describe("get_announcement_context", () => {
|
|
1095
|
+
it("should return tags and affinity groups for coordinator", async () => {
|
|
1096
|
+
// First call: get user UUID
|
|
1097
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
1098
|
+
data: [{ id: "user-uuid-123", attributes: { name: "testuser" } }],
|
|
1099
|
+
});
|
|
1100
|
+
// Second call: get tags (parallel)
|
|
1101
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
1102
|
+
data: [
|
|
1103
|
+
{ id: "tag-1", attributes: { name: "gpu" } },
|
|
1104
|
+
{ id: "tag-2", attributes: { name: "hpc" } },
|
|
1105
|
+
],
|
|
1106
|
+
});
|
|
1107
|
+
// Third call: get affinity groups (parallel)
|
|
1108
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
1109
|
+
data: [
|
|
1110
|
+
{
|
|
1111
|
+
id: "group-uuid-1",
|
|
1112
|
+
attributes: {
|
|
1113
|
+
title: "Test Group",
|
|
1114
|
+
field_group_id: 123,
|
|
1115
|
+
field_affinity_group_category: "Research"
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
],
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
const result = await server["handleToolCall"]({
|
|
1122
|
+
method: "tools/call",
|
|
1123
|
+
params: {
|
|
1124
|
+
name: "get_announcement_context",
|
|
1125
|
+
arguments: {},
|
|
1126
|
+
},
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
1130
|
+
expect(responseData.tags).toHaveLength(2);
|
|
1131
|
+
expect(responseData.tags[0].name).toBe("gpu");
|
|
1132
|
+
expect(responseData.affinity_groups).toHaveLength(1);
|
|
1133
|
+
expect(responseData.affinity_groups[0].name).toBe("Test Group");
|
|
1134
|
+
expect(responseData.is_coordinator).toBe(true);
|
|
1135
|
+
expect(responseData.affiliations).toContain("ACCESS Collaboration");
|
|
1136
|
+
expect(responseData.affiliations).toContain("Community");
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
it("should indicate non-coordinator when user has no affinity groups", async () => {
|
|
1140
|
+
// First call: get user UUID
|
|
1141
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
1142
|
+
data: [{ id: "user-uuid-123", attributes: { name: "testuser" } }],
|
|
1143
|
+
});
|
|
1144
|
+
// Second call: get tags (parallel)
|
|
1145
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
1146
|
+
data: [{ id: "tag-1", attributes: { name: "gpu" } }],
|
|
1147
|
+
});
|
|
1148
|
+
// Third call: get affinity groups - empty (parallel)
|
|
1149
|
+
mockDrupalAuth.get.mockResolvedValueOnce({
|
|
1150
|
+
data: [],
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
const result = await server["handleToolCall"]({
|
|
1154
|
+
method: "tools/call",
|
|
1155
|
+
params: {
|
|
1156
|
+
name: "get_announcement_context",
|
|
1157
|
+
arguments: {},
|
|
1158
|
+
},
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
1162
|
+
expect(responseData.is_coordinator).toBe(false);
|
|
1163
|
+
expect(responseData.affinity_groups).toHaveLength(0);
|
|
1164
|
+
});
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
describe("Resources", () => {
|
|
1169
|
+
it("should read accessci://announcements resource", async () => {
|
|
1170
|
+
mockHttpClient.get.mockResolvedValue({
|
|
1171
|
+
status: 200,
|
|
1172
|
+
data: [
|
|
1173
|
+
{
|
|
1174
|
+
uuid: "test-uuid-1",
|
|
1175
|
+
title: "Test Announcement",
|
|
1176
|
+
body: "<p>Content</p>",
|
|
1177
|
+
published_date: "2024-03-15",
|
|
1178
|
+
tags: "gpu,hpc",
|
|
1179
|
+
affinity_group: [],
|
|
1180
|
+
},
|
|
1181
|
+
],
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
const result = await server["handleResourceRead"]({
|
|
1185
|
+
method: "resources/read",
|
|
1186
|
+
params: {
|
|
1187
|
+
uri: "accessci://announcements",
|
|
1188
|
+
},
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
expect(result.contents).toHaveLength(1);
|
|
1192
|
+
expect(result.contents[0].uri).toBe("accessci://announcements");
|
|
1193
|
+
expect(result.contents[0].mimeType).toBe("application/json");
|
|
1194
|
+
|
|
1195
|
+
const data = JSON.parse(result.contents[0].text as string);
|
|
1196
|
+
expect(data).toHaveLength(1);
|
|
1197
|
+
expect(data[0].title).toBe("Test Announcement");
|
|
1198
|
+
});
|
|
1199
|
+
|
|
1200
|
+
it("should handle resource read errors", async () => {
|
|
1201
|
+
mockHttpClient.get.mockResolvedValue({
|
|
1202
|
+
status: 500,
|
|
1203
|
+
statusText: "Internal Server Error",
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
const result = await server["handleResourceRead"]({
|
|
1207
|
+
method: "resources/read",
|
|
1208
|
+
params: {
|
|
1209
|
+
uri: "accessci://announcements",
|
|
1210
|
+
},
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
expect(result.contents[0].text).toContain("Error loading announcements");
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
it("should throw for unknown resource", async () => {
|
|
1217
|
+
await expect(
|
|
1218
|
+
server["handleResourceRead"]({
|
|
1219
|
+
method: "resources/read",
|
|
1220
|
+
params: {
|
|
1221
|
+
uri: "accessci://unknown",
|
|
1222
|
+
},
|
|
1223
|
+
})
|
|
1224
|
+
).rejects.toThrow("Unknown resource");
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
describe("Prompts", () => {
|
|
1229
|
+
it("should return create_announcement_guide prompt", async () => {
|
|
1230
|
+
const result = await server["handleGetPrompt"]({
|
|
1231
|
+
params: {
|
|
1232
|
+
name: "create_announcement_guide",
|
|
1233
|
+
arguments: {},
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
expect(result.description).toBe("Guide for creating an ACCESS announcement");
|
|
1238
|
+
expect(result.messages).toHaveLength(2);
|
|
1239
|
+
expect(result.messages[0].role).toBe("user");
|
|
1240
|
+
expect(result.messages[1].role).toBe("assistant");
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
it("should include topic in create_announcement_guide", async () => {
|
|
1244
|
+
const result = await server["handleGetPrompt"]({
|
|
1245
|
+
params: {
|
|
1246
|
+
name: "create_announcement_guide",
|
|
1247
|
+
arguments: { topic: "GPU availability" },
|
|
1248
|
+
},
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
expect((result.messages[0].content as any).text).toContain("GPU availability");
|
|
1252
|
+
expect((result.messages[1].content as any).text).toContain("GPU availability");
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it("should return manage_announcements_guide prompt", async () => {
|
|
1256
|
+
const result = await server["handleGetPrompt"]({
|
|
1257
|
+
params: {
|
|
1258
|
+
name: "manage_announcements_guide",
|
|
1259
|
+
arguments: {},
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
expect(result.description).toBe("Guide for managing existing announcements");
|
|
1264
|
+
expect(result.messages).toHaveLength(2);
|
|
1265
|
+
expect((result.messages[1].content as any).text).toContain("get_my_announcements");
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it("should throw for unknown prompt", async () => {
|
|
1269
|
+
await expect(
|
|
1270
|
+
server["handleGetPrompt"]({
|
|
1271
|
+
params: {
|
|
1272
|
+
name: "unknown_prompt",
|
|
1273
|
+
arguments: {},
|
|
1274
|
+
},
|
|
1275
|
+
})
|
|
1276
|
+
).rejects.toThrow("Unknown prompt");
|
|
1277
|
+
});
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
describe("Unknown Tool", () => {
|
|
1281
|
+
it("should return error for unknown tool", async () => {
|
|
1282
|
+
const result = await server["handleToolCall"]({
|
|
1283
|
+
method: "tools/call",
|
|
356
1284
|
params: {
|
|
357
|
-
name: "
|
|
1285
|
+
name: "unknown_tool",
|
|
358
1286
|
arguments: {},
|
|
359
1287
|
},
|
|
360
|
-
}
|
|
1288
|
+
});
|
|
361
1289
|
|
|
362
|
-
const responseData = JSON.parse(result.content[0].text);
|
|
363
|
-
expect(responseData.
|
|
364
|
-
expect(responseData.announcements[0].formatted_date).toContain("March");
|
|
1290
|
+
const responseData = JSON.parse((result.content[0] as TextContent).text);
|
|
1291
|
+
expect(responseData.error).toContain("Unknown tool");
|
|
365
1292
|
});
|
|
366
1293
|
});
|
|
367
1294
|
});
|