@oneuptime/common 13.0.3 → 13.0.4

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.
@@ -0,0 +1,522 @@
1
+ import ResourceFacetResolver, {
2
+ RESOURCE_FACET_KEYS,
3
+ ResolvedFacetValue,
4
+ } from "../../../../Server/Utils/Telemetry/ResourceFacetResolver";
5
+ import ServiceService from "../../../../Server/Services/ServiceService";
6
+ import HostService from "../../../../Server/Services/HostService";
7
+ import DockerHostService from "../../../../Server/Services/DockerHostService";
8
+ import PodmanHostService from "../../../../Server/Services/PodmanHostService";
9
+ import KubernetesClusterService from "../../../../Server/Services/KubernetesClusterService";
10
+ import ServerlessFunctionService from "../../../../Server/Services/ServerlessFunctionService";
11
+ import CloudResourceService from "../../../../Server/Services/CloudResourceService";
12
+ import RumApplicationService from "../../../../Server/Services/RumApplicationService";
13
+ import ObjectID from "../../../../Types/ObjectID";
14
+ import Search from "../../../../Types/BaseDatabase/Search";
15
+ import MultiSearch from "../../../../Types/BaseDatabase/MultiSearch";
16
+ import PositiveNumber from "../../../../Types/PositiveNumber";
17
+ import { JSONObject } from "../../../../Types/JSON";
18
+ import {
19
+ afterEach,
20
+ beforeEach,
21
+ describe,
22
+ expect,
23
+ jest,
24
+ test,
25
+ } from "@jest/globals";
26
+
27
+ /*
28
+ * The resolver answers the telemetry filter sidebar's resource facets from
29
+ * Postgres instead of the sampled ClickHouse window. These tests pin the
30
+ * facet-key routing, the per-type display-name fallbacks and search fields,
31
+ * the count merge and ordering, and the per-facet failure isolation. Every
32
+ * service is mocked — nothing touches a database.
33
+ */
34
+
35
+ type Row = JSONObject;
36
+
37
+ interface FindByArgs {
38
+ query: Record<string, unknown>;
39
+ select: Record<string, boolean>;
40
+ limit: PositiveNumber;
41
+ skip: PositiveNumber;
42
+ props: { isRoot: boolean };
43
+ }
44
+
45
+ type FindBySpy = ReturnType<typeof jest.spyOn>;
46
+
47
+ const PROJECT_ID: ObjectID = ObjectID.generate();
48
+
49
+ const SERVICES: Array<{
50
+ name: string;
51
+ service: { findBy: (...args: Array<any>) => Promise<any> };
52
+ facetKeys: Array<string>;
53
+ identifierField: string | null;
54
+ }> = [
55
+ {
56
+ name: "Service",
57
+ service: ServiceService as any,
58
+ facetKeys: ["primaryEntityId", "serviceId"],
59
+ identifierField: null,
60
+ },
61
+ {
62
+ name: "Host",
63
+ service: HostService as any,
64
+ facetKeys: ["hostId"],
65
+ identifierField: "hostIdentifier",
66
+ },
67
+ {
68
+ name: "DockerHost",
69
+ service: DockerHostService as any,
70
+ facetKeys: ["dockerHostId"],
71
+ identifierField: "hostIdentifier",
72
+ },
73
+ {
74
+ name: "PodmanHost",
75
+ service: PodmanHostService as any,
76
+ facetKeys: ["podmanHostId"],
77
+ identifierField: "hostIdentifier",
78
+ },
79
+ {
80
+ name: "KubernetesCluster",
81
+ service: KubernetesClusterService as any,
82
+ facetKeys: ["kubernetesClusterId"],
83
+ identifierField: "clusterIdentifier",
84
+ },
85
+ {
86
+ name: "ServerlessFunction",
87
+ service: ServerlessFunctionService as any,
88
+ facetKeys: ["serverlessFunctionId"],
89
+ identifierField: "functionIdentifier",
90
+ },
91
+ {
92
+ name: "CloudResource",
93
+ service: CloudResourceService as any,
94
+ facetKeys: ["cloudResourceId"],
95
+ identifierField: "resourceIdentifier",
96
+ },
97
+ {
98
+ name: "RumApplication",
99
+ service: RumApplicationService as any,
100
+ facetKeys: ["rumApplicationId"],
101
+ identifierField: "appIdentifier",
102
+ },
103
+ ];
104
+
105
+ const spies: Map<string, FindBySpy> = new Map<string, FindBySpy>();
106
+ const rowsByService: Map<string, Array<Row>> = new Map<string, Array<Row>>();
107
+
108
+ function spyFor(name: string): FindBySpy {
109
+ return spies.get(name)!;
110
+ }
111
+
112
+ function lastArgs(name: string): FindByArgs {
113
+ const calls: Array<Array<unknown>> = spyFor(name).mock.calls as Array<
114
+ Array<unknown>
115
+ >;
116
+ return calls[calls.length - 1]![0] as FindByArgs;
117
+ }
118
+
119
+ beforeEach(() => {
120
+ spies.clear();
121
+ rowsByService.clear();
122
+
123
+ for (const entry of SERVICES) {
124
+ rowsByService.set(entry.name, []);
125
+ const spy: FindBySpy = jest
126
+ .spyOn(entry.service, "findBy")
127
+ .mockImplementation(async (): Promise<any> => {
128
+ return rowsByService.get(entry.name);
129
+ });
130
+ spies.set(entry.name, spy);
131
+ }
132
+ });
133
+
134
+ afterEach(() => {
135
+ jest.restoreAllMocks();
136
+ });
137
+
138
+ describe("ResourceFacetResolver.isResourceFacet", () => {
139
+ test.each([
140
+ "primaryEntityId",
141
+ "serviceId",
142
+ "hostId",
143
+ "dockerHostId",
144
+ "podmanHostId",
145
+ "kubernetesClusterId",
146
+ "serverlessFunctionId",
147
+ "cloudResourceId",
148
+ "rumApplicationId",
149
+ ])("%s is a resource facet", (facetKey: string) => {
150
+ expect(ResourceFacetResolver.isResourceFacet(facetKey)).toBe(true);
151
+ });
152
+
153
+ test.each([
154
+ "",
155
+ "traceId",
156
+ "severityText",
157
+ "HostId",
158
+ "hostid",
159
+ " hostId",
160
+ "resource.host.name",
161
+ ])("%j is not a resource facet", (facetKey: string) => {
162
+ expect(ResourceFacetResolver.isResourceFacet(facetKey)).toBe(false);
163
+ });
164
+
165
+ test("the exported key set has exactly the nine supported keys", () => {
166
+ expect(RESOURCE_FACET_KEYS.size).toBe(9);
167
+ const routed: Array<string> = SERVICES.flatMap(
168
+ (entry: { facetKeys: Array<string> }) => {
169
+ return entry.facetKeys;
170
+ },
171
+ );
172
+ expect([...RESOURCE_FACET_KEYS].sort()).toEqual([...routed].sort());
173
+ });
174
+ });
175
+
176
+ describe("ResourceFacetResolver.resolve routing", () => {
177
+ for (const entry of SERVICES) {
178
+ for (const facetKey of entry.facetKeys) {
179
+ test(`${facetKey} queries only the ${entry.name} service`, async () => {
180
+ const id: string = ObjectID.generate().toString();
181
+ rowsByService.set(entry.name, [{ _id: id, name: "row" }]);
182
+
183
+ const result: Record<
184
+ string,
185
+ Array<ResolvedFacetValue>
186
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
187
+ { facetKey, counts: new Map<string, number>([[id, 4]]) },
188
+ ]);
189
+
190
+ expect(result).toEqual({
191
+ [facetKey]: [{ value: id, count: 4, displayName: "row" }],
192
+ });
193
+
194
+ for (const other of SERVICES) {
195
+ if (other.name === entry.name) {
196
+ expect(spyFor(other.name)).toHaveBeenCalledTimes(1);
197
+ } else {
198
+ expect(spyFor(other.name)).not.toHaveBeenCalled();
199
+ }
200
+ }
201
+ });
202
+ }
203
+ }
204
+
205
+ test("an unknown facet key resolves to an empty list without any query", async () => {
206
+ const result: Record<
207
+ string,
208
+ Array<ResolvedFacetValue>
209
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
210
+ { facetKey: "severityText", counts: new Map<string, number>() },
211
+ ]);
212
+
213
+ expect(result).toEqual({ severityText: [] });
214
+ for (const entry of SERVICES) {
215
+ expect(spyFor(entry.name)).not.toHaveBeenCalled();
216
+ }
217
+ });
218
+
219
+ test("no specs resolves to an empty object", async () => {
220
+ await expect(
221
+ ResourceFacetResolver.resolve(PROJECT_ID, []),
222
+ ).resolves.toEqual({});
223
+ });
224
+
225
+ test("resolves several facets in one call, keyed by facet", async () => {
226
+ const serviceId: string = ObjectID.generate().toString();
227
+ const hostId: string = ObjectID.generate().toString();
228
+ rowsByService.set("Service", [{ _id: serviceId, name: "api" }]);
229
+ rowsByService.set("Host", [{ _id: hostId, name: "web-1" }]);
230
+
231
+ const result: Record<
232
+ string,
233
+ Array<ResolvedFacetValue>
234
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
235
+ { facetKey: "primaryEntityId", counts: new Map<string, number>() },
236
+ { facetKey: "hostId", counts: new Map<string, number>([[hostId, 2]]) },
237
+ ]);
238
+
239
+ expect(Object.keys(result).sort()).toEqual(["hostId", "primaryEntityId"]);
240
+ expect(result["primaryEntityId"]).toEqual([
241
+ { value: serviceId, count: 0, displayName: "api" },
242
+ ]);
243
+ expect(result["hostId"]).toEqual([
244
+ { value: hostId, count: 2, displayName: "web-1" },
245
+ ]);
246
+ });
247
+
248
+ test("a failing facet resolves to [] without failing its siblings", async () => {
249
+ const hostId: string = ObjectID.generate().toString();
250
+ rowsByService.set("Host", [{ _id: hostId, name: "web-1" }]);
251
+ spyFor("Service").mockImplementation(async (): Promise<any> => {
252
+ throw new Error("postgres down");
253
+ });
254
+
255
+ const result: Record<
256
+ string,
257
+ Array<ResolvedFacetValue>
258
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
259
+ { facetKey: "serviceId", counts: new Map<string, number>() },
260
+ { facetKey: "hostId", counts: new Map<string, number>() },
261
+ ]);
262
+
263
+ expect(result["serviceId"]).toEqual([]);
264
+ expect(result["hostId"]).toEqual([
265
+ { value: hostId, count: 0, displayName: "web-1" },
266
+ ]);
267
+ });
268
+ });
269
+
270
+ describe("ResourceFacetResolver.resolve query shape", () => {
271
+ test("scopes to the project, selects id + name, uses root props and skip 0", async () => {
272
+ await ResourceFacetResolver.resolve(PROJECT_ID, [
273
+ { facetKey: "serviceId", counts: new Map<string, number>() },
274
+ ]);
275
+
276
+ const args: FindByArgs = lastArgs("Service");
277
+ expect(args.query).toEqual({ projectId: PROJECT_ID });
278
+ expect(args.select).toEqual({ _id: true, name: true });
279
+ expect(args.props).toEqual({ isRoot: true });
280
+ expect(args.skip.toNumber()).toBe(0);
281
+ });
282
+
283
+ test("defaults the limit to 500", async () => {
284
+ await ResourceFacetResolver.resolve(PROJECT_ID, [
285
+ { facetKey: "hostId", counts: new Map<string, number>() },
286
+ ]);
287
+
288
+ expect(lastArgs("Host").limit.toNumber()).toBe(500);
289
+ });
290
+
291
+ test("honours an explicit limit", async () => {
292
+ await ResourceFacetResolver.resolve(PROJECT_ID, [
293
+ { facetKey: "hostId", counts: new Map<string, number>(), limit: 25 },
294
+ ]);
295
+
296
+ expect(lastArgs("Host").limit.toNumber()).toBe(25);
297
+ });
298
+
299
+ test("services search by name with a trimmed plain Search", async () => {
300
+ await ResourceFacetResolver.resolve(PROJECT_ID, [
301
+ {
302
+ facetKey: "primaryEntityId",
303
+ counts: new Map<string, number>(),
304
+ searchText: " checkout ",
305
+ },
306
+ ]);
307
+
308
+ const name: unknown = lastArgs("Service").query["name"];
309
+ expect(name).toBeInstanceOf(Search);
310
+ expect((name as Search<string>).value).toBe("checkout");
311
+ });
312
+
313
+ for (const entry of SERVICES) {
314
+ if (!entry.identifierField) {
315
+ continue;
316
+ }
317
+
318
+ const identifierField: string = entry.identifierField;
319
+
320
+ test(`${entry.name} searches name and ${identifierField}, and selects both`, async () => {
321
+ await ResourceFacetResolver.resolve(PROJECT_ID, [
322
+ {
323
+ facetKey: entry.facetKeys[0]!,
324
+ counts: new Map<string, number>(),
325
+ searchText: " prod ",
326
+ },
327
+ ]);
328
+
329
+ const args: FindByArgs = lastArgs(entry.name);
330
+ const name: unknown = args.query["name"];
331
+ expect(name).toBeInstanceOf(MultiSearch);
332
+ expect((name as MultiSearch).fields).toEqual(["name", identifierField]);
333
+ expect((name as MultiSearch).value).toBe("prod");
334
+ expect(args.query["projectId"]).toBe(PROJECT_ID);
335
+ expect(args.select).toEqual({
336
+ _id: true,
337
+ name: true,
338
+ [identifierField]: true,
339
+ });
340
+ });
341
+ }
342
+
343
+ test.each([undefined, "", " ", "\t\n"])(
344
+ "search text %j adds no name filter",
345
+ async (searchText: string | undefined) => {
346
+ await ResourceFacetResolver.resolve(PROJECT_ID, [
347
+ {
348
+ facetKey: "kubernetesClusterId",
349
+ counts: new Map<string, number>(),
350
+ searchText,
351
+ },
352
+ ]);
353
+
354
+ expect(lastArgs("KubernetesCluster").query).toEqual({
355
+ projectId: PROJECT_ID,
356
+ });
357
+ },
358
+ );
359
+ });
360
+
361
+ describe("ResourceFacetResolver.resolve display names", () => {
362
+ test("a service without a name is shown as Unknown", async () => {
363
+ const id: string = ObjectID.generate().toString();
364
+ rowsByService.set("Service", [{ _id: id }]);
365
+
366
+ const result: Record<
367
+ string,
368
+ Array<ResolvedFacetValue>
369
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
370
+ { facetKey: "serviceId", counts: new Map<string, number>() },
371
+ ]);
372
+
373
+ expect(result["serviceId"]![0]!.displayName).toBe("Unknown");
374
+ });
375
+
376
+ for (const entry of SERVICES) {
377
+ if (!entry.identifierField) {
378
+ continue;
379
+ }
380
+
381
+ const identifierField: string = entry.identifierField;
382
+ const facetKey: string = entry.facetKeys[0]!;
383
+
384
+ test(`${entry.name} prefers name, then ${identifierField}, then Unknown`, async () => {
385
+ const named: string = ObjectID.generate().toString();
386
+ const identified: string = ObjectID.generate().toString();
387
+ const anonymous: string = ObjectID.generate().toString();
388
+
389
+ rowsByService.set(entry.name, [
390
+ { _id: named, name: "B-named", [identifierField]: "ignored" },
391
+ { _id: identified, name: "", [identifierField]: "A-identifier" },
392
+ { _id: anonymous },
393
+ ]);
394
+
395
+ const result: Record<
396
+ string,
397
+ Array<ResolvedFacetValue>
398
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
399
+ { facetKey, counts: new Map<string, number>() },
400
+ ]);
401
+
402
+ const byId: Map<string, string> = new Map<string, string>(
403
+ result[facetKey]!.map((v: ResolvedFacetValue): [string, string] => {
404
+ return [v.value, v.displayName];
405
+ }),
406
+ );
407
+
408
+ expect(byId.get(named)).toBe("B-named");
409
+ expect(byId.get(identified)).toBe("A-identifier");
410
+ expect(byId.get(anonymous)).toBe("Unknown");
411
+ });
412
+ }
413
+ });
414
+
415
+ describe("ResourceFacetResolver.resolve count merge and ordering", () => {
416
+ test("rows without an id are dropped", async () => {
417
+ const id: string = ObjectID.generate().toString();
418
+ rowsByService.set("Host", [
419
+ { name: "no-id" },
420
+ { _id: "", name: "empty-id" },
421
+ { _id: id, name: "kept" },
422
+ ]);
423
+
424
+ const result: Record<
425
+ string,
426
+ Array<ResolvedFacetValue>
427
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
428
+ { facetKey: "hostId", counts: new Map<string, number>() },
429
+ ]);
430
+
431
+ expect(result["hostId"]).toEqual([
432
+ { value: id, count: 0, displayName: "kept" },
433
+ ]);
434
+ });
435
+
436
+ test("entities absent from the telemetry sample get count 0", async () => {
437
+ rowsByService.set("Host", [{ _id: "a", name: "quiet" }]);
438
+
439
+ const result: Record<
440
+ string,
441
+ Array<ResolvedFacetValue>
442
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
443
+ {
444
+ facetKey: "hostId",
445
+ counts: new Map<string, number>([["someone-else", 99]]),
446
+ },
447
+ ]);
448
+
449
+ expect(result["hostId"]).toEqual([
450
+ { value: "a", count: 0, displayName: "quiet" },
451
+ ]);
452
+ });
453
+
454
+ test("counts for ids that are not project resources are not invented", async () => {
455
+ rowsByService.set("Host", []);
456
+
457
+ const result: Record<
458
+ string,
459
+ Array<ResolvedFacetValue>
460
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
461
+ { facetKey: "hostId", counts: new Map<string, number>([["ghost", 7]]) },
462
+ ]);
463
+
464
+ expect(result["hostId"]).toEqual([]);
465
+ });
466
+
467
+ test("sorts by count descending, then display name ascending", async () => {
468
+ rowsByService.set("Host", [
469
+ { _id: "1", name: "zeta" },
470
+ { _id: "2", name: "alpha" },
471
+ { _id: "3", name: "mid" },
472
+ { _id: "4", name: "beta" },
473
+ { _id: "5", name: "gamma" },
474
+ ]);
475
+
476
+ const result: Record<
477
+ string,
478
+ Array<ResolvedFacetValue>
479
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
480
+ {
481
+ facetKey: "hostId",
482
+ counts: new Map<string, number>([
483
+ ["1", 10],
484
+ ["3", 50],
485
+ ["5", 10],
486
+ ]),
487
+ },
488
+ ]);
489
+
490
+ expect(
491
+ result["hostId"]!.map((v: ResolvedFacetValue): string => {
492
+ return v.displayName;
493
+ }),
494
+ ).toEqual(["mid", "gamma", "zeta", "alpha", "beta"]);
495
+ expect(
496
+ result["hostId"]!.map((v: ResolvedFacetValue): number => {
497
+ return v.count;
498
+ }),
499
+ ).toEqual([50, 10, 10, 0, 0]);
500
+ });
501
+
502
+ test("ObjectID-typed _id values are stringified", async () => {
503
+ const id: ObjectID = ObjectID.generate();
504
+ rowsByService.set("Service", [
505
+ { _id: id as unknown as string, name: "api" },
506
+ ]);
507
+
508
+ const result: Record<
509
+ string,
510
+ Array<ResolvedFacetValue>
511
+ > = await ResourceFacetResolver.resolve(PROJECT_ID, [
512
+ {
513
+ facetKey: "serviceId",
514
+ counts: new Map<string, number>([[id.toString(), 3]]),
515
+ },
516
+ ]);
517
+
518
+ expect(result["serviceId"]).toEqual([
519
+ { value: id.toString(), count: 3, displayName: "api" },
520
+ ]);
521
+ });
522
+ });