@topolo/sdk 0.4.7 → 0.4.8
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/dist/index.cjs +145 -135
- package/dist/index.d.cts +84 -92
- package/dist/index.d.ts +84 -92
- package/dist/index.js +145 -135
- package/package.json +1 -1
- package/src/application-requirements.ts +9 -5
- package/src/applications.generated.ts +52 -108
- package/src/client.test.ts +74 -0
- package/src/client.ts +101 -0
- package/src/index.test.ts +69 -0
- package/src/index.ts +1 -0
- package/src/modules/crm.test.ts +208 -0
- package/src/modules/identity.test.ts +165 -0
- package/src/modules/p2p.test.ts +286 -0
- package/src/oauth.test.ts +103 -0
- package/src/services.generated.ts +13 -14
- package/src/services.test.ts +18 -7
- package/src/widgets.test.ts +216 -0
package/src/oauth.test.ts
CHANGED
|
@@ -109,5 +109,108 @@ describe('TopoloOAuth.requestDeviceCode', () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
describe('TopoloOAuth.exchangeAuthorizationCode', () => {
|
|
113
|
+
it('POSTs grant_type=authorization_code with PKCE verifier + secret', async () => {
|
|
114
|
+
const { impl, calls } = stubFetch({
|
|
115
|
+
status: 200,
|
|
116
|
+
body: { access_token: 'at', token_type: 'Bearer', expires_in: 3600 },
|
|
117
|
+
});
|
|
118
|
+
const oauth = new TopoloOAuth({ fetch: impl });
|
|
119
|
+
const res = await oauth.exchangeAuthorizationCode({
|
|
120
|
+
clientId: 'doac_x',
|
|
121
|
+
code: 'auth_code_1',
|
|
122
|
+
redirectUri: 'https://app/cb',
|
|
123
|
+
codeVerifier: 'verifier_1',
|
|
124
|
+
clientSecret: 'secret_1',
|
|
125
|
+
});
|
|
126
|
+
expect(res.access_token).toBe('at');
|
|
127
|
+
const body = new URLSearchParams(calls[0]!.init.body as string);
|
|
128
|
+
expect(body.get('grant_type')).toBe('authorization_code');
|
|
129
|
+
expect(body.get('code')).toBe('auth_code_1');
|
|
130
|
+
expect(body.get('redirect_uri')).toBe('https://app/cb');
|
|
131
|
+
expect(body.get('code_verifier')).toBe('verifier_1');
|
|
132
|
+
expect(body.get('client_secret')).toBe('secret_1');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('omits optional PKCE verifier + secret when not provided', async () => {
|
|
136
|
+
const { impl, calls } = stubFetch({
|
|
137
|
+
status: 200,
|
|
138
|
+
body: { access_token: 'at', token_type: 'Bearer', expires_in: 3600 },
|
|
139
|
+
});
|
|
140
|
+
const oauth = new TopoloOAuth({ fetch: impl });
|
|
141
|
+
await oauth.exchangeAuthorizationCode({
|
|
142
|
+
clientId: 'doac_x',
|
|
143
|
+
code: 'c',
|
|
144
|
+
redirectUri: 'https://app/cb',
|
|
145
|
+
});
|
|
146
|
+
const body = new URLSearchParams(calls[0]!.init.body as string);
|
|
147
|
+
expect(body.has('code_verifier')).toBe(false);
|
|
148
|
+
expect(body.has('client_secret')).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('TopoloOAuth.requestDeviceCode (single scope + omitted)', () => {
|
|
153
|
+
function deviceBody() {
|
|
154
|
+
return {
|
|
155
|
+
device_code: 'dc',
|
|
156
|
+
user_code: 'X',
|
|
157
|
+
verification_uri: 'https://ex/dev',
|
|
158
|
+
expires_in: 600,
|
|
159
|
+
interval: 5,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
it('passes a string scope through unchanged', async () => {
|
|
164
|
+
const { impl, calls } = stubFetch({ status: 200, body: deviceBody() });
|
|
165
|
+
const oauth = new TopoloOAuth({ fetch: impl });
|
|
166
|
+
await oauth.requestDeviceCode({ clientId: 'doac_x', scope: 'read' });
|
|
167
|
+
expect(new URLSearchParams(calls[0]!.init.body as string).get('scope')).toBe('read');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('omits scope when not provided', async () => {
|
|
171
|
+
const { impl, calls } = stubFetch({ status: 200, body: deviceBody() });
|
|
172
|
+
const oauth = new TopoloOAuth({ fetch: impl });
|
|
173
|
+
await oauth.requestDeviceCode({ clientId: 'doac_x' });
|
|
174
|
+
expect(new URLSearchParams(calls[0]!.init.body as string).has('scope')).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('TopoloOAuth #postForm content negotiation', () => {
|
|
179
|
+
it('parses a non-JSON (text/plain) successful body as a string', async () => {
|
|
180
|
+
const impl = vi.fn(async () =>
|
|
181
|
+
new Response('plain-ok', { status: 200, headers: { 'Content-Type': 'text/plain' } }),
|
|
182
|
+
) as unknown as typeof fetch;
|
|
183
|
+
const oauth = new TopoloOAuth({ fetch: impl });
|
|
184
|
+
const res = await oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' });
|
|
185
|
+
expect(res as unknown).toBe('plain-ok');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('wraps a non-OAuth text error body in TopoloHttpError', async () => {
|
|
189
|
+
const impl = vi.fn(async () =>
|
|
190
|
+
new Response('upstream exploded', {
|
|
191
|
+
status: 502,
|
|
192
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
193
|
+
}),
|
|
194
|
+
) as unknown as typeof fetch;
|
|
195
|
+
const oauth = new TopoloOAuth({ fetch: impl });
|
|
196
|
+
await expect(
|
|
197
|
+
oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' }),
|
|
198
|
+
).rejects.toBeInstanceOf(TopoloHttpError);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('normalizes a base URL that already ends with a slash', async () => {
|
|
202
|
+
const { impl, calls } = stubFetch({
|
|
203
|
+
status: 200,
|
|
204
|
+
body: { access_token: 'at', token_type: 'Bearer', expires_in: 1 },
|
|
205
|
+
});
|
|
206
|
+
const oauth = new TopoloOAuth({
|
|
207
|
+
fetch: impl,
|
|
208
|
+
serviceUrls: { auth: 'https://auth.example/' },
|
|
209
|
+
});
|
|
210
|
+
await oauth.pollDeviceToken({ clientId: 'doac_x', deviceCode: 'dc_1' });
|
|
211
|
+
expect(calls[0]!.url).toBe('https://auth.example/api/developer-oauth/token');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
112
215
|
// Keep the TopoloAuthError import non-dead for type inference
|
|
113
216
|
void TopoloAuthError;
|
|
@@ -3,32 +3,32 @@
|
|
|
3
3
|
// Regenerate: npm run generate:services
|
|
4
4
|
|
|
5
5
|
export const SERVICES = {
|
|
6
|
-
agent: "https://agent
|
|
6
|
+
agent: "https://agent.topolo.app",
|
|
7
7
|
auth: "https://auth.topolo.app",
|
|
8
8
|
backup: "https://backup.topolo.app",
|
|
9
9
|
books: "https://books.topolo.app",
|
|
10
10
|
bugfix: "https://bugfix.topolo.app",
|
|
11
|
-
bytes: "https://bytes
|
|
11
|
+
bytes: "https://bytes.topolo.app",
|
|
12
12
|
calendar: "https://calendar.topolo.app",
|
|
13
13
|
capacity: "https://capacity.topolo.app",
|
|
14
14
|
chat: "https://chat.topolo.app",
|
|
15
|
-
commerce: "https://commerce
|
|
15
|
+
commerce: "https://commerce.topolo.app",
|
|
16
16
|
compose: "https://compose.topolo.app",
|
|
17
17
|
consent: "https://consent.topolo.app",
|
|
18
|
-
crm: "https://crm
|
|
18
|
+
crm: "https://crm.topolo.app",
|
|
19
19
|
developers: "https://developers.topolo.app",
|
|
20
20
|
director: "https://director.topolo.app",
|
|
21
|
-
feed: "https://feed
|
|
21
|
+
feed: "https://feed.topolo.app",
|
|
22
22
|
flow: "https://flow.topolo.app",
|
|
23
|
-
forecast: "https://forecast
|
|
23
|
+
forecast: "https://forecast.topolo.app",
|
|
24
24
|
forms: "https://forms.topolo.app",
|
|
25
25
|
home: "https://home.topolo.app",
|
|
26
26
|
insights: "https://insights.topolo.app",
|
|
27
27
|
inventory: "https://inventory.topolo.app",
|
|
28
|
-
learn: "https://learn
|
|
28
|
+
learn: "https://learn.topolo.app",
|
|
29
29
|
localize: "https://localize.topolo.app",
|
|
30
|
-
mail: "https://mail
|
|
31
|
-
mdm: "https://mdm
|
|
30
|
+
mail: "https://mail.topolo.app",
|
|
31
|
+
mdm: "https://mdm.topolo.app",
|
|
32
32
|
messages: "https://messages.topolo.app",
|
|
33
33
|
nexus: "https://nexus-api.topolo.app",
|
|
34
34
|
notify: "https://notify.topolo.app",
|
|
@@ -37,20 +37,19 @@ export const SERVICES = {
|
|
|
37
37
|
p2p: "https://p2p.topolo.app",
|
|
38
38
|
pay: "https://pay.topolo.app",
|
|
39
39
|
people: "https://people.topolo.app",
|
|
40
|
-
pulse: "https://pulse.topolo.app",
|
|
41
40
|
"quro.api": "https://api.ol0.me",
|
|
42
41
|
"quro.redirect": "https://ol0.me",
|
|
43
|
-
roadmapper: "https://roadmapper
|
|
42
|
+
roadmapper: "https://roadmapper.topolo.app",
|
|
44
43
|
sign: "https://sign.topolo.app",
|
|
45
|
-
social_studio: "https://studio
|
|
46
|
-
socialize: "https://socialize
|
|
44
|
+
social_studio: "https://studio.topolo.app",
|
|
45
|
+
socialize: "https://socialize.topolo.app",
|
|
47
46
|
spaces: "https://spaces.topolo.app",
|
|
48
47
|
status: "https://status.topolo.app",
|
|
49
48
|
success: "https://success.topolo.app",
|
|
50
49
|
support: "https://support.topolo.app",
|
|
51
50
|
survey: "https://survey.topolo.app",
|
|
52
51
|
voice: "https://voice.topolo.app",
|
|
53
|
-
web: "https://web
|
|
52
|
+
web: "https://web.topolo.app",
|
|
54
53
|
} as const;
|
|
55
54
|
|
|
56
55
|
export type ServiceId = keyof typeof SERVICES;
|
package/src/services.test.ts
CHANGED
|
@@ -48,7 +48,6 @@ const EXPECTED_APPLICATION_IDS = [
|
|
|
48
48
|
'p2p',
|
|
49
49
|
'pay',
|
|
50
50
|
'people',
|
|
51
|
-
'pulse',
|
|
52
51
|
'quro',
|
|
53
52
|
'roadmapper',
|
|
54
53
|
'sign',
|
|
@@ -69,10 +68,10 @@ describe('service registry', () => {
|
|
|
69
68
|
calendar: 'https://calendar.topolo.app',
|
|
70
69
|
books: 'https://books.topolo.app',
|
|
71
70
|
consent: 'https://consent.topolo.app',
|
|
72
|
-
feed: 'https://feed
|
|
73
|
-
mail: 'https://mail
|
|
71
|
+
feed: 'https://feed.topolo.app',
|
|
72
|
+
mail: 'https://mail.topolo.app',
|
|
74
73
|
one: 'https://www.topolo.app',
|
|
75
|
-
web: 'https://web
|
|
74
|
+
web: 'https://web.topolo.app',
|
|
76
75
|
});
|
|
77
76
|
expect(DEFAULT_SERVICE_URLS).toMatchObject({
|
|
78
77
|
success: 'https://success.topolo.app',
|
|
@@ -130,7 +129,7 @@ describe('service registry', () => {
|
|
|
130
129
|
});
|
|
131
130
|
|
|
132
131
|
describe('application catalog', () => {
|
|
133
|
-
it('covers every
|
|
132
|
+
it('covers every production-routable Topolo application directory', () => {
|
|
134
133
|
expect(Object.keys(APPLICATIONS).sort()).toEqual(EXPECTED_APPLICATION_IDS);
|
|
135
134
|
});
|
|
136
135
|
|
|
@@ -149,9 +148,15 @@ describe('application catalog', () => {
|
|
|
149
148
|
|
|
150
149
|
it('treats TopoloWeb runtime as a standalone deploy, not an agent-callable API service', () => {
|
|
151
150
|
expect(APPLICATIONS.web.services).toEqual(['web']);
|
|
151
|
+
// Front-door standardization: the callable `web` service is served by the
|
|
152
|
+
// studio web worker (web.topolo.app/api); the API worker is de-routed and
|
|
153
|
+
// no longer carries a public service registration.
|
|
152
154
|
expect(
|
|
153
|
-
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-
|
|
155
|
+
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-studio')?.serviceId,
|
|
154
156
|
).toBe('web');
|
|
157
|
+
expect(
|
|
158
|
+
APPLICATIONS.web.deployTargets.find((target) => target.name === 'topolo-web-api')?.serviceId,
|
|
159
|
+
).toBeNull();
|
|
155
160
|
// topolo-web-runtime (Astro, sites.topolo.app) deploys from its own wrangler
|
|
156
161
|
// config and is intentionally listed as non-callable, so it can never be an
|
|
157
162
|
// agent-callable service.
|
|
@@ -166,9 +171,15 @@ describe('application catalog', () => {
|
|
|
166
171
|
|
|
167
172
|
it('treats TopoloFeed analytics as telemetry, not an agent-callable API service', () => {
|
|
168
173
|
expect(APPLICATIONS.feed.services).toEqual(['feed']);
|
|
174
|
+
// Front-door standardization: the callable `feed` service is served by the
|
|
175
|
+
// feed web worker (feed.topolo.app/api); the API worker is de-routed.
|
|
169
176
|
expect(
|
|
170
|
-
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-
|
|
177
|
+
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-web')?.serviceId,
|
|
171
178
|
).toBe('feed');
|
|
179
|
+
expect(
|
|
180
|
+
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-api')?.serviceId,
|
|
181
|
+
).toBeNull();
|
|
182
|
+
// The analytics ingestion worker keeps its own host and is non-callable.
|
|
172
183
|
expect(
|
|
173
184
|
APPLICATIONS.feed.deployTargets.find((target) => target.name === 'topolo-feed-analytics-api')?.serviceId,
|
|
174
185
|
).toBeNull();
|
package/src/widgets.test.ts
CHANGED
|
@@ -2,11 +2,23 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
createTopoloWidgetError,
|
|
4
4
|
createTopoloWidgetResponse,
|
|
5
|
+
defineTopoloChartWidget,
|
|
6
|
+
defineTopoloNotificationsWidget,
|
|
7
|
+
defineTopoloQuickActionsWidget,
|
|
8
|
+
defineTopoloRecentActivityWidget,
|
|
9
|
+
defineTopoloStatsWidget,
|
|
5
10
|
TOPOLO_WIDGET_ENDPOINT,
|
|
6
11
|
TOPOLO_WIDGET_REFRESH_INTERVAL_MS,
|
|
7
12
|
validateTopoloWidgetResponse,
|
|
8
13
|
} from "./widgets.js";
|
|
9
14
|
|
|
15
|
+
const BASE = {
|
|
16
|
+
serviceId: "service_x",
|
|
17
|
+
serviceName: "X",
|
|
18
|
+
lastUpdated: "2026-04-25T00:00:00.000Z",
|
|
19
|
+
snapshot: { summary: "A summary that is long enough to read." },
|
|
20
|
+
};
|
|
21
|
+
|
|
10
22
|
describe("Topolo widget contract", () => {
|
|
11
23
|
it("exports the platform widget endpoint defaults", () => {
|
|
12
24
|
expect(TOPOLO_WIDGET_ENDPOINT).toBe("/api/widget");
|
|
@@ -177,6 +189,210 @@ describe("Topolo widget contract", () => {
|
|
|
177
189
|
]);
|
|
178
190
|
});
|
|
179
191
|
|
|
192
|
+
it("stamps the literal type onto each define* helper", () => {
|
|
193
|
+
expect(defineTopoloStatsWidget({ ...BASE, stats: [] }).type).toBe("stats");
|
|
194
|
+
expect(
|
|
195
|
+
defineTopoloRecentActivityWidget({ ...BASE, activities: [] }).type,
|
|
196
|
+
).toBe("recent-activity");
|
|
197
|
+
expect(
|
|
198
|
+
defineTopoloQuickActionsWidget({ ...BASE, actions: [] }).type,
|
|
199
|
+
).toBe("quick-actions");
|
|
200
|
+
expect(
|
|
201
|
+
defineTopoloNotificationsWidget({ ...BASE, notifications: [], unreadCount: 0 })
|
|
202
|
+
.type,
|
|
203
|
+
).toBe("notifications");
|
|
204
|
+
expect(
|
|
205
|
+
defineTopoloChartWidget({
|
|
206
|
+
...BASE,
|
|
207
|
+
chartType: "line",
|
|
208
|
+
title: "Trend",
|
|
209
|
+
data: { labels: ["a"], values: [1] },
|
|
210
|
+
}).type,
|
|
211
|
+
).toBe("chart");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("derives lastUpdated from a Date instance passed as now", () => {
|
|
215
|
+
const response = createTopoloWidgetResponse({
|
|
216
|
+
serviceId: "service_x",
|
|
217
|
+
serviceName: "X",
|
|
218
|
+
now: new Date("2026-05-01T12:00:00.000Z"),
|
|
219
|
+
widgets: [
|
|
220
|
+
{
|
|
221
|
+
type: "stats",
|
|
222
|
+
snapshot: { summary: "Date branch summary content here." },
|
|
223
|
+
stats: [],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
});
|
|
227
|
+
expect(response.widgets[0]!.lastUpdated).toBe("2026-05-01T12:00:00.000Z");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("generates a current timestamp when now is omitted", () => {
|
|
231
|
+
const before = Date.now();
|
|
232
|
+
const response = createTopoloWidgetResponse({
|
|
233
|
+
serviceId: "service_x",
|
|
234
|
+
serviceName: "X",
|
|
235
|
+
widgets: [
|
|
236
|
+
{
|
|
237
|
+
type: "stats",
|
|
238
|
+
snapshot: { summary: "Omitted-now summary content here." },
|
|
239
|
+
stats: [],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
const stamped = Date.parse(response.widgets[0]!.lastUpdated);
|
|
244
|
+
expect(stamped).toBeGreaterThanOrEqual(before);
|
|
245
|
+
expect(Number.isNaN(stamped)).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("rejects a non-object response", () => {
|
|
249
|
+
expect(validateTopoloWidgetResponse(null)).toEqual({
|
|
250
|
+
ok: false,
|
|
251
|
+
errors: ["response must be an object"],
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("rejects a non-boolean success and a non-array widgets field", () => {
|
|
256
|
+
const result = validateTopoloWidgetResponse({ success: "yes", widgets: "nope" });
|
|
257
|
+
expect(result.ok).toBe(false);
|
|
258
|
+
expect(result.errors).toContain("success must be a boolean");
|
|
259
|
+
expect(result.errors).toContain("widgets must be an array");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("flags a non-string error on a failed response", () => {
|
|
263
|
+
const result = validateTopoloWidgetResponse({
|
|
264
|
+
success: false,
|
|
265
|
+
widgets: [],
|
|
266
|
+
error: 42,
|
|
267
|
+
});
|
|
268
|
+
expect(result.errors).toContain("error must be a string when provided");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("flags a chart widget whose data is not an object", () => {
|
|
272
|
+
const result = validateTopoloWidgetResponse({
|
|
273
|
+
success: true,
|
|
274
|
+
widgets: [
|
|
275
|
+
{
|
|
276
|
+
...BASE,
|
|
277
|
+
type: "chart",
|
|
278
|
+
chartType: "bar",
|
|
279
|
+
title: "T",
|
|
280
|
+
data: "not-an-object",
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
expect(result.errors).toContain("widgets[0].data must be an object");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("flags non-number unreadCount and missing notifications array", () => {
|
|
288
|
+
const result = validateTopoloWidgetResponse({
|
|
289
|
+
success: true,
|
|
290
|
+
widgets: [
|
|
291
|
+
{
|
|
292
|
+
...BASE,
|
|
293
|
+
type: "notifications",
|
|
294
|
+
notifications: "x",
|
|
295
|
+
unreadCount: "many",
|
|
296
|
+
},
|
|
297
|
+
],
|
|
298
|
+
});
|
|
299
|
+
expect(result.errors).toContain("widgets[0].notifications must be an array");
|
|
300
|
+
expect(result.errors).toContain("widgets[0].unreadCount must be a number");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("flags malformed snapshot metadata arrays and metric entries", () => {
|
|
304
|
+
const result = validateTopoloWidgetResponse({
|
|
305
|
+
success: true,
|
|
306
|
+
widgets: [
|
|
307
|
+
{
|
|
308
|
+
...BASE,
|
|
309
|
+
snapshot: {
|
|
310
|
+
summary: "Snapshot with bad metadata arrays present here.",
|
|
311
|
+
defaultMetricKeys: "not-array",
|
|
312
|
+
highlights: "not-array",
|
|
313
|
+
availableMetrics: [{ key: "", label: "", value: "NaN" }],
|
|
314
|
+
},
|
|
315
|
+
type: "stats",
|
|
316
|
+
stats: [],
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
});
|
|
320
|
+
expect(result.errors).toContain(
|
|
321
|
+
"widgets[0].snapshot.defaultMetricKeys must be an array when provided",
|
|
322
|
+
);
|
|
323
|
+
expect(result.errors).toContain(
|
|
324
|
+
"widgets[0].snapshot.highlights must be an array when provided",
|
|
325
|
+
);
|
|
326
|
+
expect(result.errors).toContain(
|
|
327
|
+
"widgets[0].snapshot.availableMetrics[0].key must be a non-empty string",
|
|
328
|
+
);
|
|
329
|
+
expect(result.errors).toContain(
|
|
330
|
+
"widgets[0].snapshot.availableMetrics[0].value must be a number",
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("flags availableMetrics when it is present but not an array", () => {
|
|
335
|
+
const result = validateTopoloWidgetResponse({
|
|
336
|
+
success: true,
|
|
337
|
+
widgets: [
|
|
338
|
+
{
|
|
339
|
+
...BASE,
|
|
340
|
+
snapshot: {
|
|
341
|
+
summary: "Snapshot summary with bad availableMetrics field.",
|
|
342
|
+
availableMetrics: "nope",
|
|
343
|
+
},
|
|
344
|
+
type: "stats",
|
|
345
|
+
stats: [],
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
expect(result.errors).toContain(
|
|
350
|
+
"widgets[0].snapshot.availableMetrics must be an array when provided",
|
|
351
|
+
);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("flags a non-object widget entry and a missing snapshot", () => {
|
|
355
|
+
const result = validateTopoloWidgetResponse({
|
|
356
|
+
success: true,
|
|
357
|
+
widgets: [42],
|
|
358
|
+
});
|
|
359
|
+
expect(result.errors).toContain("widgets[0] must be an object");
|
|
360
|
+
|
|
361
|
+
const result2 = validateTopoloWidgetResponse({
|
|
362
|
+
success: true,
|
|
363
|
+
widgets: [
|
|
364
|
+
{
|
|
365
|
+
serviceId: "s",
|
|
366
|
+
serviceName: "n",
|
|
367
|
+
lastUpdated: "2026-04-25T00:00:00.000Z",
|
|
368
|
+
type: "stats",
|
|
369
|
+
stats: [],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
expect(result2.errors).toContain("widgets[0].snapshot must be an object");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("flags a non-object availableMetrics entry", () => {
|
|
377
|
+
const result = validateTopoloWidgetResponse({
|
|
378
|
+
success: true,
|
|
379
|
+
widgets: [
|
|
380
|
+
{
|
|
381
|
+
...BASE,
|
|
382
|
+
snapshot: {
|
|
383
|
+
summary: "Snapshot with a non-object metric entry here.",
|
|
384
|
+
availableMetrics: [42],
|
|
385
|
+
},
|
|
386
|
+
type: "stats",
|
|
387
|
+
stats: [],
|
|
388
|
+
},
|
|
389
|
+
],
|
|
390
|
+
});
|
|
391
|
+
expect(result.errors).toContain(
|
|
392
|
+
"widgets[0].snapshot.availableMetrics[0] must be an object",
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
|
|
180
396
|
it("reports missing snapshot summaries", () => {
|
|
181
397
|
const result = validateTopoloWidgetResponse({
|
|
182
398
|
success: true,
|