@oneuptime/common 11.5.5 → 11.5.7

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.
Files changed (41) hide show
  1. package/Models/DatabaseModels/User.ts +47 -0
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.ts +11 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1784048917994-AddCreatedByUserToUser.ts +23 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  5. package/Server/Middleware/MasterAdminAuthorization.ts +37 -0
  6. package/Server/Middleware/ProjectAuthorization.ts +40 -15
  7. package/Server/Services/TeamMemberService.ts +2 -0
  8. package/Server/Services/UserService.ts +10 -0
  9. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +16 -6
  10. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +14 -0
  11. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +14 -3
  12. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +130 -12
  13. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +553 -0
  14. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +590 -0
  15. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageFormatterUnits.test.ts +254 -0
  16. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +821 -0
  17. package/build/dist/Models/DatabaseModels/User.js +46 -0
  18. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  19. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.js +9 -0
  20. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.js.map +1 -1
  21. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784048917994-AddCreatedByUserToUser.js +18 -0
  22. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784048917994-AddCreatedByUserToUser.js.map +1 -0
  23. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  25. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +26 -0
  26. package/build/dist/Server/Middleware/MasterAdminAuthorization.js.map +1 -1
  27. package/build/dist/Server/Middleware/ProjectAuthorization.js +40 -14
  28. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  29. package/build/dist/Server/Services/TeamMemberService.js +2 -0
  30. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  31. package/build/dist/Server/Services/UserService.js +8 -0
  32. package/build/dist/Server/Services/UserService.js.map +1 -1
  33. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +15 -7
  34. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  35. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +12 -1
  36. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  37. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +8 -3
  38. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  39. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +96 -12
  40. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1,821 @@
1
+ import MonitorCriteriaObservationBuilder from "../../../../Server/Utils/Monitor/MonitorCriteriaObservationBuilder";
2
+ import Monitor from "../../../../Models/DatabaseModels/Monitor";
3
+ import {
4
+ CheckOn,
5
+ CriteriaFilter,
6
+ EvaluateOverTimeType,
7
+ FilterType,
8
+ } from "../../../../Types/Monitor/CriteriaFilter";
9
+ import AggregateModel from "../../../../Types/BaseDatabase/AggregatedModel";
10
+ import AggregatedResult from "../../../../Types/BaseDatabase/AggregatedResult";
11
+ import MetricAliasData from "../../../../Types/Metrics/MetricAliasData";
12
+ import MetricFormulaConfigData from "../../../../Types/Metrics/MetricFormulaConfigData";
13
+ import MetricQueryConfigData from "../../../../Types/Metrics/MetricQueryConfigData";
14
+ import MetricQueryData from "../../../../Types/Metrics/MetricQueryData";
15
+ import MetricsViewConfig from "../../../../Types/Metrics/MetricsViewConfig";
16
+ import MetricMonitorResponse from "../../../../Types/Monitor/MetricMonitor/MetricMonitorResponse";
17
+ import MonitorStep from "../../../../Types/Monitor/MonitorStep";
18
+ import RollingTime from "../../../../Types/RollingTime/RollingTime";
19
+ import ObjectID from "../../../../Types/ObjectID";
20
+
21
+ /*
22
+ * Assembles the monitor + metric response + step trio that
23
+ * MonitorCriteriaObservationBuilder needs, for a single metric alias "a"
24
+ * with a given native/legend unit and sample values. Mirrors the fixtures
25
+ * used by MetricMonitorCriteria.test.ts and MonitorCriteriaMessageBuilderUnits
26
+ * .test.ts, but additionally allows an optional nativeUnitsByMetricName map so
27
+ * we can exercise the native-unit fallback path in resolveMetricUnits.
28
+ */
29
+ function buildInputs(input: {
30
+ metricNativeUnit: string;
31
+ sampleValues: Array<number>;
32
+ criteriaFilter: CriteriaFilter;
33
+ metricName?: string;
34
+ nativeUnitsByMetricName?: { [key: string]: string };
35
+ }): {
36
+ monitor: Monitor;
37
+ criteriaFilter: CriteriaFilter;
38
+ monitorStep: MonitorStep;
39
+ dataToProcess: MetricMonitorResponse;
40
+ } {
41
+ const metricName: string = input.metricName ?? "response_time";
42
+
43
+ const aliasData: MetricAliasData = {
44
+ metricVariable: "a",
45
+ title: "Response Time",
46
+ description: undefined,
47
+ legend: undefined,
48
+ legendUnit: input.metricNativeUnit,
49
+ };
50
+
51
+ const queryConfig: MetricQueryConfigData = {
52
+ metricAliasData: aliasData,
53
+ metricQueryData: {
54
+ filterData: {
55
+ metricName: metricName,
56
+ },
57
+ } as unknown as MetricQueryData,
58
+ };
59
+
60
+ const metricViewConfig: MetricsViewConfig = {
61
+ queryConfigs: [queryConfig],
62
+ formulaConfigs: [],
63
+ };
64
+
65
+ const monitorStep: MonitorStep = new MonitorStep();
66
+ monitorStep.data = {
67
+ id: ObjectID.generate().toString(),
68
+ monitorCriteria: { data: undefined } as never,
69
+ } as unknown as MonitorStep["data"];
70
+ monitorStep.data!.metricMonitor = {
71
+ metricViewConfig,
72
+ rollingTime: RollingTime.Past1Minute,
73
+ };
74
+
75
+ const aggregated: AggregatedResult = {
76
+ data: input.sampleValues.map((v: number) => {
77
+ return {
78
+ timestamp: new Date(),
79
+ value: v,
80
+ } as AggregateModel;
81
+ }),
82
+ };
83
+
84
+ const dataToProcess: MetricMonitorResponse = {
85
+ projectId: ObjectID.generate(),
86
+ metricResult: [aggregated],
87
+ metricViewConfig,
88
+ monitorId: ObjectID.generate(),
89
+ nativeUnitsByMetricName: input.nativeUnitsByMetricName,
90
+ };
91
+
92
+ return {
93
+ monitor: new Monitor(),
94
+ criteriaFilter: input.criteriaFilter,
95
+ monitorStep,
96
+ dataToProcess,
97
+ };
98
+ }
99
+
100
+ /*
101
+ * Two-query variant (alias "a" and alias "b") so we can prove the observation
102
+ * builder resolves the criteria's alias to the correct series + unit. Each
103
+ * alias has its own legendUnit and its own aggregated result, positioned by
104
+ * index the way MonitorCriteriaDataExtractor.extractMetricValues expects.
105
+ */
106
+ function buildTwoAliasInputs(input: {
107
+ aliasAUnit: string;
108
+ aliasBUnit: string;
109
+ aliasASamples: Array<number>;
110
+ aliasBSamples: Array<number>;
111
+ criteriaFilter: CriteriaFilter;
112
+ }): {
113
+ monitor: Monitor;
114
+ criteriaFilter: CriteriaFilter;
115
+ monitorStep: MonitorStep;
116
+ dataToProcess: MetricMonitorResponse;
117
+ } {
118
+ const queryA: MetricQueryConfigData = {
119
+ metricAliasData: {
120
+ metricVariable: "a",
121
+ title: "",
122
+ description: "",
123
+ legend: "",
124
+ legendUnit: input.aliasAUnit,
125
+ },
126
+ metricQueryData: {
127
+ filterData: {
128
+ metricName: "request_latency",
129
+ },
130
+ } as unknown as MetricQueryData,
131
+ };
132
+
133
+ const queryB: MetricQueryConfigData = {
134
+ metricAliasData: {
135
+ metricVariable: "b",
136
+ title: "",
137
+ description: "",
138
+ legend: "",
139
+ legendUnit: input.aliasBUnit,
140
+ },
141
+ metricQueryData: {
142
+ filterData: {
143
+ metricName: "db_latency",
144
+ },
145
+ } as unknown as MetricQueryData,
146
+ };
147
+
148
+ const metricViewConfig: MetricsViewConfig = {
149
+ queryConfigs: [queryA, queryB],
150
+ formulaConfigs: [],
151
+ };
152
+
153
+ const monitorStep: MonitorStep = new MonitorStep();
154
+ monitorStep.data = {
155
+ id: ObjectID.generate().toString(),
156
+ monitorCriteria: { data: undefined } as never,
157
+ } as unknown as MonitorStep["data"];
158
+ monitorStep.data!.metricMonitor = {
159
+ metricViewConfig,
160
+ rollingTime: RollingTime.Past1Minute,
161
+ };
162
+
163
+ const resultA: AggregatedResult = {
164
+ data: input.aliasASamples.map((v: number) => {
165
+ return { timestamp: new Date(), value: v } as AggregateModel;
166
+ }),
167
+ };
168
+ const resultB: AggregatedResult = {
169
+ data: input.aliasBSamples.map((v: number) => {
170
+ return { timestamp: new Date(), value: v } as AggregateModel;
171
+ }),
172
+ };
173
+
174
+ const dataToProcess: MetricMonitorResponse = {
175
+ projectId: ObjectID.generate(),
176
+ metricResult: [resultA, resultB],
177
+ metricViewConfig,
178
+ monitorId: ObjectID.generate(),
179
+ };
180
+
181
+ return {
182
+ monitor: new Monitor(),
183
+ criteriaFilter: input.criteriaFilter,
184
+ monitorStep,
185
+ dataToProcess,
186
+ };
187
+ }
188
+
189
+ /*
190
+ * Formula-alias variant: a base query (alias "a") plus a FORMULA (alias "c")
191
+ * that references it. extractMetricValues positions the formula's series at
192
+ * queryConfigs.length + formulaIndex, so metricResult[0] is the base query
193
+ * series and metricResult[1] is the formula series. Lets us prove a criteria
194
+ * targeting the formula alias "c" resolves the FORMULA's legendUnit — not the
195
+ * base query's legendUnit.
196
+ */
197
+ function buildFormulaAliasInputs(input: {
198
+ queryUnit: string;
199
+ formulaUnit: string;
200
+ querySamples: Array<number>;
201
+ formulaSamples: Array<number>;
202
+ criteriaFilter: CriteriaFilter;
203
+ }): {
204
+ monitor: Monitor;
205
+ criteriaFilter: CriteriaFilter;
206
+ monitorStep: MonitorStep;
207
+ dataToProcess: MetricMonitorResponse;
208
+ } {
209
+ const queryConfig: MetricQueryConfigData = {
210
+ metricAliasData: {
211
+ metricVariable: "a",
212
+ title: "",
213
+ description: "",
214
+ legend: "",
215
+ legendUnit: input.queryUnit,
216
+ },
217
+ metricQueryData: {
218
+ filterData: {
219
+ metricName: "x",
220
+ },
221
+ } as unknown as MetricQueryData,
222
+ };
223
+
224
+ const formulaConfig: MetricFormulaConfigData = {
225
+ metricAliasData: {
226
+ metricVariable: "c",
227
+ title: "",
228
+ description: "",
229
+ legend: "",
230
+ legendUnit: input.formulaUnit,
231
+ },
232
+ metricFormulaData: {
233
+ metricFormula: "a",
234
+ },
235
+ };
236
+
237
+ const metricViewConfig: MetricsViewConfig = {
238
+ queryConfigs: [queryConfig],
239
+ formulaConfigs: [formulaConfig],
240
+ };
241
+
242
+ const monitorStep: MonitorStep = new MonitorStep();
243
+ monitorStep.data = {
244
+ id: ObjectID.generate().toString(),
245
+ monitorCriteria: { data: undefined } as never,
246
+ } as unknown as MonitorStep["data"];
247
+ monitorStep.data!.metricMonitor = {
248
+ metricViewConfig,
249
+ rollingTime: RollingTime.Past1Minute,
250
+ };
251
+
252
+ const queryResult: AggregatedResult = {
253
+ data: input.querySamples.map((v: number) => {
254
+ return { timestamp: new Date(), value: v } as AggregateModel;
255
+ }),
256
+ };
257
+ const formulaResult: AggregatedResult = {
258
+ data: input.formulaSamples.map((v: number) => {
259
+ return { timestamp: new Date(), value: v } as AggregateModel;
260
+ }),
261
+ };
262
+
263
+ const dataToProcess: MetricMonitorResponse = {
264
+ projectId: ObjectID.generate(),
265
+ // index 0 => base query "a", index 1 => formula "c" (positional).
266
+ metricResult: [queryResult, formulaResult],
267
+ metricViewConfig,
268
+ monitorId: ObjectID.generate(),
269
+ };
270
+
271
+ return {
272
+ monitor: new Monitor(),
273
+ criteriaFilter: input.criteriaFilter,
274
+ monitorStep,
275
+ dataToProcess,
276
+ };
277
+ }
278
+
279
+ /*
280
+ * Convenience for a metric-value criteria filter with the common options.
281
+ */
282
+ function metricFilter(options: {
283
+ filterType?: FilterType;
284
+ value?: string;
285
+ metricAlias?: string;
286
+ thresholdUnit?: string;
287
+ }): CriteriaFilter {
288
+ return {
289
+ checkOn: CheckOn.MetricValue,
290
+ filterType: options.filterType ?? FilterType.GreaterThan,
291
+ value: options.value ?? "1",
292
+ metricMonitorOptions: {
293
+ metricAlias: options.metricAlias ?? "a",
294
+ metricAggregationType: EvaluateOverTimeType.AnyValue,
295
+ thresholdUnit: options.thresholdUnit,
296
+ },
297
+ };
298
+ }
299
+
300
+ describe("MonitorCriteriaObservationBuilder.describeFilterObservation — metric value", () => {
301
+ /*
302
+ * Native/legend unit "sec": every rendered number carries " sec" and the
303
+ * three-point series produces the plural "(min .. max ..) across 3 data
304
+ * points" shape. "latest" is the LAST sample, not the max.
305
+ */
306
+ test("native unit 'sec' — three equal samples render with the sec suffix", () => {
307
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "5" });
308
+
309
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
310
+ metricNativeUnit: "sec",
311
+ sampleValues: [0.06, 0.06, 0.06],
312
+ criteriaFilter,
313
+ });
314
+
315
+ const observation: string | null =
316
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
317
+
318
+ expect(observation).toBe(
319
+ "Metric Value (a) recorded latest 0.06 sec (min 0.06 sec, max 0.06 sec) across 3 data points.",
320
+ );
321
+ });
322
+
323
+ /*
324
+ * Legend unit "ms": two-point series. Each value is toFixed(2), so 40 -> 40.00
325
+ * and 60 -> 60.00; latest is 60 (the last element).
326
+ */
327
+ test("legend unit 'ms' — two samples render latest/min/max with ms", () => {
328
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "10" });
329
+
330
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
331
+ metricNativeUnit: "ms",
332
+ sampleValues: [40, 60],
333
+ criteriaFilter,
334
+ });
335
+
336
+ const observation: string | null =
337
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
338
+
339
+ expect(observation).toBe(
340
+ "Metric Value (a) recorded latest 60.00 ms (min 40.00 ms, max 60.00 ms) across 2 data points.",
341
+ );
342
+ });
343
+
344
+ /*
345
+ * thresholdUnit "sec" over a native "ms" metric: 2500 ms converts to 2.5 sec
346
+ * and the label follows the threshold unit. Single sample => singular
347
+ * "across 1 data point" with no (min .. max ..) clause.
348
+ */
349
+ test("thresholdUnit 'sec' over native 'ms' — converts 2500ms to 2.50 sec", () => {
350
+ const criteriaFilter: CriteriaFilter = metricFilter({
351
+ value: "2",
352
+ thresholdUnit: "sec",
353
+ });
354
+
355
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
356
+ metricNativeUnit: "ms",
357
+ sampleValues: [2500],
358
+ criteriaFilter,
359
+ });
360
+
361
+ const observation: string | null =
362
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
363
+
364
+ expect(observation).toBe(
365
+ "Metric Value (a) recorded latest 2.50 sec across 1 data point.",
366
+ );
367
+ expect(observation).toContain("2.50 sec");
368
+ });
369
+
370
+ /*
371
+ * thresholdUnit "GB" over a native "bytes" metric: 2.5e9 bytes converts to a
372
+ * clean 2.5 GB, rendered "2.50 GB".
373
+ */
374
+ test("thresholdUnit 'GB' over native 'bytes' — converts 2.5e9 bytes to 2.50 GB", () => {
375
+ const criteriaFilter: CriteriaFilter = metricFilter({
376
+ value: "2",
377
+ thresholdUnit: "GB",
378
+ });
379
+
380
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
381
+ metricNativeUnit: "bytes",
382
+ sampleValues: [2.5e9],
383
+ criteriaFilter,
384
+ });
385
+
386
+ const observation: string | null =
387
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
388
+
389
+ expect(observation).toBe(
390
+ "Metric Value (a) recorded latest 2.50 GB across 1 data point.",
391
+ );
392
+ expect(observation).toContain("2.50 GB");
393
+ });
394
+
395
+ /*
396
+ * Incompatible families (native "ms", thresholdUnit "MB"): conversion no-ops,
397
+ * so 5000 passes through UNCONVERTED as "5000.00" but is still LABELLED "MB".
398
+ */
399
+ test("incompatible thresholdUnit 'MB' over native 'ms' — value unconverted, still labelled MB", () => {
400
+ const criteriaFilter: CriteriaFilter = metricFilter({
401
+ value: "1000",
402
+ thresholdUnit: "MB",
403
+ });
404
+
405
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
406
+ metricNativeUnit: "ms",
407
+ sampleValues: [5000],
408
+ criteriaFilter,
409
+ });
410
+
411
+ const observation: string | null =
412
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
413
+
414
+ expect(observation).toBe(
415
+ "Metric Value (a) recorded latest 5000.00 MB across 1 data point.",
416
+ );
417
+ // Unconverted value passed through.
418
+ expect(observation).toContain("5000.00");
419
+ // But labelled with the (incompatible) threshold unit.
420
+ expect(observation).toContain("MB");
421
+ });
422
+
423
+ /*
424
+ * Dimensionless OTel unit "1" is suppressed: a fraction like 0.06 renders
425
+ * WITHOUT any suffix — never the misleading "0.06 1".
426
+ */
427
+ test("dimensionless native '1' — no unit suffix", () => {
428
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "0.9" });
429
+
430
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
431
+ metricNativeUnit: "1",
432
+ sampleValues: [0.06],
433
+ criteriaFilter,
434
+ });
435
+
436
+ const observation: string | null =
437
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
438
+
439
+ expect(observation).toBe(
440
+ "Metric Value (a) recorded latest 0.06 across 1 data point.",
441
+ );
442
+ expect(observation).toContain("latest 0.06 ");
443
+ expect(observation).not.toContain("0.06 1");
444
+ });
445
+
446
+ /*
447
+ * No known unit at all (empty legendUnit, no nativeUnitsByMetricName map):
448
+ * numbers render with no suffix.
449
+ */
450
+ test("missing/empty native unit — no unit suffix", () => {
451
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "1" });
452
+
453
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
454
+ metricNativeUnit: "",
455
+ sampleValues: [0.06],
456
+ criteriaFilter,
457
+ });
458
+
459
+ const observation: string | null =
460
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
461
+
462
+ expect(observation).toBe(
463
+ "Metric Value (a) recorded latest 0.06 across 1 data point.",
464
+ );
465
+ });
466
+
467
+ /*
468
+ * nativeUnitsByMetricName supplies the unit when the alias has no legendUnit:
469
+ * the map value ("ms", keyed by the lowercased metric name) becomes the
470
+ * sample unit.
471
+ */
472
+ test("native-unit fallback via nativeUnitsByMetricName when legendUnit empty", () => {
473
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "10" });
474
+
475
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
476
+ metricNativeUnit: "",
477
+ metricName: "http.server.duration",
478
+ sampleValues: [40, 60],
479
+ criteriaFilter,
480
+ nativeUnitsByMetricName: { "http.server.duration": "ms" },
481
+ });
482
+
483
+ const observation: string | null =
484
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
485
+
486
+ expect(observation).toBe(
487
+ "Metric Value (a) recorded latest 60.00 ms (min 40.00 ms, max 60.00 ms) across 2 data points.",
488
+ );
489
+ });
490
+
491
+ /*
492
+ * A metricResult present but with an empty data array yields the
493
+ * "returned no data points." message (no unit, no numbers).
494
+ */
495
+ test("no samples — returns the no-data-points message", () => {
496
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "1" });
497
+
498
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
499
+ metricNativeUnit: "sec",
500
+ sampleValues: [],
501
+ criteriaFilter,
502
+ });
503
+
504
+ const observation: string | null =
505
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
506
+
507
+ expect(observation).toBe("Metric Value (a) returned no data points.");
508
+ });
509
+
510
+ /*
511
+ * Alias resolution: with two queries (a: ms, b: sec) a criteria targeting "b"
512
+ * summarizes the "b" series with its "sec" unit — proving the right series and
513
+ * unit are chosen, not alias "a"'s ms values.
514
+ */
515
+ test("alias resolution — criteria on 'b' summarizes the b series in sec", () => {
516
+ const criteriaFilter: CriteriaFilter = metricFilter({
517
+ value: "1",
518
+ metricAlias: "b",
519
+ });
520
+
521
+ const inputs: ReturnType<typeof buildTwoAliasInputs> = buildTwoAliasInputs({
522
+ aliasAUnit: "ms",
523
+ aliasBUnit: "sec",
524
+ aliasASamples: [40, 60],
525
+ aliasBSamples: [1, 2, 3],
526
+ criteriaFilter,
527
+ });
528
+
529
+ const observation: string | null =
530
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
531
+
532
+ expect(observation).toBe(
533
+ "Metric Value (b) recorded latest 3.00 sec (min 1.00 sec, max 3.00 sec) across 3 data points.",
534
+ );
535
+ // The a-series (ms, 40/60) must not leak in.
536
+ expect(observation).not.toContain("(a)");
537
+ expect(observation).not.toContain("ms");
538
+ expect(observation).not.toContain("60.00");
539
+ });
540
+
541
+ /*
542
+ * Flagship fraction->percent: native/legend unit "1" (dimensionless) with a
543
+ * thresholdUnit of "%". The RAW "1" drives the numeric conversion (0.06 *
544
+ * 100 = 6) while the DISPLAY unit is the real "%" — never "0.06 1". The
545
+ * display unit resolved for this same criteria is "%".
546
+ */
547
+ test("fraction->percent — native '1' + thresholdUnit '%' converts 0.06 to 6.00 %", () => {
548
+ const criteriaFilter: CriteriaFilter = metricFilter({
549
+ value: "2",
550
+ thresholdUnit: "%",
551
+ });
552
+
553
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
554
+ metricNativeUnit: "1",
555
+ sampleValues: [0.06],
556
+ criteriaFilter,
557
+ });
558
+
559
+ const observation: string | null =
560
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
561
+
562
+ expect(observation).toBe(
563
+ "Metric Value (a) recorded latest 6.00 % across 1 data point.",
564
+ );
565
+
566
+ const unit: string | undefined =
567
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
568
+
569
+ expect(unit).toBe("%");
570
+ });
571
+
572
+ /*
573
+ * Reverse conversion with threshold-side "1" suppression: native/legend unit
574
+ * "%" with a thresholdUnit of "1". The RAW "1" drives the conversion (6 %
575
+ * -> 0.06 fraction) but the dimensionless "1" is SUPPRESSED for display, so
576
+ * the value renders with NO unit suffix. Display unit resolves to undefined.
577
+ */
578
+ test("percent->fraction — native '%' + thresholdUnit '1' converts 6 to 0.06, no suffix", () => {
579
+ const criteriaFilter: CriteriaFilter = metricFilter({
580
+ value: "0.5",
581
+ thresholdUnit: "1",
582
+ });
583
+
584
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
585
+ metricNativeUnit: "%",
586
+ sampleValues: [6],
587
+ criteriaFilter,
588
+ });
589
+
590
+ const observation: string | null =
591
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
592
+
593
+ expect(observation).toBe(
594
+ "Metric Value (a) recorded latest 0.06 across 1 data point.",
595
+ );
596
+ // The suppressed "1" must never leak in as a suffix.
597
+ expect(observation).not.toContain("0.06 1");
598
+
599
+ const unit: string | undefined =
600
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
601
+
602
+ expect(unit).toBeUndefined();
603
+ });
604
+
605
+ /*
606
+ * Formula alias resolution: the criteria targets the FORMULA alias "c",
607
+ * whose series lives at metricResult[1] (queryConfigs.length + formulaIndex).
608
+ * The observation must render "(c)" and use the FORMULA's legendUnit "sec",
609
+ * not the base query "a"'s "ms". No thresholdUnit, so values pass through
610
+ * unconverted (latest 2.50, min 1.50, max 2.50 over two points).
611
+ */
612
+ test("formula alias 'c' — observation uses the formula's legendUnit 'sec'", () => {
613
+ const criteriaFilter: CriteriaFilter = metricFilter({
614
+ value: "1",
615
+ metricAlias: "c",
616
+ });
617
+
618
+ const inputs: ReturnType<typeof buildFormulaAliasInputs> =
619
+ buildFormulaAliasInputs({
620
+ queryUnit: "ms",
621
+ formulaUnit: "sec",
622
+ querySamples: [40, 60],
623
+ formulaSamples: [1.5, 2.5],
624
+ criteriaFilter,
625
+ });
626
+
627
+ const observation: string | null =
628
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
629
+
630
+ expect(observation).toBe(
631
+ "Metric Value (c) recorded latest 2.50 sec (min 1.50 sec, max 2.50 sec) across 2 data points.",
632
+ );
633
+ // The formula alias, not the base query, is summarized.
634
+ expect(observation).toContain("(c)");
635
+ expect(observation).not.toContain("(a)");
636
+ expect(observation).not.toContain("ms");
637
+ });
638
+
639
+ /*
640
+ * Bit-units family: native/legend unit "mbit" converted into thresholdUnit
641
+ * "gbit" (mbit.toCanonical=1e6, gbit.toCanonical=1e9), so 1500 mbit -> 1.5
642
+ * gbit, labelled with the threshold unit.
643
+ */
644
+ test("bit family — native 'mbit' + thresholdUnit 'gbit' converts 1500 to 1.50 gbit", () => {
645
+ const criteriaFilter: CriteriaFilter = metricFilter({
646
+ value: "1",
647
+ thresholdUnit: "gbit",
648
+ });
649
+
650
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
651
+ metricNativeUnit: "mbit",
652
+ sampleValues: [1500],
653
+ criteriaFilter,
654
+ });
655
+
656
+ const observation: string | null =
657
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
658
+
659
+ expect(observation).toBe(
660
+ "Metric Value (a) recorded latest 1.50 gbit across 1 data point.",
661
+ );
662
+ expect(observation).toContain("1.50 gbit");
663
+ });
664
+
665
+ /*
666
+ * Unknown sample unit (empty legendUnit and no nativeUnitsByMetricName) but a
667
+ * thresholdUnit "sec" is set. The conversion no-ops on the !sampleUnit guard
668
+ * so 5 passes through unconverted, yet the value is still LABELLED "sec"
669
+ * because the display unit follows the threshold unit.
670
+ */
671
+ test("unknown sampleUnit + thresholdUnit 'sec' — value unconverted, still labelled sec", () => {
672
+ const criteriaFilter: CriteriaFilter = metricFilter({
673
+ value: "1",
674
+ thresholdUnit: "sec",
675
+ });
676
+
677
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
678
+ metricNativeUnit: "",
679
+ sampleValues: [5],
680
+ criteriaFilter,
681
+ });
682
+
683
+ const observation: string | null =
684
+ MonitorCriteriaObservationBuilder.describeFilterObservation(inputs);
685
+
686
+ expect(observation).toBe(
687
+ "Metric Value (a) recorded latest 5.00 sec across 1 data point.",
688
+ );
689
+ // Unconverted value, but labelled with the threshold unit.
690
+ expect(observation).toContain("5.00 sec");
691
+ });
692
+ });
693
+
694
+ describe("MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit", () => {
695
+ /*
696
+ * Metric criteria, native "sec", no thresholdUnit => the native unit wins.
697
+ */
698
+ test("native 'sec' with no thresholdUnit => 'sec'", () => {
699
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "5" });
700
+
701
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
702
+ metricNativeUnit: "sec",
703
+ sampleValues: [0.5],
704
+ criteriaFilter,
705
+ });
706
+
707
+ const unit: string | undefined =
708
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
709
+
710
+ expect(unit).toBe("sec");
711
+ });
712
+
713
+ /*
714
+ * thresholdUnit always wins over the native unit, even in a compatible family.
715
+ */
716
+ test("thresholdUnit 'MB' over native 'ms' => 'MB' (threshold wins)", () => {
717
+ const criteriaFilter: CriteriaFilter = metricFilter({
718
+ value: "1000",
719
+ thresholdUnit: "MB",
720
+ });
721
+
722
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
723
+ metricNativeUnit: "ms",
724
+ sampleValues: [5000],
725
+ criteriaFilter,
726
+ });
727
+
728
+ const unit: string | undefined =
729
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
730
+
731
+ expect(unit).toBe("MB");
732
+ });
733
+
734
+ /*
735
+ * thresholdUnit "sec" wins even when it is convertible from native "ms".
736
+ */
737
+ test("thresholdUnit 'sec' over convertible native 'ms' => 'sec'", () => {
738
+ const criteriaFilter: CriteriaFilter = metricFilter({
739
+ value: "2",
740
+ thresholdUnit: "sec",
741
+ });
742
+
743
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
744
+ metricNativeUnit: "ms",
745
+ sampleValues: [2500],
746
+ criteriaFilter,
747
+ });
748
+
749
+ const unit: string | undefined =
750
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
751
+
752
+ expect(unit).toBe("sec");
753
+ });
754
+
755
+ /*
756
+ * Dimensionless native "1" resolves to undefined (suppressed), so callers can
757
+ * safely append it as a suffix without producing "0.06 1".
758
+ */
759
+ test("dimensionless native '1' => undefined", () => {
760
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "0.9" });
761
+
762
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
763
+ metricNativeUnit: "1",
764
+ sampleValues: [0.06],
765
+ criteriaFilter,
766
+ });
767
+
768
+ const unit: string | undefined =
769
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
770
+
771
+ expect(unit).toBeUndefined();
772
+ });
773
+
774
+ /*
775
+ * Non-metric criteria (checkOn !== MetricValue) short-circuit to undefined.
776
+ */
777
+ test("non-metric criteria (ResponseTime) => undefined", () => {
778
+ const criteriaFilter: CriteriaFilter = {
779
+ checkOn: CheckOn.ResponseTime,
780
+ filterType: FilterType.GreaterThan,
781
+ value: "500",
782
+ };
783
+
784
+ const inputs: ReturnType<typeof buildInputs> = buildInputs({
785
+ metricNativeUnit: "sec",
786
+ sampleValues: [0.5],
787
+ criteriaFilter,
788
+ });
789
+
790
+ const unit: string | undefined =
791
+ MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit(inputs);
792
+
793
+ expect(unit).toBeUndefined();
794
+ });
795
+
796
+ /*
797
+ * A dataToProcess with no metricResult (empty object cast) must resolve to
798
+ * undefined without throwing.
799
+ */
800
+ test("dataToProcess with no metricResult => undefined (no throw)", () => {
801
+ const criteriaFilter: CriteriaFilter = metricFilter({ value: "5" });
802
+
803
+ const base: ReturnType<typeof buildInputs> = buildInputs({
804
+ metricNativeUnit: "sec",
805
+ sampleValues: [0.5],
806
+ criteriaFilter,
807
+ });
808
+
809
+ let unit: string | undefined;
810
+ const call: () => void = () => {
811
+ unit = MonitorCriteriaObservationBuilder.getMetricValueDisplayUnit({
812
+ criteriaFilter,
813
+ monitorStep: base.monitorStep,
814
+ dataToProcess: {} as unknown as MetricMonitorResponse,
815
+ });
816
+ };
817
+
818
+ expect(call).not.toThrow();
819
+ expect(unit).toBeUndefined();
820
+ });
821
+ });