@oneuptime/common 14.0.5 → 14.0.6

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 (66) hide show
  1. package/Server/Services/MetricService.ts +1 -1
  2. package/Server/Types/Database/QueryHelper.ts +54 -1
  3. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  4. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  5. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  6. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  7. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  8. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  9. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  10. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  11. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  12. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  13. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  14. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  15. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  16. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  17. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  18. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  19. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  20. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  21. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  22. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  23. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  24. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  25. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  26. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  27. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  28. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  29. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  30. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  31. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  32. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  33. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  34. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  35. package/Types/BaseDatabase/AggregationType.ts +7 -0
  36. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  37. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  38. package/Types/Rum/WebVitals.ts +13 -0
  39. package/UI/Components/Input/Input.tsx +11 -0
  40. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  41. package/build/dist/Server/Services/MetricService.js +1 -1
  42. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  43. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  44. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  45. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  46. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  47. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  48. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  49. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  50. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  51. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  52. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  53. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  54. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  55. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  56. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  57. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  58. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  59. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  60. package/build/dist/Types/Rum/WebVitals.js +12 -0
  61. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  62. package/build/dist/UI/Components/Input/Input.js +1 -1
  63. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  64. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  65. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  66. package/package.json +1 -1
@@ -72,6 +72,7 @@ import {
72
72
  fetchLogAndExceptionSignals,
73
73
  fetchSpanMetrics,
74
74
  fetchSpanNameStats,
75
+ fetchWebVitalByRoute,
75
76
  fetchWebVitals,
76
77
  formatBytes as reexportedFormatBytes,
77
78
  formatCompact as reexportedFormatCompact,
@@ -84,7 +85,9 @@ import {
84
85
  spanNameStatsFromTableRows,
85
86
  summarizeLogAndExceptionBuckets,
86
87
  TimePoint,
88
+ WEB_VITAL_ROUTE_LIMIT,
87
89
  WebVital,
90
+ WebVitalByRoute,
88
91
  } from "../../../../App/FeatureSet/Dashboard/src/Components/TelemetryResource/telemetryMetrics";
89
92
  import {
90
93
  formatBytes,
@@ -98,11 +101,13 @@ import URL from "../../../Types/API/URL";
98
101
  import { JSONObject } from "../../../Types/JSON";
99
102
  import ObjectID from "../../../Types/ObjectID";
100
103
  import AggregationType from "../../../Types/BaseDatabase/AggregationType";
104
+ import AggregationInterval from "../../../Types/BaseDatabase/AggregationInterval";
101
105
  import InBetween from "../../../Types/BaseDatabase/InBetween";
102
106
  import { SpanStatus } from "../../../Models/AnalyticsModels/Span";
103
107
  import {
104
108
  WebVitalDefinition,
105
109
  WebVitalDefinitions,
110
+ WebVitalRouteAttributeKeys,
106
111
  } from "../../../Types/Rum/WebVitals";
107
112
  import { APP_API_URL } from "../../../UI/Config";
108
113
 
@@ -126,10 +131,13 @@ interface AggregateRequest {
126
131
  aggregateBy: {
127
132
  query: Record<string, unknown>;
128
133
  aggregationType: AggregationType;
134
+ aggregationInterval?: AggregationInterval;
129
135
  aggregateColumnName: string;
130
136
  aggregationTimestampColumnName: string;
131
137
  startTimestamp: Date;
132
138
  endTimestamp: Date;
139
+ groupByAttributeKeys?: Array<string>;
140
+ topK?: { count: number; rankBy: string };
133
141
  };
134
142
  }
135
143
 
@@ -1072,6 +1080,7 @@ describe("fetchWebVitals", () => {
1072
1080
  value: null,
1073
1081
  unit: d.unit,
1074
1082
  thresholds: d.thresholds,
1083
+ metricName: null,
1075
1084
  });
1076
1085
  expect(v.description.length).toBeGreaterThan(20);
1077
1086
  });
@@ -1086,12 +1095,7 @@ describe("fetchWebVitals", () => {
1086
1095
  ];
1087
1096
 
1088
1097
  if (name === second) {
1089
- return {
1090
- data: [
1091
- { time: T0, value: 1000 },
1092
- { time: T1, value: 3000 },
1093
- ],
1094
- };
1098
+ return { data: [{ time: T0, value: 2000 }] };
1095
1099
  }
1096
1100
 
1097
1101
  return { data: [] };
@@ -1107,8 +1111,9 @@ describe("fetchWebVitals", () => {
1107
1111
  return v.key === "lcp";
1108
1112
  })!;
1109
1113
 
1110
- // The average of the per-interval averages.
1114
+ // The one whole-range mean, and the name it was found under.
1111
1115
  expect(lcp.value).toBe(2000);
1116
+ expect(lcp.metricName).toBe(second);
1112
1117
  expect(lcp.description).toBe(LCP.description);
1113
1118
 
1114
1119
  const asked: Array<string> = metricNames();
@@ -1119,7 +1124,13 @@ describe("fetchWebVitals", () => {
1119
1124
  }
1120
1125
  });
1121
1126
 
1122
- test("averages, scoped to the entity and window", async () => {
1127
+ /*
1128
+ * A mean of the whole range at once - not a mean of per-interval means,
1129
+ * which let a quiet interval count as much as a busy one. A mean rather
1130
+ * than a p75 because a histogram's percentile comes from its buckets,
1131
+ * and the OpenTelemetry defaults put every CLS value in (0, 5].
1132
+ */
1133
+ test("asks for one mean over the whole window, scoped to the entity", async () => {
1123
1134
  aggregateMock.mockResolvedValue({ data: [] });
1124
1135
 
1125
1136
  await fetchWebVitals({
@@ -1130,6 +1141,7 @@ describe("fetchWebVitals", () => {
1130
1141
 
1131
1142
  for (const r of aggregateRequests()) {
1132
1143
  expect(r.aggregateBy.aggregationType).toBe(AggregationType.Avg);
1144
+ expect(r.aggregateBy.aggregationInterval).toBe(AggregationInterval.Total);
1133
1145
  expect(String(r.aggregateBy.query["primaryEntityId"])).toBe(ENTITY_ID);
1134
1146
  expect(r.aggregateBy.startTimestamp).toBe(START);
1135
1147
  expect(r.aggregateBy.endTimestamp).toBe(END);
@@ -1137,6 +1149,154 @@ describe("fetchWebVitals", () => {
1137
1149
  });
1138
1150
  });
1139
1151
 
1152
+ describe("fetchWebVitalByRoute", () => {
1153
+ const INP_NAME: string = "web_vital.inp";
1154
+
1155
+ function ask(): Promise<WebVitalByRoute> {
1156
+ return fetchWebVitalByRoute({
1157
+ primaryEntityId: new ObjectID(ENTITY_ID),
1158
+ metricName: INP_NAME,
1159
+ start: START,
1160
+ end: END,
1161
+ });
1162
+ }
1163
+
1164
+ function routeRow(
1165
+ key: string,
1166
+ route: string | undefined,
1167
+ value: number,
1168
+ ): Record<string, unknown> {
1169
+ return {
1170
+ time: T0,
1171
+ value: value,
1172
+ attributes: route === undefined ? {} : { [key]: route },
1173
+ };
1174
+ }
1175
+
1176
+ test("asks for the mean of this metric per route over the whole window, top routes only", async () => {
1177
+ aggregateMock.mockResolvedValue({ data: [] });
1178
+
1179
+ await ask();
1180
+
1181
+ const [first] = aggregateRequests();
1182
+
1183
+ expect(first?.aggregateBy.query["name"]).toBe(INP_NAME);
1184
+ expect(String(first?.aggregateBy.query["primaryEntityId"])).toBe(ENTITY_ID);
1185
+ expect(String(first?.aggregateBy.query["projectId"])).toBe(PROJECT_ID);
1186
+ expect(first?.aggregateBy.aggregationType).toBe(AggregationType.Avg);
1187
+ expect(first?.aggregateBy.aggregationInterval).toBe(
1188
+ AggregationInterval.Total,
1189
+ );
1190
+ expect(first?.aggregateBy.groupByAttributeKeys).toEqual(["app.route"]);
1191
+ // One spare slot for the pooled rows that carry no route at all.
1192
+ expect(first?.aggregateBy.topK).toEqual({
1193
+ count: WEB_VITAL_ROUTE_LIMIT + 1,
1194
+ rankBy: "max",
1195
+ });
1196
+ });
1197
+
1198
+ test("returns the routes slowest first, leaving out rows without a route", async () => {
1199
+ aggregateMock.mockResolvedValue({
1200
+ data: [
1201
+ routeRow("app.route", "/cart", 240),
1202
+ routeRow("app.route", undefined, 900),
1203
+ routeRow("app.route", " ", 800),
1204
+ routeRow("app.route", "/products/:id", 620),
1205
+ ],
1206
+ totalGroups: 5,
1207
+ });
1208
+
1209
+ const result: WebVitalByRoute = await ask();
1210
+
1211
+ expect(result).toEqual({
1212
+ routeAttribute: "app.route",
1213
+ routes: [
1214
+ { route: "/products/:id", value: 620 },
1215
+ { route: "/cart", value: 240 },
1216
+ ],
1217
+ // Five groups, one of them the blank no-route group.
1218
+ totalRoutes: 4,
1219
+ failed: false,
1220
+ });
1221
+ });
1222
+
1223
+ test("falls back to url.template, OpenTelemetry's name for the route", async () => {
1224
+ aggregateMock.mockImplementation(async (...args: Array<unknown>) => {
1225
+ const keys: Array<string> =
1226
+ (args[0] as AggregateRequest).aggregateBy.groupByAttributeKeys || [];
1227
+
1228
+ if (keys[0] === "url.template") {
1229
+ return { data: [routeRow("url.template", "/orders/{id}", 310)] };
1230
+ }
1231
+
1232
+ return { data: [routeRow("app.route", undefined, 310)] };
1233
+ });
1234
+
1235
+ const result: WebVitalByRoute = await ask();
1236
+
1237
+ expect(
1238
+ aggregateRequests().map((r: AggregateRequest): unknown => {
1239
+ return r.aggregateBy.groupByAttributeKeys;
1240
+ }),
1241
+ ).toEqual(
1242
+ WebVitalRouteAttributeKeys.map((key: string): Array<string> => {
1243
+ return [key];
1244
+ }),
1245
+ );
1246
+ expect(result.routeAttribute).toBe("url.template");
1247
+ expect(result.routes).toEqual([{ route: "/orders/{id}", value: 310 }]);
1248
+ expect(result.totalRoutes).toBeNull();
1249
+ });
1250
+
1251
+ test("keeps at most the route limit", async () => {
1252
+ aggregateMock.mockResolvedValue({
1253
+ data: Array.from(
1254
+ { length: WEB_VITAL_ROUTE_LIMIT + 1 },
1255
+ (_: unknown, i: number) => {
1256
+ return routeRow("app.route", `/r${i}`, 100 + i);
1257
+ },
1258
+ ),
1259
+ });
1260
+
1261
+ const result: WebVitalByRoute = await ask();
1262
+
1263
+ expect(result.routes).toHaveLength(WEB_VITAL_ROUTE_LIMIT);
1264
+ expect(result.routes[0]?.value).toBe(100 + WEB_VITAL_ROUTE_LIMIT);
1265
+ });
1266
+
1267
+ test("no route under any attribute is an empty answer, not a failure", async () => {
1268
+ aggregateMock.mockResolvedValue({ data: [] });
1269
+
1270
+ expect(await ask()).toEqual({
1271
+ routeAttribute: null,
1272
+ routes: [],
1273
+ totalRoutes: null,
1274
+ failed: false,
1275
+ });
1276
+ expect(aggregateMock).toHaveBeenCalledTimes(
1277
+ WebVitalRouteAttributeKeys.length,
1278
+ );
1279
+ });
1280
+
1281
+ test("a failed lookup is marked failed rather than read as no routes", async () => {
1282
+ aggregateMock.mockRejectedValue(new Error("403"));
1283
+
1284
+ const result: WebVitalByRoute = await ask();
1285
+
1286
+ expect(result.failed).toBe(true);
1287
+ expect(result.routes).toEqual([]);
1288
+ });
1289
+
1290
+ test("no current project: returns empty without asking", async () => {
1291
+ projectIdMock.mockReturnValue(null);
1292
+
1293
+ const result: WebVitalByRoute = await ask();
1294
+
1295
+ expect(result.failed).toBe(false);
1296
+ expect(aggregateMock).not.toHaveBeenCalled();
1297
+ });
1298
+ });
1299
+
1140
1300
  describe("telemetryFormat", () => {
1141
1301
  test.each([
1142
1302
  [null, "—"],
@@ -203,6 +203,18 @@ describe("MetricService aggregate statement generation", () => {
203
203
  expect(query).not.toContain("__attr_grp_0");
204
204
  expect(query).toContain("GROUP BY time");
205
205
  });
206
+
207
+ it("computes P75 - the Core Web Vitals level - over the histogram buckets", () => {
208
+ const query: string = getQuery(
209
+ buildAggregateBy({
210
+ aggregationType: AggregationType.P75,
211
+ groupByAttributeKeys: ["app.route"],
212
+ }),
213
+ );
214
+
215
+ expect(query).toContain("quantileExactWeighted(0.75)");
216
+ expect(query).toContain("GROUP BY time, __attr_grp_0");
217
+ });
206
218
  });
207
219
 
208
220
  /*
@@ -0,0 +1,325 @@
1
+ import LogSavedViewService from "../../../Server/Services/LogSavedViewService";
2
+ import MetricSavedViewService from "../../../Server/Services/MetricSavedViewService";
3
+ import TraceSavedViewService from "../../../Server/Services/TraceSavedViewService";
4
+ import CreateBy from "../../../Server/Types/Database/CreateBy";
5
+ import UpdateBy from "../../../Server/Types/Database/UpdateBy";
6
+ import { OnCreate, OnUpdate } from "../../../Server/Types/Database/Hooks";
7
+ import LogSavedView from "../../../Models/DatabaseModels/LogSavedView";
8
+ import MetricSavedView from "../../../Models/DatabaseModels/MetricSavedView";
9
+ import TraceSavedView from "../../../Models/DatabaseModels/TraceSavedView";
10
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
11
+ import ObjectID from "../../../Types/ObjectID";
12
+ import { afterEach, describe, expect, test } from "@jest/globals";
13
+
14
+ /*
15
+ * Telemetry saved views (logs, traces and metrics) each promise a project
16
+ * exactly one default: the first view a project saves becomes its default, and
17
+ * marking another one default clears whichever view held it. Three services
18
+ * carry the same hooks, so the suite runs against all three -- a change made
19
+ * to one of them and not the others shows up here rather than as a project
20
+ * whose telemetry page opens two default views, or none.
21
+ */
22
+
23
+ type SavedViewModel = LogSavedView | MetricSavedView | TraceSavedView;
24
+
25
+ /*
26
+ * The two hooks under test, reached past `protected`. Typed over the union of
27
+ * the three models so one set of cases can drive all three services.
28
+ */
29
+ type SavedViewService = {
30
+ onBeforeCreate: (
31
+ createBy: CreateBy<SavedViewModel>,
32
+ ) => Promise<OnCreate<SavedViewModel>>;
33
+ onBeforeUpdate: (
34
+ updateBy: UpdateBy<SavedViewModel>,
35
+ ) => Promise<OnUpdate<SavedViewModel>>;
36
+ };
37
+
38
+ type ServiceCase = {
39
+ label: string;
40
+ service: SavedViewService;
41
+ buildModel: () => SavedViewModel;
42
+ };
43
+
44
+ const PROJECT_ID: ObjectID = new ObjectID(
45
+ "11111111-1111-4111-8111-111111111111",
46
+ );
47
+ const VIEW_ID: ObjectID = new ObjectID("22222222-2222-4222-8222-222222222222");
48
+
49
+ const cases: Array<ServiceCase> = [
50
+ {
51
+ label: "LogSavedViewService",
52
+ service: LogSavedViewService as unknown as SavedViewService,
53
+ buildModel: (): SavedViewModel => {
54
+ return new LogSavedView();
55
+ },
56
+ },
57
+ {
58
+ label: "TraceSavedViewService",
59
+ service: TraceSavedViewService as unknown as SavedViewService,
60
+ buildModel: (): SavedViewModel => {
61
+ return new TraceSavedView();
62
+ },
63
+ },
64
+ {
65
+ label: "MetricSavedViewService",
66
+ service: MetricSavedViewService as unknown as SavedViewService,
67
+ buildModel: (): SavedViewModel => {
68
+ return new MetricSavedView();
69
+ },
70
+ },
71
+ ];
72
+
73
+ afterEach(() => {
74
+ jest.restoreAllMocks();
75
+ });
76
+
77
+ describe.each(cases)(
78
+ "$label default-view hooks",
79
+ ({ service, buildModel }: ServiceCase) => {
80
+ function mockFindOneBy(existing: SavedViewModel | null): jest.SpyInstance {
81
+ return jest
82
+ .spyOn(service as never, "findOneBy")
83
+ .mockResolvedValue(existing as never);
84
+ }
85
+
86
+ function mockFindBy(items: Array<SavedViewModel>): jest.SpyInstance {
87
+ return jest
88
+ .spyOn(service as never, "findBy")
89
+ .mockResolvedValue(items as never);
90
+ }
91
+
92
+ function mockUpdateBy(): jest.SpyInstance {
93
+ return jest
94
+ .spyOn(service as never, "updateBy")
95
+ .mockResolvedValue(undefined as never);
96
+ }
97
+
98
+ describe("onBeforeCreate", () => {
99
+ test("the first view a project saves becomes its default", async () => {
100
+ mockFindOneBy(null);
101
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
102
+
103
+ const view: SavedViewModel = buildModel();
104
+ view.projectId = PROJECT_ID;
105
+
106
+ const result: OnCreate<SavedViewModel> = await service.onBeforeCreate({
107
+ data: view,
108
+ } as CreateBy<SavedViewModel>);
109
+
110
+ expect(result.createBy.data.isDefault).toBe(true);
111
+ /*
112
+ * Nothing else held the default, but the sweep still runs and is a no-op
113
+ * against an empty project.
114
+ */
115
+ expect(updateSpy).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ test("a later view is not made default while one already exists", async () => {
119
+ const existing: SavedViewModel = buildModel();
120
+ existing._id = VIEW_ID.toString();
121
+ mockFindOneBy(existing);
122
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
123
+
124
+ const view: SavedViewModel = buildModel();
125
+ view.projectId = PROJECT_ID;
126
+
127
+ const result: OnCreate<SavedViewModel> = await service.onBeforeCreate({
128
+ data: view,
129
+ } as CreateBy<SavedViewModel>);
130
+
131
+ expect(result.createBy.data.isDefault).toBe(false);
132
+ // Not default, so nothing is unset.
133
+ expect(updateSpy).not.toHaveBeenCalled();
134
+ });
135
+
136
+ test("an explicit isDefault=true clears whichever view held it", async () => {
137
+ const findOneSpy: jest.SpyInstance = mockFindOneBy(null);
138
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
139
+
140
+ const view: SavedViewModel = buildModel();
141
+ view.projectId = PROJECT_ID;
142
+ view.isDefault = true;
143
+
144
+ await service.onBeforeCreate({
145
+ data: view,
146
+ } as CreateBy<SavedViewModel>);
147
+
148
+ // The caller said so, so the service does not go looking.
149
+ expect(findOneSpy).not.toHaveBeenCalled();
150
+ expect(updateSpy).toHaveBeenCalledTimes(1);
151
+ const updateArgs: Record<string, unknown> = updateSpy.mock
152
+ .calls[0]?.[0] as Record<string, unknown>;
153
+ const query: Record<string, unknown> = updateArgs["query"] as Record<
154
+ string,
155
+ unknown
156
+ >;
157
+ expect(query["projectId"]).toBe(PROJECT_ID);
158
+ expect(query["isDefault"]).toBe(true);
159
+ expect(updateArgs["data"]).toEqual({ isDefault: false });
160
+ /*
161
+ * The sweep runs as root: a member may only see their own views, and
162
+ * leaving the others default would break the one-default promise.
163
+ */
164
+ expect(updateArgs["props"]).toEqual({ isRoot: true });
165
+ });
166
+
167
+ test("an explicit isDefault=false is kept, and unsets nothing", async () => {
168
+ const findOneSpy: jest.SpyInstance = mockFindOneBy(null);
169
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
170
+
171
+ const view: SavedViewModel = buildModel();
172
+ view.projectId = PROJECT_ID;
173
+ view.isDefault = false;
174
+
175
+ const result: OnCreate<SavedViewModel> = await service.onBeforeCreate({
176
+ data: view,
177
+ } as CreateBy<SavedViewModel>);
178
+
179
+ expect(result.createBy.data.isDefault).toBe(false);
180
+ expect(findOneSpy).not.toHaveBeenCalled();
181
+ expect(updateSpy).not.toHaveBeenCalled();
182
+ });
183
+
184
+ test("a view with no project is left alone", async () => {
185
+ const findOneSpy: jest.SpyInstance = mockFindOneBy(null);
186
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
187
+
188
+ const view: SavedViewModel = buildModel();
189
+
190
+ const result: OnCreate<SavedViewModel> = await service.onBeforeCreate({
191
+ data: view,
192
+ } as CreateBy<SavedViewModel>);
193
+
194
+ expect(result.createBy.data.isDefault).toBeUndefined();
195
+ expect(findOneSpy).not.toHaveBeenCalled();
196
+ expect(updateSpy).not.toHaveBeenCalled();
197
+ });
198
+
199
+ test("the lookup for an existing default is scoped to the same project", async () => {
200
+ const findOneSpy: jest.SpyInstance = mockFindOneBy(null);
201
+ mockUpdateBy();
202
+
203
+ const view: SavedViewModel = buildModel();
204
+ view.projectId = PROJECT_ID;
205
+
206
+ await service.onBeforeCreate({
207
+ data: view,
208
+ } as CreateBy<SavedViewModel>);
209
+
210
+ const findArgs: Record<string, unknown> = findOneSpy.mock
211
+ .calls[0]?.[0] as Record<string, unknown>;
212
+ expect(findArgs["query"]).toEqual({
213
+ projectId: PROJECT_ID,
214
+ isDefault: true,
215
+ });
216
+ });
217
+ });
218
+
219
+ describe("onBeforeUpdate", () => {
220
+ test("promoting a view to default demotes the others, but not itself", async () => {
221
+ const item: SavedViewModel = buildModel();
222
+ item._id = VIEW_ID.toString();
223
+ item.projectId = PROJECT_ID;
224
+ mockFindBy([item]);
225
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
226
+
227
+ await service.onBeforeUpdate({
228
+ query: { _id: VIEW_ID.toString() },
229
+ data: { isDefault: true },
230
+ } as unknown as UpdateBy<SavedViewModel>);
231
+
232
+ expect(updateSpy).toHaveBeenCalledTimes(1);
233
+ const query: Record<string, unknown> = (
234
+ updateSpy.mock.calls[0]?.[0] as Record<string, unknown>
235
+ )["query"] as Record<string, unknown>;
236
+ expect(query["projectId"]).toBe(PROJECT_ID);
237
+ expect(query["isDefault"]).toBe(true);
238
+ /*
239
+ * The view being promoted is excluded, so the same statement cannot
240
+ * demote it again.
241
+ */
242
+ expect(query["_id"]).toBeDefined();
243
+ });
244
+
245
+ test("an update that does not set isDefault=true touches nothing", async () => {
246
+ const findSpy: jest.SpyInstance = mockFindBy([]);
247
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
248
+
249
+ for (const data of [{ name: "Renamed" }, { isDefault: false }] as Array<
250
+ Partial<SavedViewModel>
251
+ >) {
252
+ await service.onBeforeUpdate({
253
+ query: { _id: VIEW_ID.toString() },
254
+ data: data,
255
+ } as unknown as UpdateBy<SavedViewModel>);
256
+ }
257
+
258
+ expect(findSpy).not.toHaveBeenCalled();
259
+ expect(updateSpy).not.toHaveBeenCalled();
260
+ });
261
+
262
+ test("a bulk promotion demotes the others in each affected project", async () => {
263
+ const otherProjectId: ObjectID = new ObjectID(
264
+ "33333333-3333-4333-8333-333333333333",
265
+ );
266
+ const first: SavedViewModel = buildModel();
267
+ first._id = VIEW_ID.toString();
268
+ first.projectId = PROJECT_ID;
269
+ const second: SavedViewModel = buildModel();
270
+ second._id = "44444444-4444-4444-8444-444444444444";
271
+ second.projectId = otherProjectId;
272
+ mockFindBy([first, second]);
273
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
274
+
275
+ await service.onBeforeUpdate({
276
+ query: { isDefault: false },
277
+ data: { isDefault: true },
278
+ } as unknown as UpdateBy<SavedViewModel>);
279
+
280
+ expect(updateSpy).toHaveBeenCalledTimes(2);
281
+ const projectIds: Array<unknown> = updateSpy.mock.calls.map(
282
+ (call: Array<unknown>) => {
283
+ return (
284
+ (call[0] as Record<string, unknown>)["query"] as Record<
285
+ string,
286
+ unknown
287
+ >
288
+ )["projectId"];
289
+ },
290
+ );
291
+ expect(projectIds).toEqual([PROJECT_ID, otherProjectId]);
292
+ });
293
+
294
+ test("a matched view with no project is skipped rather than swept globally", async () => {
295
+ const orphan: SavedViewModel = buildModel();
296
+ orphan._id = VIEW_ID.toString();
297
+ mockFindBy([orphan]);
298
+ const updateSpy: jest.SpyInstance = mockUpdateBy();
299
+
300
+ await service.onBeforeUpdate({
301
+ query: { _id: VIEW_ID.toString() },
302
+ data: { isDefault: true },
303
+ } as unknown as UpdateBy<SavedViewModel>);
304
+
305
+ expect(updateSpy).not.toHaveBeenCalled();
306
+ });
307
+ });
308
+ },
309
+ );
310
+
311
+ describe("the three saved-view services", () => {
312
+ test("are distinct services over distinct models", () => {
313
+ const models: Array<string> = cases.map(({ buildModel }: ServiceCase) => {
314
+ return (buildModel() as BaseModel).tableName as string;
315
+ });
316
+ expect(new Set(models).size).toBe(3);
317
+ expect(
318
+ new Set([
319
+ LogSavedViewService,
320
+ TraceSavedViewService,
321
+ MetricSavedViewService,
322
+ ]).size,
323
+ ).toBe(3);
324
+ });
325
+ });