@oneuptime/common 14.0.6 → 14.0.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 (127) hide show
  1. package/Models/DatabaseModels/InventoryItem.ts +23 -12
  2. package/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.ts +217 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  4. package/Server/Services/AlertService.ts +32 -36
  5. package/Server/Services/IncidentAlertService.ts +609 -2
  6. package/Server/Services/IncidentService.ts +134 -44
  7. package/Server/Services/InventoryItemService.ts +38 -0
  8. package/Server/Services/IoTDeviceCredentialService.ts +13 -4
  9. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  10. package/Server/Services/UserNotificationRuleService.ts +55 -53
  11. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  12. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  13. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  14. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  15. package/Server/Utils/Response.ts +31 -0
  16. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  17. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  18. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  19. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  20. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  21. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  22. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  23. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  24. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  25. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  26. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  27. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  28. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  29. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  30. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  31. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  32. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  33. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  34. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  35. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  36. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  37. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  38. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  39. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  40. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  41. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  42. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  43. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  44. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  45. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  46. package/Tests/Server/Services/DomainService.test.ts +525 -0
  47. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  48. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  49. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  50. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  51. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  52. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  53. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  54. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  55. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  56. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  57. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  58. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  59. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  60. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  61. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  62. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  63. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  64. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  65. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  66. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  67. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  68. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  69. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  70. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  71. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  72. package/Types/Incident/IncidentAlertLink.ts +19 -0
  73. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  74. package/Types/Topology/TopologyApi.ts +428 -0
  75. package/Types/Topology/TopologyTypeRules.ts +191 -0
  76. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  77. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  78. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  79. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  80. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  81. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  82. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  83. package/build/dist/Server/Services/AlertService.js +21 -33
  84. package/build/dist/Server/Services/AlertService.js.map +1 -1
  85. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  86. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  87. package/build/dist/Server/Services/IncidentService.js +96 -42
  88. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  89. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  90. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  91. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  92. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  93. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  94. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  95. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  96. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  97. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  98. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  99. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  100. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  101. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  102. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  103. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  104. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  105. package/build/dist/Server/Utils/Response.js +30 -0
  106. package/build/dist/Server/Utils/Response.js.map +1 -1
  107. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  108. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  109. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  110. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  111. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  112. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  113. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  114. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  115. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  116. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  117. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  118. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  119. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  120. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  121. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  122. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  123. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  124. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  125. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  126. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  127. package/package.json +1 -1
@@ -0,0 +1,886 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ act,
12
+ cleanup,
13
+ fireEvent,
14
+ render,
15
+ screen,
16
+ within,
17
+ } from "@testing-library/react";
18
+ import React, { ReactElement } from "react";
19
+ import getJestMockFunction, { MockFunction } from "../../MockType";
20
+
21
+ const getListMock: MockFunction = getJestMockFunction();
22
+ const modelFormModalMock: MockFunction = getJestMockFunction();
23
+
24
+ jest.mock("react-i18next", () => {
25
+ return {
26
+ useTranslation: () => {
27
+ return {
28
+ t: (key: string, options?: { defaultValue?: string }): string => {
29
+ return options?.defaultValue ?? key;
30
+ },
31
+ };
32
+ },
33
+ };
34
+ });
35
+
36
+ /*
37
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the compiled
38
+ * requires, so the mock consts above are still unassigned when the factory
39
+ * runs. Dereferencing them lazily, at call time, is what makes this work.
40
+ */
41
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
42
+ return {
43
+ __esModule: true,
44
+ default: {
45
+ getList: (...args: Array<unknown>) => {
46
+ return getListMock(...args);
47
+ },
48
+ },
49
+ };
50
+ });
51
+
52
+ /*
53
+ * The state-change modal is a full model form with its own API traffic. The
54
+ * stub records its props and renders the title, so a test can tell whether a
55
+ * click opened it.
56
+ */
57
+ jest.mock("../../../UI/Components/ModelFormModal/ModelFormModal", () => {
58
+ return {
59
+ __esModule: true,
60
+ default: (props: {
61
+ title: string;
62
+ submitButtonText: string;
63
+ }): ReactElement => {
64
+ modelFormModalMock(props);
65
+ return React.createElement(
66
+ "div",
67
+ { "data-testid": "state-change-modal" },
68
+ `${props.title} / ${props.submitButtonText}`,
69
+ );
70
+ },
71
+ };
72
+ });
73
+
74
+ import ChangeAlertState from "../../../../App/FeatureSet/Dashboard/src/Components/Alert/ChangeState";
75
+ import ChangeIncidentState from "../../../../App/FeatureSet/Dashboard/src/Components/Incident/ChangeState";
76
+ import AlertNoteTemplate from "../../../Models/DatabaseModels/AlertNoteTemplate";
77
+ import AlertState from "../../../Models/DatabaseModels/AlertState";
78
+ import AlertStateTimeline from "../../../Models/DatabaseModels/AlertStateTimeline";
79
+ import IncidentNoteTemplate from "../../../Models/DatabaseModels/IncidentNoteTemplate";
80
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
81
+ import IncidentStateTimeline from "../../../Models/DatabaseModels/IncidentStateTimeline";
82
+ import Project from "../../../Models/DatabaseModels/Project";
83
+ import Route from "../../../Types/API/Route";
84
+ import Color from "../../../Types/Color";
85
+ import ObjectID from "../../../Types/ObjectID";
86
+ import Permission from "../../../Types/Permission";
87
+ import Navigation from "../../../UI/Utils/Navigation";
88
+ import PermissionUtil from "../../../UI/Utils/Permission";
89
+ import PermissionGate from "../../../UI/Utils/PermissionGate";
90
+ import ProjectUtil from "../../../UI/Utils/Project";
91
+ import UserUtil from "../../../UI/Utils/User";
92
+
93
+ /*
94
+ * "Declare Incident" in the alert header (the real ChangeAlertState, against
95
+ * a fake API). It sits in the "Event actions" row after Acknowledge and
96
+ * Resolve, in the neutral look so the state action stays the one primary
97
+ * button, and it stays there once the alert is resolved - declaring after
98
+ * the fact is allowed everywhere else too. It is gated on permissions alone
99
+ * (Incident create, then IncidentAlert create), so it adds no request to the
100
+ * header's three reads. The incident header never gets one.
101
+ */
102
+
103
+ const PROJECT_ID: string = "22222222-2222-4222-8222-222222222222";
104
+ const ALERT_ID: string = "11111111-1111-4111-8111-111111111111";
105
+ const INCIDENT_ID: string = "33333333-3333-4333-8333-333333333333";
106
+ const CREATED_STATE_ID: string = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
107
+ const ACKNOWLEDGED_STATE_ID: string = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2";
108
+ const RESOLVED_STATE_ID: string = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3";
109
+
110
+ const DECLARE_ID: string = "alert-declare-incident-btn";
111
+ const DECLARE_WRAPPER_TEST_ID: string = `${DECLARE_ID}-disabled-wrapper`;
112
+ const ACKNOWLEDGE_ID: string = "alert-acknowledge-btn";
113
+ const RESOLVE_ID: string = "alert-resolve-btn";
114
+ const TITLE: string = "Checkout latency above 2s";
115
+
116
+ const CREATE_PAGE_URL: string = `/dashboard/${PROJECT_ID}/incidents/create?alertIds=${ALERT_ID}`;
117
+
118
+ const INCIDENT_CREATE_REASON: string =
119
+ "You do not have permission to create this Incident. You need one of these permissions: Project Owner, Project Admin, Project Member, Incident Admin, Incident Member, Create Incident.";
120
+
121
+ const INCIDENT_ALERT_CREATE_REASON: string =
122
+ "You do not have permission to create this Incident Alert. You need one of these permissions: Project Owner, Project Admin, Project Member, Incident Admin, Incident Member, Alert Admin, Alert Member, Create Incident Alert.";
123
+
124
+ const START: Date = new Date("2026-09-14T18:00:00.000Z");
125
+
126
+ type StageName = "created" | "acknowledged" | "resolved";
127
+
128
+ const STAGE_STATE_IDS: Record<StageName, Array<string>> = {
129
+ created: [CREATED_STATE_ID],
130
+ acknowledged: [CREATED_STATE_ID, ACKNOWLEDGED_STATE_ID],
131
+ resolved: [CREATED_STATE_ID, ACKNOWLEDGED_STATE_ID, RESOLVED_STATE_ID],
132
+ };
133
+
134
+ interface StateSpec {
135
+ id: string;
136
+ name: string;
137
+ color: string;
138
+ flag: "isCreatedState" | "isAcknowledgedState" | "isResolvedState";
139
+ }
140
+
141
+ const STATE_SPECS: Array<StateSpec> = [
142
+ {
143
+ id: CREATED_STATE_ID,
144
+ name: "Created",
145
+ color: "#ef4444",
146
+ flag: "isCreatedState",
147
+ },
148
+ {
149
+ id: ACKNOWLEDGED_STATE_ID,
150
+ name: "Acknowledged",
151
+ color: "#f59e0b",
152
+ flag: "isAcknowledgedState",
153
+ },
154
+ {
155
+ id: RESOLVED_STATE_ID,
156
+ name: "Resolved",
157
+ color: "#10b981",
158
+ flag: "isResolvedState",
159
+ },
160
+ ];
161
+
162
+ type BuildAlertStatesFunction = () => Array<AlertState>;
163
+
164
+ const buildAlertStates: BuildAlertStatesFunction = (): Array<AlertState> => {
165
+ return STATE_SPECS.map((spec: StateSpec): AlertState => {
166
+ const state: AlertState = new AlertState();
167
+ state.id = new ObjectID(spec.id);
168
+ state.name = spec.name;
169
+ state.color = new Color(spec.color);
170
+ state[spec.flag] = true;
171
+ return state;
172
+ });
173
+ };
174
+
175
+ type BuildAlertTimelinesFunction = (
176
+ stage: StageName,
177
+ ) => Array<AlertStateTimeline>;
178
+
179
+ const buildAlertTimelines: BuildAlertTimelinesFunction = (
180
+ stage: StageName,
181
+ ): Array<AlertStateTimeline> => {
182
+ return STAGE_STATE_IDS[stage].map(
183
+ (stateId: string, index: number): AlertStateTimeline => {
184
+ const timeline: AlertStateTimeline = new AlertStateTimeline();
185
+ timeline.alertStateId = new ObjectID(stateId);
186
+ timeline.startsAt = new Date(START.getTime() + index * 10 * 60 * 1000);
187
+ return timeline;
188
+ },
189
+ );
190
+ };
191
+
192
+ type BuildIncidentStatesFunction = () => Array<IncidentState>;
193
+
194
+ const buildIncidentStates: BuildIncidentStatesFunction =
195
+ (): Array<IncidentState> => {
196
+ return STATE_SPECS.map((spec: StateSpec): IncidentState => {
197
+ const state: IncidentState = new IncidentState();
198
+ state.id = new ObjectID(spec.id);
199
+ state.name = spec.name;
200
+ state.color = new Color(spec.color);
201
+ state[spec.flag] = true;
202
+ return state;
203
+ });
204
+ };
205
+
206
+ type BuildIncidentTimelinesFunction = (
207
+ stage: StageName,
208
+ ) => Array<IncidentStateTimeline>;
209
+
210
+ const buildIncidentTimelines: BuildIncidentTimelinesFunction = (
211
+ stage: StageName,
212
+ ): Array<IncidentStateTimeline> => {
213
+ return STAGE_STATE_IDS[stage].map(
214
+ (stateId: string, index: number): IncidentStateTimeline => {
215
+ const timeline: IncidentStateTimeline = new IncidentStateTimeline();
216
+ timeline.incidentStateId = new ObjectID(stateId);
217
+ timeline.startsAt = new Date(START.getTime() + index * 10 * 60 * 1000);
218
+ return timeline;
219
+ },
220
+ );
221
+ };
222
+
223
+ interface ListResultShape {
224
+ data: Array<unknown>;
225
+ count: number;
226
+ skip: number;
227
+ limit: number;
228
+ }
229
+
230
+ type ListResultFunction = (data: Array<unknown>) => ListResultShape;
231
+
232
+ const listResult: ListResultFunction = (
233
+ data: Array<unknown>,
234
+ ): ListResultShape => {
235
+ return { data: data, count: data.length, skip: 0, limit: 99 };
236
+ };
237
+
238
+ /*
239
+ * Read at call time, so a test can move the alert on (acknowledge it) and
240
+ * the header's timeline re-read sees the new stage.
241
+ */
242
+ let alertStage: StageName = "created";
243
+ let permissionsForTest: Array<Permission> = [];
244
+ let navigateSpy: ReturnType<typeof jest.spyOn>;
245
+
246
+ interface AlertApiOptions {
247
+ statesFail?: boolean | undefined;
248
+ }
249
+
250
+ /*
251
+ * A call that throws from inside rather than a promise that is already
252
+ * rejected: zone.js sees an eagerly rejected promise before the header's
253
+ * catch is attached and prints its stack on every run.
254
+ */
255
+ type FailWithFunction = (message: string) => Promise<never>;
256
+
257
+ const failWith: FailWithFunction = async (message: string): Promise<never> => {
258
+ throw new Error(message);
259
+ };
260
+
261
+ type RespondForAlertFunction = (options?: AlertApiOptions) => void;
262
+
263
+ const respondForAlert: RespondForAlertFunction = (
264
+ options?: AlertApiOptions,
265
+ ): void => {
266
+ getListMock.mockImplementation((...args: Array<unknown>) => {
267
+ const request: { modelType: unknown } = args[0] as { modelType: unknown };
268
+
269
+ if (request.modelType === AlertState) {
270
+ if (options?.statesFail) {
271
+ return failWith("States are unavailable");
272
+ }
273
+
274
+ return Promise.resolve(listResult(buildAlertStates()));
275
+ }
276
+
277
+ if (request.modelType === AlertStateTimeline) {
278
+ return Promise.resolve(listResult(buildAlertTimelines(alertStage)));
279
+ }
280
+
281
+ if (request.modelType === AlertNoteTemplate) {
282
+ return Promise.resolve(listResult([]));
283
+ }
284
+
285
+ return Promise.reject(new Error("Unexpected list request"));
286
+ });
287
+ };
288
+
289
+ type RespondForIncidentFunction = (stage: StageName) => void;
290
+
291
+ const respondForIncident: RespondForIncidentFunction = (
292
+ stage: StageName,
293
+ ): void => {
294
+ getListMock.mockImplementation((...args: Array<unknown>) => {
295
+ const request: { modelType: unknown } = args[0] as { modelType: unknown };
296
+
297
+ if (request.modelType === IncidentState) {
298
+ return Promise.resolve(listResult(buildIncidentStates()));
299
+ }
300
+
301
+ if (request.modelType === IncidentStateTimeline) {
302
+ return Promise.resolve(listResult(buildIncidentTimelines(stage)));
303
+ }
304
+
305
+ if (request.modelType === IncidentNoteTemplate) {
306
+ return Promise.resolve(listResult([]));
307
+ }
308
+
309
+ return Promise.reject(new Error("Unexpected list request"));
310
+ });
311
+ };
312
+
313
+ type RenderAlertHeaderFunction = (onActionComplete?: () => void) => void;
314
+
315
+ const renderAlertHeader: RenderAlertHeaderFunction = (
316
+ onActionComplete?: () => void,
317
+ ): void => {
318
+ render(
319
+ <ChangeAlertState
320
+ alertId={new ObjectID(ALERT_ID)}
321
+ eventNumber="ALR-12"
322
+ title={TITLE}
323
+ eventStartsAt={START}
324
+ onActionComplete={onActionComplete || ((): void => {})}
325
+ />,
326
+ );
327
+ };
328
+
329
+ type RenderIncidentHeaderFunction = () => void;
330
+
331
+ const renderIncidentHeader: RenderIncidentHeaderFunction = (): void => {
332
+ render(
333
+ <ChangeIncidentState
334
+ incidentId={new ObjectID(INCIDENT_ID)}
335
+ eventNumber="INC-42"
336
+ title={TITLE}
337
+ eventStartsAt={START}
338
+ onActionComplete={(): void => {}}
339
+ />,
340
+ );
341
+ };
342
+
343
+ type WaitForHeaderFunction = () => Promise<HTMLElement>;
344
+
345
+ const waitForHeader: WaitForHeaderFunction = async (): Promise<HTMLElement> => {
346
+ return screen.findByRole("heading", { level: 2, name: TITLE });
347
+ };
348
+
349
+ type GetActionGroupFunction = () => HTMLElement;
350
+
351
+ const getActionGroup: GetActionGroupFunction = (): HTMLElement => {
352
+ return screen.getByRole("group", { name: "Event actions" });
353
+ };
354
+
355
+ type ActionIdsFunction = () => Array<string>;
356
+
357
+ // The ids of every button in the header's action row, in DOM order.
358
+ const actionIds: ActionIdsFunction = (): Array<string> => {
359
+ return within(getActionGroup())
360
+ .getAllByRole("button")
361
+ .map((button: HTMLElement): string => {
362
+ return button.id;
363
+ });
364
+ };
365
+
366
+ type GetDeclareButtonFunction = () => HTMLElement;
367
+
368
+ const getDeclareButton: GetDeclareButtonFunction = (): HTMLElement => {
369
+ const button: HTMLElement | null = document.getElementById(DECLARE_ID);
370
+
371
+ if (!button) {
372
+ throw new Error("The Declare Incident button is not rendered.");
373
+ }
374
+
375
+ return button;
376
+ };
377
+
378
+ type RequestedModelsFunction = () => Array<unknown>;
379
+
380
+ const requestedModels: RequestedModelsFunction = (): Array<unknown> => {
381
+ return getListMock.mock.calls.map((call: Array<unknown>): unknown => {
382
+ return (call[0] as { modelType: unknown }).modelType;
383
+ });
384
+ };
385
+
386
+ beforeEach(() => {
387
+ alertStage = "created";
388
+ permissionsForTest = [Permission.ProjectMember];
389
+ PermissionGate.clearPermissionPropsCache();
390
+
391
+ const project: Project = new Project();
392
+ project.id = new ObjectID(PROJECT_ID);
393
+
394
+ jest.spyOn(ProjectUtil, "getCurrentProject").mockReturnValue(project);
395
+ jest
396
+ .spyOn(ProjectUtil, "getCurrentProjectId")
397
+ .mockReturnValue(new ObjectID(PROJECT_ID));
398
+ jest.spyOn(PermissionUtil, "getAllPermissions").mockImplementation(() => {
399
+ return permissionsForTest;
400
+ });
401
+ jest.spyOn(UserUtil, "isMasterAdmin").mockReturnValue(false);
402
+ navigateSpy = jest
403
+ .spyOn(Navigation, "navigate")
404
+ .mockImplementation((): void => {});
405
+ });
406
+
407
+ afterEach(() => {
408
+ cleanup();
409
+ getListMock.mockReset();
410
+ modelFormModalMock.mockReset();
411
+ jest.restoreAllMocks();
412
+ });
413
+
414
+ describe("ChangeAlertState Declare Incident button", () => {
415
+ describe("placement in each alert state", () => {
416
+ test.each([
417
+ ["created", [ACKNOWLEDGE_ID, RESOLVE_ID, DECLARE_ID]],
418
+ ["acknowledged", [RESOLVE_ID, DECLARE_ID]],
419
+ ["resolved", [DECLARE_ID]],
420
+ ])(
421
+ "is offered last in the %s state",
422
+ async (stage: string, expectedIds: Array<string>) => {
423
+ alertStage = stage as StageName;
424
+ respondForAlert();
425
+
426
+ renderAlertHeader();
427
+
428
+ await waitForHeader();
429
+
430
+ expect(actionIds()).toEqual(expectedIds);
431
+
432
+ const declare: HTMLElement = getDeclareButton();
433
+
434
+ expect(declare.tagName).toBe("BUTTON");
435
+ expect(declare).toHaveAttribute("type", "button");
436
+ expect(declare).toHaveTextContent("Declare Incident");
437
+ expect(declare).toHaveAttribute("title", "Declare Incident");
438
+ expect(declare).toBeEnabled();
439
+ expect(declare.querySelector("svg")).not.toBeNull();
440
+ expect(screen.queryByTestId(DECLARE_WRAPPER_TEST_ID)).toBeNull();
441
+ },
442
+ );
443
+
444
+ test.each([
445
+ ["created", ACKNOWLEDGE_ID],
446
+ ["acknowledged", RESOLVE_ID],
447
+ ])(
448
+ "leaves the %s state's own action as the one primary button",
449
+ async (stage: string, primaryId: string) => {
450
+ alertStage = stage as StageName;
451
+ respondForAlert();
452
+
453
+ renderAlertHeader();
454
+
455
+ await waitForHeader();
456
+
457
+ const declare: HTMLElement = getDeclareButton();
458
+ const primaryIds: Array<string> = within(getActionGroup())
459
+ .getAllByRole("button")
460
+ .filter((button: HTMLElement): boolean => {
461
+ return button.classList.contains("bg-indigo-600");
462
+ })
463
+ .map((button: HTMLElement): string => {
464
+ return button.id;
465
+ });
466
+
467
+ expect(primaryIds).toEqual([primaryId]);
468
+ expect(declare).toHaveClass(
469
+ "h-9",
470
+ "border-gray-300",
471
+ "bg-white",
472
+ "text-gray-700",
473
+ );
474
+ expect(declare).not.toHaveClass("bg-indigo-600", "text-white");
475
+ },
476
+ );
477
+
478
+ test("is the only control on a resolved alert, with no More actions menu", async () => {
479
+ alertStage = "resolved";
480
+ respondForAlert();
481
+
482
+ renderAlertHeader();
483
+
484
+ await waitForHeader();
485
+
486
+ expect(within(getActionGroup()).getAllByRole("button")).toEqual([
487
+ getDeclareButton(),
488
+ ]);
489
+ expect(
490
+ screen.queryByRole("button", { name: "More actions" }),
491
+ ).not.toBeInTheDocument();
492
+ expect(document.getElementById(ACKNOWLEDGE_ID)).toBeNull();
493
+ expect(document.getElementById(RESOLVE_ID)).toBeNull();
494
+ });
495
+ });
496
+
497
+ describe("clicking it", () => {
498
+ test.each(["created", "acknowledged", "resolved"])(
499
+ "opens the create-incident page for this alert from the %s state",
500
+ async (stage: string) => {
501
+ alertStage = stage as StageName;
502
+ respondForAlert();
503
+
504
+ renderAlertHeader();
505
+
506
+ await waitForHeader();
507
+
508
+ fireEvent.click(getDeclareButton());
509
+
510
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
511
+
512
+ const call: Array<unknown> = navigateSpy.mock
513
+ .calls[0] as Array<unknown>;
514
+
515
+ expect(call[0]).toBeInstanceOf(Route);
516
+ expect((call[0] as Route).toString()).toBe(CREATE_PAGE_URL);
517
+ },
518
+ );
519
+
520
+ test("does not open the state-change modal or report a state change", async () => {
521
+ respondForAlert();
522
+
523
+ const onActionComplete: MockFunction = getJestMockFunction();
524
+
525
+ renderAlertHeader(onActionComplete);
526
+
527
+ await waitForHeader();
528
+
529
+ const listCallsBeforeClick: number = getListMock.mock.calls.length;
530
+
531
+ fireEvent.click(getDeclareButton());
532
+
533
+ expect(modelFormModalMock).not.toHaveBeenCalled();
534
+ expect(screen.queryByTestId("state-change-modal")).toBeNull();
535
+ expect(onActionComplete).not.toHaveBeenCalled();
536
+ // Nothing is read or reloaded: it is a navigation.
537
+ expect(getListMock.mock.calls.length).toBe(listCallsBeforeClick);
538
+ expect(screen.queryByTestId("alert-state-placeholder")).toBeNull();
539
+ });
540
+
541
+ test("leaves Acknowledge opening its own modal", async () => {
542
+ respondForAlert();
543
+
544
+ renderAlertHeader();
545
+
546
+ await waitForHeader();
547
+
548
+ fireEvent.click(document.getElementById(ACKNOWLEDGE_ID)!);
549
+
550
+ expect(screen.getByTestId("state-change-modal")).toHaveTextContent(
551
+ "Acknowledge Alert / Acknowledge",
552
+ );
553
+ expect(navigateSpy).not.toHaveBeenCalled();
554
+ });
555
+ });
556
+
557
+ test("stays in the row, after Resolve, once the alert is acknowledged from the header", async () => {
558
+ respondForAlert();
559
+
560
+ const onActionComplete: MockFunction = getJestMockFunction();
561
+
562
+ renderAlertHeader(onActionComplete);
563
+
564
+ await waitForHeader();
565
+
566
+ expect(actionIds()).toEqual([ACKNOWLEDGE_ID, RESOLVE_ID, DECLARE_ID]);
567
+
568
+ fireEvent.click(document.getElementById(ACKNOWLEDGE_ID)!);
569
+
570
+ const modalProps: { onSuccess: () => Promise<void> } = modelFormModalMock
571
+ .mock.calls[modelFormModalMock.mock.calls.length - 1]![0] as {
572
+ onSuccess: () => Promise<void>;
573
+ };
574
+
575
+ alertStage = "acknowledged";
576
+
577
+ await act(async () => {
578
+ await modalProps.onSuccess();
579
+ });
580
+
581
+ expect(onActionComplete).toHaveBeenCalledTimes(1);
582
+ expect(actionIds()).toEqual([RESOLVE_ID, DECLARE_ID]);
583
+ expect(getDeclareButton()).toBeEnabled();
584
+ });
585
+
586
+ describe("permissions", () => {
587
+ test.each([
588
+ ["Project Owner", [Permission.ProjectOwner]],
589
+ ["Project Admin", [Permission.ProjectAdmin]],
590
+ ["Project Member", [Permission.ProjectMember]],
591
+ [
592
+ "Alert Member + Create Incident",
593
+ [Permission.AlertMember, Permission.CreateProjectIncident],
594
+ ],
595
+ ])(
596
+ "is enabled for %s",
597
+ async (_label: string, permissions: Array<Permission>) => {
598
+ permissionsForTest = permissions;
599
+ respondForAlert();
600
+
601
+ renderAlertHeader();
602
+
603
+ await waitForHeader();
604
+
605
+ expect(getDeclareButton()).toBeEnabled();
606
+ expect(screen.queryByTestId(DECLARE_WRAPPER_TEST_ID)).toBeNull();
607
+
608
+ fireEvent.click(getDeclareButton());
609
+
610
+ expect(navigateSpy).toHaveBeenCalledTimes(1);
611
+ },
612
+ );
613
+
614
+ test("is locked for a viewer, saying which Incident permission is missing", async () => {
615
+ permissionsForTest = [Permission.Viewer];
616
+ respondForAlert();
617
+
618
+ renderAlertHeader();
619
+
620
+ await waitForHeader();
621
+
622
+ const wrapper: HTMLElement = screen.getByTestId(DECLARE_WRAPPER_TEST_ID);
623
+ const declare: HTMLElement = getDeclareButton();
624
+
625
+ // Still in its place in the row.
626
+ expect(actionIds()).toEqual([ACKNOWLEDGE_ID, RESOLVE_ID, DECLARE_ID]);
627
+ expect(wrapper).toHaveAttribute("tabindex", "0");
628
+ expect(declare.parentElement).toBe(wrapper);
629
+ expect(wrapper.parentElement).toBe(getActionGroup());
630
+ expect(declare).toBeDisabled();
631
+ expect(declare).toHaveAttribute("aria-disabled", "true");
632
+ expect(declare).toHaveClass("pointer-events-none");
633
+ expect(declare).not.toHaveAttribute("title");
634
+
635
+ fireEvent.mouseEnter(wrapper);
636
+
637
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
638
+ INCIDENT_CREATE_REASON,
639
+ );
640
+
641
+ fireEvent.click(wrapper);
642
+ fireEvent.click(declare);
643
+
644
+ expect(navigateSpy).not.toHaveBeenCalled();
645
+ expect(modelFormModalMock).not.toHaveBeenCalled();
646
+ // The state actions are not gated by this button's permissions.
647
+ expect(document.getElementById(ACKNOWLEDGE_ID)).toBeEnabled();
648
+ expect(document.getElementById(RESOLVE_ID)).toBeEnabled();
649
+ });
650
+
651
+ test("is locked for an alert member, saying which Incident permission is missing", async () => {
652
+ permissionsForTest = [Permission.AlertMember];
653
+ respondForAlert();
654
+
655
+ renderAlertHeader();
656
+
657
+ await waitForHeader();
658
+
659
+ expect(getDeclareButton()).toBeDisabled();
660
+
661
+ fireEvent.mouseEnter(screen.getByTestId(DECLARE_WRAPPER_TEST_ID));
662
+
663
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
664
+ INCIDENT_CREATE_REASON,
665
+ );
666
+ });
667
+
668
+ test("is locked for someone who may create incidents but not link alerts", async () => {
669
+ permissionsForTest = [Permission.CreateProjectIncident];
670
+ respondForAlert();
671
+
672
+ renderAlertHeader();
673
+
674
+ await waitForHeader();
675
+
676
+ const declare: HTMLElement = getDeclareButton();
677
+
678
+ expect(declare).toBeDisabled();
679
+
680
+ fireEvent.focus(screen.getByTestId(DECLARE_WRAPPER_TEST_ID));
681
+
682
+ expect(screen.getByRole("tooltip")).toHaveTextContent(
683
+ INCIDENT_ALERT_CREATE_REASON,
684
+ );
685
+
686
+ fireEvent.click(declare);
687
+
688
+ expect(navigateSpy).not.toHaveBeenCalled();
689
+ });
690
+
691
+ test("stays locked, and explained, on a resolved alert", async () => {
692
+ alertStage = "resolved";
693
+ permissionsForTest = [Permission.Viewer];
694
+ respondForAlert();
695
+
696
+ renderAlertHeader();
697
+
698
+ await waitForHeader();
699
+
700
+ expect(actionIds()).toEqual([DECLARE_ID]);
701
+ expect(getDeclareButton()).toBeDisabled();
702
+ expect(screen.getByTestId(DECLARE_WRAPPER_TEST_ID)).toBeInTheDocument();
703
+ });
704
+
705
+ test("is not rendered before the permission snapshot has loaded", async () => {
706
+ permissionsForTest = [];
707
+ respondForAlert();
708
+
709
+ renderAlertHeader();
710
+
711
+ await waitForHeader();
712
+
713
+ expect(document.getElementById(DECLARE_ID)).toBeNull();
714
+ expect(screen.queryByTestId(DECLARE_WRAPPER_TEST_ID)).toBeNull();
715
+ expect(
716
+ screen.queryByRole("button", { name: "Declare Incident" }),
717
+ ).not.toBeInTheDocument();
718
+ // The state actions are unaffected.
719
+ expect(actionIds()).toEqual([ACKNOWLEDGE_ID, RESOLVE_ID]);
720
+ });
721
+
722
+ test("leaves a resolved alert with no buttons before the snapshot has loaded", async () => {
723
+ alertStage = "resolved";
724
+ permissionsForTest = [];
725
+ respondForAlert();
726
+
727
+ renderAlertHeader();
728
+
729
+ await waitForHeader();
730
+
731
+ expect(within(getActionGroup()).queryAllByRole("button")).toEqual([]);
732
+ });
733
+
734
+ test("is enabled for a master admin, even with an empty snapshot", async () => {
735
+ permissionsForTest = [];
736
+ jest.spyOn(UserUtil, "isMasterAdmin").mockReturnValue(true);
737
+ respondForAlert();
738
+
739
+ renderAlertHeader();
740
+
741
+ await waitForHeader();
742
+
743
+ expect(actionIds()).toEqual([ACKNOWLEDGE_ID, RESOLVE_ID, DECLARE_ID]);
744
+ expect(getDeclareButton()).toBeEnabled();
745
+
746
+ fireEvent.click(getDeclareButton());
747
+
748
+ expect((navigateSpy.mock.calls[0]![0] as Route).toString()).toBe(
749
+ CREATE_PAGE_URL,
750
+ );
751
+ });
752
+ });
753
+
754
+ describe("requests", () => {
755
+ test.each([
756
+ ["a project member", [Permission.ProjectMember]],
757
+ ["a viewer", [Permission.Viewer]],
758
+ ["an empty snapshot", []],
759
+ ])(
760
+ "adds no request for %s: still exactly the three header reads",
761
+ async (_label: string, permissions: Array<Permission>) => {
762
+ permissionsForTest = permissions;
763
+ respondForAlert();
764
+
765
+ renderAlertHeader();
766
+
767
+ await waitForHeader();
768
+
769
+ expect(getListMock).toHaveBeenCalledTimes(3);
770
+ expect(requestedModels()).toEqual(
771
+ expect.arrayContaining([
772
+ AlertNoteTemplate,
773
+ AlertState,
774
+ AlertStateTimeline,
775
+ ]),
776
+ );
777
+ },
778
+ );
779
+
780
+ test("issues the same three reads on a resolved alert", async () => {
781
+ alertStage = "resolved";
782
+ respondForAlert();
783
+
784
+ renderAlertHeader();
785
+
786
+ await waitForHeader();
787
+
788
+ expect(getDeclareButton()).toBeEnabled();
789
+ expect(getListMock).toHaveBeenCalledTimes(3);
790
+ expect(requestedModels()).toEqual(
791
+ expect.arrayContaining([
792
+ AlertNoteTemplate,
793
+ AlertState,
794
+ AlertStateTimeline,
795
+ ]),
796
+ );
797
+ });
798
+ });
799
+
800
+ describe("loading and errors", () => {
801
+ test("the placeholder holds room for three buttons", () => {
802
+ getListMock.mockImplementation(() => {
803
+ return new Promise<never>(() => {
804
+ // never settles: the header stays loading
805
+ });
806
+ });
807
+
808
+ renderAlertHeader();
809
+
810
+ const placeholder: HTMLElement = screen.getByTestId(
811
+ "alert-state-placeholder",
812
+ );
813
+ const buttonSkeletons: Array<Element> = Array.from(
814
+ placeholder.querySelectorAll(".h-9.rounded-md"),
815
+ );
816
+
817
+ expect(buttonSkeletons).toHaveLength(3);
818
+
819
+ const row: Element = buttonSkeletons[0]!.parentElement as Element;
820
+
821
+ expect(row).toHaveClass("flex", "gap-2");
822
+ expect(Array.from(row.children)).toEqual(buttonSkeletons);
823
+ expect(buttonSkeletons[0]).toHaveClass("w-28");
824
+ expect(buttonSkeletons[1]).toHaveClass("w-24");
825
+ expect(buttonSkeletons[2]).toHaveClass("w-36");
826
+ // Skeletons only: no real button until the header has loaded.
827
+ expect(document.getElementById(DECLARE_ID)).toBeNull();
828
+ expect(within(placeholder).queryAllByRole("button")).toEqual([]);
829
+ });
830
+
831
+ test("is not offered while the header shows a load error", async () => {
832
+ respondForAlert({ statesFail: true });
833
+
834
+ renderAlertHeader();
835
+
836
+ expect(
837
+ await screen.findByText("States are unavailable"),
838
+ ).toBeInTheDocument();
839
+ expect(document.getElementById(DECLARE_ID)).toBeNull();
840
+ expect(navigateSpy).not.toHaveBeenCalled();
841
+ });
842
+ });
843
+ });
844
+
845
+ describe("ChangeIncidentState header", () => {
846
+ test.each([
847
+ ["created", ["incident-acknowledge-btn", "incident-resolve-btn"]],
848
+ ["acknowledged", ["incident-resolve-btn"]],
849
+ ["resolved", []],
850
+ ])(
851
+ "never offers Declare Incident in the %s state",
852
+ async (stage: string, expectedIds: Array<string>) => {
853
+ respondForIncident(stage as StageName);
854
+
855
+ renderIncidentHeader();
856
+
857
+ await waitForHeader();
858
+
859
+ expect(
860
+ within(getActionGroup())
861
+ .queryAllByRole("button")
862
+ .map((button: HTMLElement): string => {
863
+ return button.id;
864
+ }),
865
+ ).toEqual(expectedIds);
866
+ expect(document.getElementById(DECLARE_ID)).toBeNull();
867
+ expect(screen.queryByTestId(DECLARE_WRAPPER_TEST_ID)).toBeNull();
868
+ expect(
869
+ screen.queryByRole("button", { name: "Declare Incident" }),
870
+ ).not.toBeInTheDocument();
871
+ },
872
+ );
873
+
874
+ test("never offers it to a master admin either", async () => {
875
+ permissionsForTest = [Permission.ProjectOwner];
876
+ jest.spyOn(UserUtil, "isMasterAdmin").mockReturnValue(true);
877
+ respondForIncident("resolved");
878
+
879
+ renderIncidentHeader();
880
+
881
+ await waitForHeader();
882
+
883
+ expect(within(getActionGroup()).queryAllByRole("button")).toEqual([]);
884
+ expect(screen.queryByText("Declare Incident")).toBeNull();
885
+ });
886
+ });