@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.
- package/Server/Services/MetricService.ts +1 -1
- package/Server/Types/Database/QueryHelper.ts +54 -1
- package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
- package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
- package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
- package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
- package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
- package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
- package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
- package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
- package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
- package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
- package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
- package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
- package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
- package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
- package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
- package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
- package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
- package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
- package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
- package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
- package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
- package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
- package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
- package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
- package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
- package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
- package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
- package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
- package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
- package/Types/BaseDatabase/AggregationType.ts +7 -0
- package/Types/Dashboard/DashboardTemplates.ts +20 -7
- package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
- package/Types/Rum/WebVitals.ts +13 -0
- package/UI/Components/Input/Input.tsx +11 -0
- package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
- package/build/dist/Server/Services/MetricService.js +1 -1
- package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
- package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
- package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
- package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
- package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
- package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
- package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
- package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
- package/build/dist/Types/Rum/WebVitals.js +12 -0
- package/build/dist/Types/Rum/WebVitals.js.map +1 -1
- package/build/dist/UI/Components/Input/Input.js +1 -1
- package/build/dist/UI/Components/Input/Input.js.map +1 -1
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
- package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
- 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
|
|
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
|
-
|
|
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
|
+
});
|