@checkstack/integration-teams-backend 0.0.34 → 0.1.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/CHANGELOG.md +206 -0
- package/package.json +8 -5
- package/src/automations.test.ts +289 -0
- package/src/automations.ts +213 -0
- package/src/index.ts +27 -6
- package/src/provider.ts +61 -296
- package/tsconfig.json +6 -0
- package/src/provider.test.ts +0 -486
package/src/provider.test.ts
DELETED
|
@@ -1,486 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
teamsProvider,
|
|
4
|
-
TeamsConnectionSchema,
|
|
5
|
-
TeamsSubscriptionSchema,
|
|
6
|
-
buildAdaptiveCard,
|
|
7
|
-
} from "./provider";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Unit tests for the Microsoft Teams Integration Provider.
|
|
11
|
-
*
|
|
12
|
-
* Tests cover:
|
|
13
|
-
* - Config schema validation
|
|
14
|
-
* - Connection testing
|
|
15
|
-
* - Team/channel options resolution
|
|
16
|
-
* - Adaptive Card building
|
|
17
|
-
* - Event delivery
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
// Mock logger
|
|
21
|
-
const mockLogger = {
|
|
22
|
-
debug: mock(() => {}),
|
|
23
|
-
info: mock(() => {}),
|
|
24
|
-
warn: mock(() => {}),
|
|
25
|
-
error: mock(() => {}),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
describe("Microsoft Teams Integration Provider", () => {
|
|
29
|
-
beforeEach(() => {
|
|
30
|
-
mockLogger.debug.mockClear();
|
|
31
|
-
mockLogger.info.mockClear();
|
|
32
|
-
mockLogger.warn.mockClear();
|
|
33
|
-
mockLogger.error.mockClear();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
37
|
-
// Provider Metadata
|
|
38
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
describe("metadata", () => {
|
|
41
|
-
it("has correct basic metadata", () => {
|
|
42
|
-
expect(teamsProvider.id).toBe("teams");
|
|
43
|
-
expect(teamsProvider.displayName).toBe("Microsoft Teams");
|
|
44
|
-
expect(teamsProvider.description).toContain("Teams");
|
|
45
|
-
expect(teamsProvider.icon).toBe("MessageSquareMore");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("has versioned config and connection schemas", () => {
|
|
49
|
-
expect(teamsProvider.config).toBeDefined();
|
|
50
|
-
expect(teamsProvider.config.version).toBe(1);
|
|
51
|
-
expect(teamsProvider.connectionSchema).toBeDefined();
|
|
52
|
-
expect(teamsProvider.connectionSchema?.version).toBe(1);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("has documentation", () => {
|
|
56
|
-
expect(teamsProvider.documentation).toBeDefined();
|
|
57
|
-
expect(teamsProvider.documentation?.setupGuide).toContain("Azure");
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
62
|
-
// Config Schema Validation
|
|
63
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
describe("connection schema", () => {
|
|
66
|
-
it("requires all credentials", () => {
|
|
67
|
-
expect(() => {
|
|
68
|
-
TeamsConnectionSchema.parse({});
|
|
69
|
-
}).toThrow();
|
|
70
|
-
|
|
71
|
-
expect(() => {
|
|
72
|
-
TeamsConnectionSchema.parse({
|
|
73
|
-
tenantId: "tenant-1",
|
|
74
|
-
clientId: "client-1",
|
|
75
|
-
});
|
|
76
|
-
}).toThrow();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("accepts valid connection config", () => {
|
|
80
|
-
const result = TeamsConnectionSchema.parse({
|
|
81
|
-
tenantId: "12345678-1234-1234-1234-123456789abc",
|
|
82
|
-
clientId: "87654321-4321-4321-4321-cba987654321",
|
|
83
|
-
clientSecret: "super-secret",
|
|
84
|
-
});
|
|
85
|
-
expect(result.tenantId).toBe("12345678-1234-1234-1234-123456789abc");
|
|
86
|
-
expect(result.clientId).toBe("87654321-4321-4321-4321-cba987654321");
|
|
87
|
-
expect(result.clientSecret).toBe("super-secret");
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
describe("subscription schema", () => {
|
|
92
|
-
it("requires all fields", () => {
|
|
93
|
-
expect(() => {
|
|
94
|
-
TeamsSubscriptionSchema.parse({});
|
|
95
|
-
}).toThrow();
|
|
96
|
-
|
|
97
|
-
expect(() => {
|
|
98
|
-
TeamsSubscriptionSchema.parse({ connectionId: "conn-1" });
|
|
99
|
-
}).toThrow();
|
|
100
|
-
|
|
101
|
-
expect(() => {
|
|
102
|
-
TeamsSubscriptionSchema.parse({
|
|
103
|
-
connectionId: "conn-1",
|
|
104
|
-
teamId: "team-1",
|
|
105
|
-
});
|
|
106
|
-
}).toThrow();
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("accepts valid subscription config", () => {
|
|
110
|
-
const result = TeamsSubscriptionSchema.parse({
|
|
111
|
-
connectionId: "conn-1",
|
|
112
|
-
teamId: "team-123",
|
|
113
|
-
channelId: "channel-456",
|
|
114
|
-
});
|
|
115
|
-
expect(result.connectionId).toBe("conn-1");
|
|
116
|
-
expect(result.teamId).toBe("team-123");
|
|
117
|
-
expect(result.channelId).toBe("channel-456");
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
122
|
-
// Adaptive Card Building
|
|
123
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
describe("adaptive card builder", () => {
|
|
126
|
-
it("builds card with event details", () => {
|
|
127
|
-
const card = buildAdaptiveCard({
|
|
128
|
-
eventId: "incident.created",
|
|
129
|
-
payload: { incidentId: "inc-123", severity: "critical" },
|
|
130
|
-
subscriptionName: "Critical Incidents",
|
|
131
|
-
timestamp: "2024-01-15T10:30:00Z",
|
|
132
|
-
}) as Record<string, unknown>;
|
|
133
|
-
|
|
134
|
-
expect(card.type).toBe("AdaptiveCard");
|
|
135
|
-
expect(card.version).toBe("1.4");
|
|
136
|
-
|
|
137
|
-
const body = card.body as Array<Record<string, unknown>>;
|
|
138
|
-
expect(body.length).toBeGreaterThan(0);
|
|
139
|
-
|
|
140
|
-
// Check for event info in FactSet
|
|
141
|
-
const factSet = body.find((b) => b.type === "FactSet") as Record<
|
|
142
|
-
string,
|
|
143
|
-
unknown
|
|
144
|
-
>;
|
|
145
|
-
expect(factSet).toBeDefined();
|
|
146
|
-
|
|
147
|
-
const facts = factSet.facts as Array<{ title: string; value: string }>;
|
|
148
|
-
expect(facts.some((f) => f.value === "incident.created")).toBe(true);
|
|
149
|
-
expect(facts.some((f) => f.value === "Critical Incidents")).toBe(true);
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it("includes JSON payload in code block", () => {
|
|
153
|
-
const card = buildAdaptiveCard({
|
|
154
|
-
eventId: "test.event",
|
|
155
|
-
payload: { key: "value" },
|
|
156
|
-
subscriptionName: "Test",
|
|
157
|
-
timestamp: new Date().toISOString(),
|
|
158
|
-
}) as Record<string, unknown>;
|
|
159
|
-
|
|
160
|
-
const body = card.body as Array<Record<string, unknown>>;
|
|
161
|
-
const codeBlock = body.find((b) => b.fontType === "monospace") as Record<
|
|
162
|
-
string,
|
|
163
|
-
unknown
|
|
164
|
-
>;
|
|
165
|
-
|
|
166
|
-
expect(codeBlock).toBeDefined();
|
|
167
|
-
expect(codeBlock.text).toContain('"key"');
|
|
168
|
-
expect(codeBlock.text).toContain('"value"');
|
|
169
|
-
});
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
173
|
-
// Test Connection
|
|
174
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
175
|
-
|
|
176
|
-
describe("testConnection", () => {
|
|
177
|
-
it("returns success when Graph API is accessible", async () => {
|
|
178
|
-
let requestCount = 0;
|
|
179
|
-
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
180
|
-
url: RequestInfo | URL,
|
|
181
|
-
) => {
|
|
182
|
-
requestCount++;
|
|
183
|
-
const urlStr = url.toString();
|
|
184
|
-
|
|
185
|
-
// Token request
|
|
186
|
-
if (urlStr.includes("oauth2/v2.0/token")) {
|
|
187
|
-
return new Response(
|
|
188
|
-
JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
|
|
189
|
-
{ status: 200 },
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Teams list request
|
|
194
|
-
if (urlStr.includes("/teams")) {
|
|
195
|
-
return new Response(
|
|
196
|
-
JSON.stringify({
|
|
197
|
-
value: [
|
|
198
|
-
{ id: "team-1", displayName: "Engineering" },
|
|
199
|
-
{ id: "team-2", displayName: "DevOps" },
|
|
200
|
-
],
|
|
201
|
-
}),
|
|
202
|
-
{ status: 200 },
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return new Response("Not Found", { status: 404 });
|
|
207
|
-
}) as unknown as typeof fetch);
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
const result = await teamsProvider.testConnection!({
|
|
211
|
-
tenantId: "tenant-123",
|
|
212
|
-
clientId: "client-123",
|
|
213
|
-
clientSecret: "secret-123",
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
expect(result.success).toBe(true);
|
|
217
|
-
expect(result.message).toContain("2 team(s)");
|
|
218
|
-
} finally {
|
|
219
|
-
mockFetch.mockRestore();
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("returns failure for auth errors", async () => {
|
|
224
|
-
const mockFetch = spyOn(globalThis, "fetch").mockImplementation(
|
|
225
|
-
(async () => {
|
|
226
|
-
return new Response("Unauthorized", { status: 401 });
|
|
227
|
-
}) as unknown as typeof fetch,
|
|
228
|
-
);
|
|
229
|
-
|
|
230
|
-
try {
|
|
231
|
-
const result = await teamsProvider.testConnection!({
|
|
232
|
-
tenantId: "tenant-123",
|
|
233
|
-
clientId: "client-123",
|
|
234
|
-
clientSecret: "wrong-secret",
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
expect(result.success).toBe(false);
|
|
238
|
-
expect(result.message).toContain("failed");
|
|
239
|
-
} finally {
|
|
240
|
-
mockFetch.mockRestore();
|
|
241
|
-
}
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
246
|
-
// Get Connection Options
|
|
247
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
describe("getConnectionOptions", () => {
|
|
250
|
-
it("returns team options", async () => {
|
|
251
|
-
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
252
|
-
url: RequestInfo | URL,
|
|
253
|
-
) => {
|
|
254
|
-
const urlStr = url.toString();
|
|
255
|
-
|
|
256
|
-
if (urlStr.includes("oauth2/v2.0/token")) {
|
|
257
|
-
return new Response(
|
|
258
|
-
JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
|
|
259
|
-
{ status: 200 },
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
if (urlStr.includes("/teams") && !urlStr.includes("/channels")) {
|
|
264
|
-
return new Response(
|
|
265
|
-
JSON.stringify({
|
|
266
|
-
value: [
|
|
267
|
-
{ id: "team-1", displayName: "Engineering" },
|
|
268
|
-
{ id: "team-2", displayName: "DevOps" },
|
|
269
|
-
],
|
|
270
|
-
}),
|
|
271
|
-
{ status: 200 },
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return new Response("Not Found", { status: 404 });
|
|
276
|
-
}) as unknown as typeof fetch);
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
const options = await teamsProvider.getConnectionOptions!({
|
|
280
|
-
resolverName: "teamOptions",
|
|
281
|
-
connectionId: "conn-1",
|
|
282
|
-
context: {},
|
|
283
|
-
logger: mockLogger,
|
|
284
|
-
getConnectionWithCredentials: async () => ({
|
|
285
|
-
config: {
|
|
286
|
-
tenantId: "t",
|
|
287
|
-
clientId: "c",
|
|
288
|
-
clientSecret: "s",
|
|
289
|
-
},
|
|
290
|
-
}),
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
expect(options).toHaveLength(2);
|
|
294
|
-
expect(options[0]).toEqual({ value: "team-1", label: "Engineering" });
|
|
295
|
-
expect(options[1]).toEqual({ value: "team-2", label: "DevOps" });
|
|
296
|
-
} finally {
|
|
297
|
-
mockFetch.mockRestore();
|
|
298
|
-
}
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it("returns channel options when teamId is provided", async () => {
|
|
302
|
-
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
303
|
-
url: RequestInfo | URL,
|
|
304
|
-
) => {
|
|
305
|
-
const urlStr = url.toString();
|
|
306
|
-
|
|
307
|
-
if (urlStr.includes("oauth2/v2.0/token")) {
|
|
308
|
-
return new Response(
|
|
309
|
-
JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
|
|
310
|
-
{ status: 200 },
|
|
311
|
-
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (urlStr.includes("/channels")) {
|
|
315
|
-
return new Response(
|
|
316
|
-
JSON.stringify({
|
|
317
|
-
value: [
|
|
318
|
-
{ id: "ch-1", displayName: "General" },
|
|
319
|
-
{ id: "ch-2", displayName: "Alerts" },
|
|
320
|
-
],
|
|
321
|
-
}),
|
|
322
|
-
{ status: 200 },
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return new Response("Not Found", { status: 404 });
|
|
327
|
-
}) as unknown as typeof fetch);
|
|
328
|
-
|
|
329
|
-
try {
|
|
330
|
-
const options = await teamsProvider.getConnectionOptions!({
|
|
331
|
-
resolverName: "channelOptions",
|
|
332
|
-
connectionId: "conn-1",
|
|
333
|
-
context: { teamId: "team-1" },
|
|
334
|
-
logger: mockLogger,
|
|
335
|
-
getConnectionWithCredentials: async () => ({
|
|
336
|
-
config: {
|
|
337
|
-
tenantId: "t",
|
|
338
|
-
clientId: "c",
|
|
339
|
-
clientSecret: "s",
|
|
340
|
-
},
|
|
341
|
-
}),
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
expect(options).toHaveLength(2);
|
|
345
|
-
expect(options[0]).toEqual({ value: "ch-1", label: "General" });
|
|
346
|
-
expect(options[1]).toEqual({ value: "ch-2", label: "Alerts" });
|
|
347
|
-
} finally {
|
|
348
|
-
mockFetch.mockRestore();
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("returns empty array when teamId is missing for channel options", async () => {
|
|
353
|
-
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
354
|
-
url: RequestInfo | URL,
|
|
355
|
-
) => {
|
|
356
|
-
const urlStr = url.toString();
|
|
357
|
-
if (urlStr.includes("oauth2/v2.0/token")) {
|
|
358
|
-
return new Response(
|
|
359
|
-
JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
|
|
360
|
-
{ status: 200 },
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
return new Response("Not Found", { status: 404 });
|
|
364
|
-
}) as unknown as typeof fetch);
|
|
365
|
-
|
|
366
|
-
try {
|
|
367
|
-
const options = await teamsProvider.getConnectionOptions!({
|
|
368
|
-
resolverName: "channelOptions",
|
|
369
|
-
connectionId: "conn-1",
|
|
370
|
-
context: {}, // No teamId
|
|
371
|
-
logger: mockLogger,
|
|
372
|
-
getConnectionWithCredentials: async () => ({
|
|
373
|
-
config: {
|
|
374
|
-
tenantId: "t",
|
|
375
|
-
clientId: "c",
|
|
376
|
-
clientSecret: "s",
|
|
377
|
-
},
|
|
378
|
-
}),
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
expect(options).toEqual([]);
|
|
382
|
-
} finally {
|
|
383
|
-
mockFetch.mockRestore();
|
|
384
|
-
}
|
|
385
|
-
});
|
|
386
|
-
});
|
|
387
|
-
|
|
388
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
389
|
-
// Delivery
|
|
390
|
-
// ─────────────────────────────────────────────────────────────────────────
|
|
391
|
-
|
|
392
|
-
describe("deliver", () => {
|
|
393
|
-
it("sends message to Teams channel successfully", async () => {
|
|
394
|
-
let capturedMessageUrl: string | undefined;
|
|
395
|
-
let capturedBody: string | undefined;
|
|
396
|
-
|
|
397
|
-
const mockFetch = spyOn(globalThis, "fetch").mockImplementation((async (
|
|
398
|
-
url: RequestInfo | URL,
|
|
399
|
-
options?: RequestInit,
|
|
400
|
-
) => {
|
|
401
|
-
const urlStr = url.toString();
|
|
402
|
-
|
|
403
|
-
if (urlStr.includes("oauth2/v2.0/token")) {
|
|
404
|
-
return new Response(
|
|
405
|
-
JSON.stringify({ access_token: "test-token", expires_in: 3600 }),
|
|
406
|
-
{ status: 200 },
|
|
407
|
-
);
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (urlStr.includes("/messages")) {
|
|
411
|
-
capturedMessageUrl = urlStr;
|
|
412
|
-
capturedBody = options?.body as string;
|
|
413
|
-
return new Response(JSON.stringify({ id: "msg-123" }), {
|
|
414
|
-
status: 200,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return new Response("Not Found", { status: 404 });
|
|
419
|
-
}) as unknown as typeof fetch);
|
|
420
|
-
|
|
421
|
-
try {
|
|
422
|
-
const result = await teamsProvider.deliver({
|
|
423
|
-
event: {
|
|
424
|
-
eventId: "incident.created",
|
|
425
|
-
payload: { incidentId: "inc-123" },
|
|
426
|
-
timestamp: new Date().toISOString(),
|
|
427
|
-
deliveryId: "del-789",
|
|
428
|
-
},
|
|
429
|
-
subscription: {
|
|
430
|
-
id: "sub-1",
|
|
431
|
-
name: "Incident Alerts",
|
|
432
|
-
},
|
|
433
|
-
providerConfig: {
|
|
434
|
-
connectionId: "conn-1",
|
|
435
|
-
teamId: "team-abc",
|
|
436
|
-
channelId: "channel-xyz",
|
|
437
|
-
},
|
|
438
|
-
logger: mockLogger,
|
|
439
|
-
getConnectionWithCredentials: async () => ({
|
|
440
|
-
id: "conn-1",
|
|
441
|
-
config: {
|
|
442
|
-
tenantId: "t",
|
|
443
|
-
clientId: "c",
|
|
444
|
-
clientSecret: "s",
|
|
445
|
-
},
|
|
446
|
-
}),
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
expect(result.success).toBe(true);
|
|
450
|
-
expect(result.externalId).toBe("msg-123");
|
|
451
|
-
expect(capturedMessageUrl).toContain("team-abc");
|
|
452
|
-
expect(capturedMessageUrl).toContain("channel-xyz");
|
|
453
|
-
|
|
454
|
-
const parsedBody = JSON.parse(capturedBody!);
|
|
455
|
-
expect(parsedBody.attachments).toHaveLength(1);
|
|
456
|
-
expect(parsedBody.attachments[0].contentType).toBe(
|
|
457
|
-
"application/vnd.microsoft.card.adaptive",
|
|
458
|
-
);
|
|
459
|
-
} finally {
|
|
460
|
-
mockFetch.mockRestore();
|
|
461
|
-
}
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it("returns error when connection not found", async () => {
|
|
465
|
-
const result = await teamsProvider.deliver({
|
|
466
|
-
event: {
|
|
467
|
-
eventId: "test.event",
|
|
468
|
-
payload: {},
|
|
469
|
-
timestamp: new Date().toISOString(),
|
|
470
|
-
deliveryId: "del-1",
|
|
471
|
-
},
|
|
472
|
-
subscription: { id: "sub-1", name: "Test" },
|
|
473
|
-
providerConfig: {
|
|
474
|
-
connectionId: "nonexistent",
|
|
475
|
-
teamId: "team-1",
|
|
476
|
-
channelId: "ch-1",
|
|
477
|
-
},
|
|
478
|
-
logger: mockLogger,
|
|
479
|
-
getConnectionWithCredentials: async () => undefined,
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
expect(result.success).toBe(false);
|
|
483
|
-
expect(result.error).toContain("not found");
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
});
|