@oneuptime/common 14.0.2 → 14.0.3

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 (166) hide show
  1. package/Models/DatabaseModels/AlertFeed.ts +2 -0
  2. package/Models/DatabaseModels/IncidentAlert.ts +540 -0
  3. package/Models/DatabaseModels/IncidentFeed.ts +2 -0
  4. package/Models/DatabaseModels/Index.ts +2 -0
  5. package/Models/DatabaseModels/Project.ts +60 -0
  6. package/Models/DatabaseModels/RumApplication.ts +52 -1
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.ts +28 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.ts +89 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  10. package/Server/Services/IncidentAlertService.ts +1811 -0
  11. package/Server/Services/IncidentService.ts +210 -10
  12. package/Server/Services/IncidentStateTimelineService.ts +24 -0
  13. package/Server/Services/Index.ts +2 -0
  14. package/Server/Utils/SessionReplay/SessionReplayGateCache.ts +24 -2
  15. package/Tests/App/Dashboard/BulkIncidentLinkActions.test.tsx +869 -0
  16. package/Tests/App/Dashboard/CephMetricTooltips.test.tsx +1777 -0
  17. package/Tests/App/Dashboard/CloudFleetSummary.test.ts +8 -0
  18. package/Tests/App/Dashboard/CloudMetricTooltips.test.tsx +887 -0
  19. package/Tests/App/Dashboard/ContainerHostMetricTooltips.test.tsx +1309 -0
  20. package/Tests/App/Dashboard/DiscoveryScanLivePage.test.tsx +39 -0
  21. package/Tests/App/Dashboard/DockerSwarmClustersListTooltips.test.tsx +414 -0
  22. package/Tests/App/Dashboard/DockerSwarmMetricTooltips.test.ts +796 -0
  23. package/Tests/App/Dashboard/DockerSwarmOverviewTooltips.test.tsx +747 -0
  24. package/Tests/App/Dashboard/ExceptionMonitorEnvironmentFilter.test.tsx +38 -18
  25. package/Tests/App/Dashboard/HostDetailViewTooltips.test.tsx +725 -0
  26. package/Tests/App/Dashboard/HostListColumnTooltips.test.tsx +472 -0
  27. package/Tests/App/Dashboard/HostMetricTooltips.test.ts +709 -0
  28. package/Tests/App/Dashboard/HostOverviewTooltips.test.tsx +698 -0
  29. package/Tests/App/Dashboard/HostTooltipHarness.ts +124 -0
  30. package/Tests/App/Dashboard/HostsListTooltips.test.tsx +395 -0
  31. package/Tests/App/Dashboard/IncidentAlertLinkDialog.test.tsx +691 -0
  32. package/Tests/App/Dashboard/IncidentAlertLinkHelpers.test.ts +458 -0
  33. package/Tests/App/Dashboard/IncidentAlertLinkPages.test.tsx +828 -0
  34. package/Tests/App/Dashboard/IncidentAlertLinkSideMenus.test.tsx +231 -0
  35. package/Tests/App/Dashboard/IncidentCreateFromAlerts.test.tsx +689 -0
  36. package/Tests/App/Dashboard/IoTMetricTooltips.test.tsx +1123 -0
  37. package/Tests/App/Dashboard/KubernetesClusterMetricTooltips.test.ts +837 -0
  38. package/Tests/App/Dashboard/KubernetesClusterOverviewTooltips.test.tsx +699 -0
  39. package/Tests/App/Dashboard/KubernetesContainersTabTooltips.test.tsx +320 -0
  40. package/Tests/App/Dashboard/KubernetesCostTooltips.test.tsx +502 -0
  41. package/Tests/App/Dashboard/KubernetesInsightsTooltips.test.tsx +222 -0
  42. package/Tests/App/Dashboard/KubernetesResourceMetricTooltips.test.ts +561 -0
  43. package/Tests/App/Dashboard/KubernetesResourcePagesTooltips.test.tsx +1168 -0
  44. package/Tests/App/Dashboard/LinkIncidentAlertModal.test.tsx +154 -0
  45. package/Tests/App/Dashboard/MetricDescriptionRules.ts +170 -0
  46. package/Tests/App/Dashboard/MetricDescriptionsCatalog.test.ts +206 -0
  47. package/Tests/App/Dashboard/MetricDescriptionsConsistency.test.ts +275 -0
  48. package/Tests/App/Dashboard/NetworkDeviceTooltips.test.tsx +852 -0
  49. package/Tests/App/Dashboard/NetworkDiagnosticsTooltips.test.tsx +556 -0
  50. package/Tests/App/Dashboard/NetworkMetricTooltips.test.ts +1348 -0
  51. package/Tests/App/Dashboard/NetworkSiteTooltips.test.tsx +679 -0
  52. package/Tests/App/Dashboard/ProxmoxMetricTooltips.test.ts +592 -0
  53. package/Tests/App/Dashboard/ProxmoxOverviewTooltips.test.tsx +1002 -0
  54. package/Tests/App/Dashboard/ProxmoxResourcePageTooltips.test.tsx +923 -0
  55. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +1066 -0
  56. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +1195 -0
  57. package/Tests/App/Dashboard/ServerlessMetricTooltips.test.tsx +474 -0
  58. package/Tests/App/Dashboard/ServiceMetricTooltips.test.tsx +772 -0
  59. package/Tests/App/Dashboard/SessionReplayAuditTable.test.tsx +56 -1
  60. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +1207 -0
  61. package/Tests/App/Dashboard/TelemetryResourceTabTooltips.test.tsx +437 -0
  62. package/Tests/App/Dashboard/VMwareMetricTooltips.test.ts +801 -0
  63. package/Tests/App/Dashboard/VMwareOverviewTooltips.test.tsx +601 -0
  64. package/Tests/App/Dashboard/VMwareResourcePagesTooltips.test.tsx +646 -0
  65. package/Tests/App/Dashboard/VMwareTooltipHarness.ts +145 -0
  66. package/Tests/Models/DatabaseModels/IncidentAlertModel.test.ts +554 -0
  67. package/Tests/Models/DatabaseModels/SessionReplayModels.test.ts +117 -0
  68. package/Tests/Server/Infrastructure/Postgres/AddIncidentAlertMigration.test.ts +341 -0
  69. package/Tests/Server/Infrastructure/Postgres/AddSessionReplaySameOriginTracePropagationMigration.test.ts +247 -0
  70. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +903 -0
  71. package/Tests/Server/Services/IncidentAlertRegistration.test.ts +96 -0
  72. package/Tests/Server/Services/IncidentAlertService.test.ts +2225 -0
  73. package/Tests/Server/Services/IncidentAlertStateCascade.test.ts +970 -0
  74. package/Tests/Server/Services/IncidentCreateFromAlerts.test.ts +1284 -0
  75. package/Tests/Server/Utils/SessionReplay/SessionReplayGateCachePolicy.test.ts +103 -0
  76. package/Tests/Types/Rum/WebVitals.test.ts +173 -0
  77. package/Tests/UI/Components/FieldLabelInfoCardTooltip.test.tsx +413 -0
  78. package/Tests/UI/Components/InfoTooltip.test.tsx +242 -0
  79. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +207 -3
  80. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDeleteVerb.test.tsx +367 -0
  81. package/Tests/UI/Components/ModelTable/BaseModelTableCreateButtonTitle.test.tsx +275 -0
  82. package/Tests/UI/Components/TableHeaderTooltip.test.tsx +662 -0
  83. package/Tests/UI/Rum/PrivacySummaryCard.test.tsx +166 -9
  84. package/Tests/UI/Rum/RecordingHealthTooltips.test.tsx +447 -0
  85. package/Tests/UI/Rum/ReplayCorrelationPanel.test.tsx +164 -0
  86. package/Tests/UI/Rum/ReplayFrameCapture.test.ts +6750 -0
  87. package/Tests/UI/Rum/ReplayRail.test.tsx +723 -12
  88. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +86 -0
  89. package/Tests/UI/Rum/ReplayScreenshot.test.ts +2124 -0
  90. package/Tests/UI/Rum/ReplayScreenshotActions.test.tsx +3288 -0
  91. package/Tests/UI/Rum/ReplayStage.test.tsx +62 -0
  92. package/Tests/UI/Rum/ReplayStageOverlays.test.tsx +983 -2
  93. package/Tests/UI/Rum/SessionReplaySettingsPolicyLoading.test.tsx +149 -0
  94. package/Tests/UI/Rum/SessionReplaySetupGuide.test.tsx +107 -6
  95. package/Tests/UI/Rum/SessionReplayUsersTableTooltips.test.tsx +330 -0
  96. package/Tests/UI/Rum/UserFlowTooltips.test.tsx +886 -0
  97. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  98. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  99. package/Tests/UI/Utils/PermissionGate.test.ts +76 -0
  100. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +696 -0
  101. package/Tests/Utils/Rum/SessionTraceState.test.ts +239 -0
  102. package/Types/Incident/IncidentAlertLink.ts +38 -0
  103. package/Types/Permission.ts +48 -0
  104. package/Types/Rum/SessionReplay.ts +25 -6
  105. package/Types/Rum/WebVitals.ts +52 -0
  106. package/UI/Components/Detail/FieldLabel.tsx +8 -0
  107. package/UI/Components/InfoCard/InfoCard.tsx +47 -1
  108. package/UI/Components/ModelTable/BaseModelTable.tsx +95 -17
  109. package/UI/Components/ModelTable/Column.ts +5 -0
  110. package/UI/Components/Table/TableHeader.tsx +47 -2
  111. package/UI/Components/Table/Types/Column.ts +7 -0
  112. package/UI/Components/Tooltip/InfoTooltip.tsx +78 -0
  113. package/UI/Utils/PermissionGate.ts +11 -2
  114. package/Utils/Incident/IncidentFromAlerts.ts +516 -0
  115. package/Utils/Rum/SessionTraceState.ts +212 -0
  116. package/build/dist/Models/DatabaseModels/AlertFeed.js +2 -0
  117. package/build/dist/Models/DatabaseModels/AlertFeed.js.map +1 -1
  118. package/build/dist/Models/DatabaseModels/IncidentAlert.js +553 -0
  119. package/build/dist/Models/DatabaseModels/IncidentAlert.js.map +1 -0
  120. package/build/dist/Models/DatabaseModels/IncidentFeed.js +2 -0
  121. package/build/dist/Models/DatabaseModels/IncidentFeed.js.map +1 -1
  122. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  123. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Project.js +62 -0
  125. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/RumApplication.js +53 -1
  127. package/build/dist/Models/DatabaseModels/RumApplication.js.map +1 -1
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js +20 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794800000000-AddSessionReplaySameOriginTracePropagation.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js +42 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1794900000000-AddIncidentAlert.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  134. package/build/dist/Server/Services/IncidentAlertService.js +1370 -0
  135. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -0
  136. package/build/dist/Server/Services/IncidentService.js +164 -12
  137. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  138. package/build/dist/Server/Services/IncidentStateTimelineService.js +21 -0
  139. package/build/dist/Server/Services/IncidentStateTimelineService.js.map +1 -1
  140. package/build/dist/Server/Services/Index.js +2 -0
  141. package/build/dist/Server/Services/Index.js.map +1 -1
  142. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js +8 -0
  143. package/build/dist/Server/Utils/SessionReplay/SessionReplayGateCache.js.map +1 -1
  144. package/build/dist/Types/Incident/IncidentAlertLink.js +34 -0
  145. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -0
  146. package/build/dist/Types/Permission.js +42 -0
  147. package/build/dist/Types/Permission.js.map +1 -1
  148. package/build/dist/Types/Rum/WebVitals.js +25 -0
  149. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  150. package/build/dist/UI/Components/Detail/FieldLabel.js +2 -0
  151. package/build/dist/UI/Components/Detail/FieldLabel.js.map +1 -1
  152. package/build/dist/UI/Components/InfoCard/InfoCard.js +22 -1
  153. package/build/dist/UI/Components/InfoCard/InfoCard.js.map +1 -1
  154. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +66 -14
  155. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  156. package/build/dist/UI/Components/Table/TableHeader.js +18 -2
  157. package/build/dist/UI/Components/Table/TableHeader.js.map +1 -1
  158. package/build/dist/UI/Components/Tooltip/InfoTooltip.js +28 -0
  159. package/build/dist/UI/Components/Tooltip/InfoTooltip.js.map +1 -0
  160. package/build/dist/UI/Utils/PermissionGate.js +4 -2
  161. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  162. package/build/dist/Utils/Incident/IncidentFromAlerts.js +306 -0
  163. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -0
  164. package/build/dist/Utils/Rum/SessionTraceState.js +154 -0
  165. package/build/dist/Utils/Rum/SessionTraceState.js.map +1 -0
  166. package/package.json +1 -1
@@ -0,0 +1,970 @@
1
+ import AlertService from "../../../Server/Services/AlertService";
2
+ import AlertStateService from "../../../Server/Services/AlertStateService";
3
+ import IncidentAlertService, {
4
+ chooseLinkedAlertTargetState,
5
+ getLinkedAlertStateTargets,
6
+ LinkedAlertStateTargets,
7
+ } from "../../../Server/Services/IncidentAlertService";
8
+ import IncidentService from "../../../Server/Services/IncidentService";
9
+ import IncidentStateService from "../../../Server/Services/IncidentStateService";
10
+ import ProjectService from "../../../Server/Services/ProjectService";
11
+ import logger from "../../../Server/Utils/Logger";
12
+ import Alert from "../../../Models/DatabaseModels/Alert";
13
+ import AlertState from "../../../Models/DatabaseModels/AlertState";
14
+ import Incident from "../../../Models/DatabaseModels/Incident";
15
+ import IncidentAlert from "../../../Models/DatabaseModels/IncidentAlert";
16
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
17
+ import Project from "../../../Models/DatabaseModels/Project";
18
+ import DatabaseCommonInteractionProps from "../../../Types/BaseDatabase/DatabaseCommonInteractionProps";
19
+ import { JSONObject } from "../../../Types/JSON";
20
+ import ObjectID from "../../../Types/ObjectID";
21
+ import { afterEach, beforeEach, describe, expect, test } from "@jest/globals";
22
+ import { FindOperator } from "typeorm";
23
+
24
+ /*
25
+ * The opt-in lifecycle sync between an incident and the alerts linked to it.
26
+ *
27
+ * When the project turns the switches on, acknowledging an incident
28
+ * acknowledges its linked alerts (which is what stops their paging) and
29
+ * resolving it resolves them - except an alert another open incident is still
30
+ * tracking. States are compared by order, custom states between Acknowledged
31
+ * and Resolved included; an alert is never moved backwards, never moved to
32
+ * where it already is, and one alert failing never stops the rest.
33
+ *
34
+ * The database is replaced by small in-memory tables. Every read the cascade
35
+ * makes is answered from them, and every state change it asks for is recorded
36
+ * instead of written.
37
+ */
38
+
39
+ const PROJECT_ID: ObjectID = new ObjectID(
40
+ "0194b2f8-0000-4000-8000-000000000001",
41
+ );
42
+ const INCIDENT_ID: ObjectID = new ObjectID(
43
+ "0194b2f8-0000-4000-8000-0000000000a1",
44
+ );
45
+ const OPEN_INCIDENT_ID: ObjectID = new ObjectID(
46
+ "0194b2f8-0000-4000-8000-0000000000a2",
47
+ );
48
+ const RESOLVED_INCIDENT_ID: ObjectID = new ObjectID(
49
+ "0194b2f8-0000-4000-8000-0000000000a3",
50
+ );
51
+
52
+ // Alert ids, named after the state each starts in.
53
+ const CREATED_ALERT: string = "0194b2f8-0000-4000-8000-0000000000b1";
54
+ const SECOND_CREATED_ALERT: string = "0194b2f8-0000-4000-8000-0000000000b2";
55
+ const ACKNOWLEDGED_ALERT: string = "0194b2f8-0000-4000-8000-0000000000b3";
56
+ const INVESTIGATING_ALERT: string = "0194b2f8-0000-4000-8000-0000000000b4";
57
+ const RESOLVED_ALERT: string = "0194b2f8-0000-4000-8000-0000000000b5";
58
+
59
+ interface StateRow {
60
+ id: string;
61
+ name: string;
62
+ order: number;
63
+ isAcknowledged?: boolean;
64
+ isResolved?: boolean;
65
+ }
66
+
67
+ // Incident states. "Monitoring" is a custom state between ack and resolve.
68
+ const INCIDENT_CREATED: StateRow = {
69
+ id: "0194b2f8-0000-4000-8000-0000000000c1",
70
+ name: "Created",
71
+ order: 1,
72
+ };
73
+ const INCIDENT_ACKNOWLEDGED: StateRow = {
74
+ id: "0194b2f8-0000-4000-8000-0000000000c2",
75
+ name: "Acknowledged",
76
+ order: 2,
77
+ isAcknowledged: true,
78
+ };
79
+ const INCIDENT_MONITORING: StateRow = {
80
+ id: "0194b2f8-0000-4000-8000-0000000000c3",
81
+ name: "Monitoring",
82
+ order: 3,
83
+ };
84
+ const INCIDENT_RESOLVED: StateRow = {
85
+ id: "0194b2f8-0000-4000-8000-0000000000c4",
86
+ name: "Resolved",
87
+ order: 4,
88
+ isResolved: true,
89
+ };
90
+
91
+ // Alert states. "Investigating" is a custom state between ack and resolve.
92
+ const ALERT_CREATED: StateRow = {
93
+ id: "0194b2f8-0000-4000-8000-0000000000d1",
94
+ name: "Created",
95
+ order: 1,
96
+ };
97
+ const ALERT_ACKNOWLEDGED: StateRow = {
98
+ id: "0194b2f8-0000-4000-8000-0000000000d2",
99
+ name: "Acknowledged",
100
+ order: 2,
101
+ isAcknowledged: true,
102
+ };
103
+ const ALERT_INVESTIGATING: StateRow = {
104
+ id: "0194b2f8-0000-4000-8000-0000000000d3",
105
+ name: "Investigating",
106
+ order: 3,
107
+ };
108
+ const ALERT_RESOLVED: StateRow = {
109
+ id: "0194b2f8-0000-4000-8000-0000000000d4",
110
+ name: "Resolved",
111
+ order: 4,
112
+ isResolved: true,
113
+ };
114
+
115
+ interface Tables {
116
+ switches: { acknowledge: boolean; resolve: boolean } | null;
117
+ incidentStates: Array<StateRow>;
118
+ alertStates: Array<StateRow>;
119
+ // incident id -> current state id
120
+ incidents: Map<string, string>;
121
+ // alert id -> current state id
122
+ alerts: Map<string, string>;
123
+ links: Array<{ incidentId: string; alertId: string }>;
124
+ }
125
+
126
+ let tables: Tables;
127
+
128
+ interface StateChange {
129
+ alertId: string;
130
+ alertStateId: string;
131
+ rootCause: string | undefined;
132
+ notifyOwners: boolean;
133
+ stateChangeLog: JSONObject | undefined;
134
+ props: DatabaseCommonInteractionProps | undefined;
135
+ projectId: string;
136
+ }
137
+
138
+ let changes: Array<StateChange>;
139
+ let changeAlertState: jest.SpyInstance;
140
+ let incidentStateLookup: jest.SpyInstance;
141
+ let alertStateLookup: jest.SpyInstance;
142
+ let linkLookup: jest.SpyInstance;
143
+ let alertLookup: jest.SpyInstance;
144
+ let errorLog: jest.SpyInstance;
145
+
146
+ function freshTables(): Tables {
147
+ return {
148
+ switches: { acknowledge: true, resolve: true },
149
+ incidentStates: [
150
+ INCIDENT_CREATED,
151
+ INCIDENT_ACKNOWLEDGED,
152
+ INCIDENT_MONITORING,
153
+ INCIDENT_RESOLVED,
154
+ ],
155
+ alertStates: [
156
+ ALERT_CREATED,
157
+ ALERT_ACKNOWLEDGED,
158
+ ALERT_INVESTIGATING,
159
+ ALERT_RESOLVED,
160
+ ],
161
+ incidents: new Map([
162
+ [INCIDENT_ID.toString(), INCIDENT_CREATED.id],
163
+ [OPEN_INCIDENT_ID.toString(), INCIDENT_ACKNOWLEDGED.id],
164
+ [RESOLVED_INCIDENT_ID.toString(), INCIDENT_RESOLVED.id],
165
+ ]),
166
+ alerts: new Map([
167
+ [CREATED_ALERT, ALERT_CREATED.id],
168
+ [SECOND_CREATED_ALERT, ALERT_CREATED.id],
169
+ [ACKNOWLEDGED_ALERT, ALERT_ACKNOWLEDGED.id],
170
+ [INVESTIGATING_ALERT, ALERT_INVESTIGATING.id],
171
+ [RESOLVED_ALERT, ALERT_RESOLVED.id],
172
+ ]),
173
+ links: [],
174
+ };
175
+ }
176
+
177
+ function link(alertId: string, incidentId: ObjectID = INCIDENT_ID): void {
178
+ tables.links.push({ incidentId: incidentId.toString(), alertId: alertId });
179
+ }
180
+
181
+ // The ids a QueryHelper.any(...) filter matches, or the one plain value.
182
+ function idsIn(value: unknown): Array<string> | null {
183
+ if (value === undefined) {
184
+ return null;
185
+ }
186
+
187
+ if (value instanceof FindOperator) {
188
+ const parameters: Array<unknown> = Object.values(
189
+ value.objectLiteralParameters || {},
190
+ );
191
+ return ((parameters[0] as Array<string>) || []).map((id: string) => {
192
+ return id.toLowerCase();
193
+ });
194
+ }
195
+
196
+ return [String(value).toLowerCase()];
197
+ }
198
+
199
+ function matches(value: string, filter: Array<string> | null): boolean {
200
+ return filter === null || filter.includes(value.toLowerCase());
201
+ }
202
+
203
+ function incidentStateModel(row: StateRow): IncidentState {
204
+ const state: IncidentState = new IncidentState();
205
+ state._id = row.id;
206
+ state.name = row.name;
207
+ state.order = row.order;
208
+ state.isAcknowledgedState = Boolean(row.isAcknowledged);
209
+ state.isResolvedState = Boolean(row.isResolved);
210
+ state.isCreatedState = row.order === 1;
211
+ return state;
212
+ }
213
+
214
+ function alertStateModel(row: StateRow): AlertState {
215
+ const state: AlertState = new AlertState();
216
+ state._id = row.id;
217
+ state.name = row.name;
218
+ state.order = row.order;
219
+ state.isAcknowledgedState = Boolean(row.isAcknowledged);
220
+ state.isResolvedState = Boolean(row.isResolved);
221
+ state.isCreatedState = row.order === 1;
222
+ return state;
223
+ }
224
+
225
+ function incidentModel(id: string): Incident | null {
226
+ const stateId: string | undefined = tables.incidents.get(id);
227
+
228
+ if (!stateId) {
229
+ return null;
230
+ }
231
+
232
+ const incident: Incident = new Incident();
233
+ incident._id = id;
234
+ incident.projectId = PROJECT_ID;
235
+ incident.currentIncidentStateId = new ObjectID(stateId);
236
+ incident.incidentNumber = 42;
237
+ incident.incidentNumberWithPrefix = "INC-42";
238
+ return incident;
239
+ }
240
+
241
+ beforeEach(() => {
242
+ tables = freshTables();
243
+ changes = [];
244
+
245
+ jest
246
+ .spyOn(ProjectService, "findOneById")
247
+ .mockImplementation((async (): Promise<Project | null> => {
248
+ if (!tables.switches) {
249
+ return null;
250
+ }
251
+ const project: Project = new Project();
252
+ project._id = PROJECT_ID.toString();
253
+ project.acknowledgeLinkedAlertsWhenIncidentAcknowledged =
254
+ tables.switches.acknowledge;
255
+ project.resolveLinkedAlertsWhenIncidentResolved = tables.switches.resolve;
256
+ return project;
257
+ }) as never);
258
+
259
+ incidentStateLookup = jest
260
+ .spyOn(IncidentStateService, "getAllIncidentStates")
261
+ .mockImplementation((async (): Promise<Array<IncidentState>> => {
262
+ return tables.incidentStates.map(incidentStateModel);
263
+ }) as never);
264
+
265
+ alertStateLookup = jest
266
+ .spyOn(AlertStateService, "getAllAlertStates")
267
+ .mockImplementation((async (): Promise<Array<AlertState>> => {
268
+ return tables.alertStates.map(alertStateModel);
269
+ }) as never);
270
+
271
+ linkLookup = jest
272
+ .spyOn(IncidentAlertService, "findBy")
273
+ .mockImplementation((async (args: {
274
+ query: { incidentId?: unknown; alertId?: unknown; projectId?: unknown };
275
+ props: DatabaseCommonInteractionProps;
276
+ }): Promise<Array<IncidentAlert>> => {
277
+ expect(args.props).toEqual({ isRoot: true });
278
+ expect(String(args.query.projectId)).toBe(PROJECT_ID.toString());
279
+
280
+ const incidentFilter: Array<string> | null = idsIn(args.query.incidentId);
281
+ const alertFilter: Array<string> | null = idsIn(args.query.alertId);
282
+
283
+ return tables.links
284
+ .filter((row: { incidentId: string; alertId: string }) => {
285
+ return (
286
+ matches(row.incidentId, incidentFilter) &&
287
+ matches(row.alertId, alertFilter)
288
+ );
289
+ })
290
+ .map((row: { incidentId: string; alertId: string }) => {
291
+ const model: IncidentAlert = new IncidentAlert();
292
+ model.incidentId = new ObjectID(row.incidentId);
293
+ model.alertId = new ObjectID(row.alertId);
294
+ model.projectId = PROJECT_ID;
295
+ return model;
296
+ });
297
+ }) as never);
298
+
299
+ alertLookup = jest
300
+ .spyOn(AlertService, "findBy")
301
+ .mockImplementation((async (args: {
302
+ query: { _id?: unknown };
303
+ props: DatabaseCommonInteractionProps;
304
+ }): Promise<Array<Alert>> => {
305
+ expect(args.props).toEqual({ isRoot: true });
306
+ const filter: Array<string> | null = idsIn(args.query._id);
307
+
308
+ return Array.from(tables.alerts.entries())
309
+ .filter(([id]: [string, string]) => {
310
+ return matches(id, filter);
311
+ })
312
+ .map(([id, stateId]: [string, string]) => {
313
+ const alert: Alert = new Alert();
314
+ alert._id = id;
315
+ alert.currentAlertStateId = new ObjectID(stateId);
316
+ return alert;
317
+ });
318
+ }) as never);
319
+
320
+ jest.spyOn(IncidentService, "findBy").mockImplementation((async (args: {
321
+ query: { _id?: unknown };
322
+ }): Promise<Array<Incident>> => {
323
+ const filter: Array<string> | null = idsIn(args.query._id);
324
+
325
+ return Array.from(tables.incidents.keys())
326
+ .filter((id: string) => {
327
+ return matches(id, filter);
328
+ })
329
+ .map((id: string) => {
330
+ return incidentModel(id)!;
331
+ });
332
+ }) as never);
333
+
334
+ jest.spyOn(IncidentService, "findOneById").mockImplementation((async (args: {
335
+ id: ObjectID;
336
+ }): Promise<Incident | null> => {
337
+ return incidentModel(args.id.toString());
338
+ }) as never);
339
+
340
+ changeAlertState = jest
341
+ .spyOn(AlertService, "changeAlertState")
342
+ .mockImplementation((async (data: {
343
+ projectId: ObjectID;
344
+ alertId: ObjectID;
345
+ alertStateId: ObjectID;
346
+ notifyOwners: boolean;
347
+ rootCause: string | undefined;
348
+ stateChangeLog: JSONObject | undefined;
349
+ props: DatabaseCommonInteractionProps | undefined;
350
+ }): Promise<void> => {
351
+ changes.push({
352
+ alertId: data.alertId.toString(),
353
+ alertStateId: data.alertStateId.toString(),
354
+ rootCause: data.rootCause,
355
+ notifyOwners: data.notifyOwners,
356
+ stateChangeLog: data.stateChangeLog,
357
+ props: data.props,
358
+ projectId: data.projectId.toString(),
359
+ });
360
+ }) as never);
361
+
362
+ errorLog = jest.spyOn(logger, "error").mockImplementation((() => {
363
+ // quiet
364
+ }) as never);
365
+ });
366
+
367
+ afterEach(() => {
368
+ jest.restoreAllMocks();
369
+ });
370
+
371
+ function setSwitches(acknowledge: boolean, resolve: boolean): void {
372
+ tables.switches = { acknowledge, resolve };
373
+ }
374
+
375
+ async function cascade(incidentState: StateRow): Promise<void> {
376
+ tables.incidents.set(INCIDENT_ID.toString(), incidentState.id);
377
+
378
+ await IncidentAlertService.cascadeIncidentStateToLinkedAlerts({
379
+ projectId: PROJECT_ID,
380
+ incidentId: INCIDENT_ID,
381
+ incidentStateId: new ObjectID(incidentState.id),
382
+ });
383
+ }
384
+
385
+ // alert id -> state it was moved to (by name), in the order they were moved.
386
+ function moved(): Array<[string, string]> {
387
+ return changes.map((change: StateChange): [string, string] => {
388
+ const state: StateRow | undefined = tables.alertStates.find(
389
+ (row: StateRow) => {
390
+ return row.id === change.alertStateId;
391
+ },
392
+ );
393
+ return [change.alertId, state?.name || change.alertStateId];
394
+ });
395
+ }
396
+
397
+ function linkEveryAlert(): void {
398
+ link(CREATED_ALERT);
399
+ link(ACKNOWLEDGED_ALERT);
400
+ link(INVESTIGATING_ALERT);
401
+ link(RESOLVED_ALERT);
402
+ }
403
+
404
+ describe("the project switches", () => {
405
+ test("both off (the default): nothing about the alerts is even read", async () => {
406
+ setSwitches(false, false);
407
+ linkEveryAlert();
408
+
409
+ await cascade(INCIDENT_RESOLVED);
410
+
411
+ expect(incidentStateLookup).not.toHaveBeenCalled();
412
+ expect(alertStateLookup).not.toHaveBeenCalled();
413
+ expect(linkLookup).not.toHaveBeenCalled();
414
+ expect(alertLookup).not.toHaveBeenCalled();
415
+ expect(changeAlertState).not.toHaveBeenCalled();
416
+ });
417
+
418
+ test("a project that cannot be read counts as both off", async () => {
419
+ tables.switches = null;
420
+ linkEveryAlert();
421
+
422
+ await cascade(INCIDENT_RESOLVED);
423
+
424
+ expect(changeAlertState).not.toHaveBeenCalled();
425
+ });
426
+
427
+ test("acknowledge only: an acknowledged incident acknowledges the alerts behind it", async () => {
428
+ setSwitches(true, false);
429
+ linkEveryAlert();
430
+
431
+ await cascade(INCIDENT_ACKNOWLEDGED);
432
+
433
+ // Acknowledged, Investigating and Resolved alerts are already there.
434
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
435
+ });
436
+
437
+ test("acknowledge only: a resolved incident still only acknowledges", async () => {
438
+ setSwitches(true, false);
439
+ linkEveryAlert();
440
+
441
+ await cascade(INCIDENT_RESOLVED);
442
+
443
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
444
+ expect(changes[0]!.rootCause).toBe(
445
+ "Acknowledged because linked Incident INC-42 was resolved.",
446
+ );
447
+ });
448
+
449
+ test("resolve only: an acknowledged incident changes nothing", async () => {
450
+ setSwitches(false, true);
451
+ linkEveryAlert();
452
+
453
+ await cascade(INCIDENT_ACKNOWLEDGED);
454
+
455
+ expect(changeAlertState).not.toHaveBeenCalled();
456
+ expect(alertStateLookup).not.toHaveBeenCalled();
457
+ });
458
+
459
+ test("resolve only: a resolved incident resolves every alert that is not yet resolved", async () => {
460
+ setSwitches(false, true);
461
+ linkEveryAlert();
462
+
463
+ await cascade(INCIDENT_RESOLVED);
464
+
465
+ expect(moved()).toEqual([
466
+ [CREATED_ALERT, "Resolved"],
467
+ [ACKNOWLEDGED_ALERT, "Resolved"],
468
+ [INVESTIGATING_ALERT, "Resolved"],
469
+ ]);
470
+ });
471
+
472
+ test("both on: a resolved incident resolves, an acknowledged one acknowledges", async () => {
473
+ setSwitches(true, true);
474
+ linkEveryAlert();
475
+
476
+ await cascade(INCIDENT_ACKNOWLEDGED);
477
+
478
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
479
+
480
+ changes = [];
481
+ await cascade(INCIDENT_RESOLVED);
482
+
483
+ expect(moved()).toEqual([
484
+ [CREATED_ALERT, "Resolved"],
485
+ [ACKNOWLEDGED_ALERT, "Resolved"],
486
+ [INVESTIGATING_ALERT, "Resolved"],
487
+ ]);
488
+ });
489
+
490
+ test("an incident back in its created state changes nothing", async () => {
491
+ setSwitches(true, true);
492
+ linkEveryAlert();
493
+
494
+ await cascade(INCIDENT_CREATED);
495
+
496
+ expect(changeAlertState).not.toHaveBeenCalled();
497
+ expect(linkLookup).not.toHaveBeenCalled();
498
+ });
499
+ });
500
+
501
+ describe("states are compared by order", () => {
502
+ test("an incident in a custom state past Acknowledged counts as acknowledged", async () => {
503
+ setSwitches(true, true);
504
+ link(CREATED_ALERT);
505
+ link(INVESTIGATING_ALERT);
506
+
507
+ await cascade(INCIDENT_MONITORING);
508
+
509
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
510
+ expect(changes[0]!.rootCause).toBe(
511
+ "Acknowledged because linked Incident INC-42 was acknowledged.",
512
+ );
513
+ });
514
+
515
+ test("an incident in a custom state before Resolved does not resolve anything", async () => {
516
+ setSwitches(false, true);
517
+ link(CREATED_ALERT);
518
+
519
+ await cascade(INCIDENT_MONITORING);
520
+
521
+ expect(changeAlertState).not.toHaveBeenCalled();
522
+ });
523
+
524
+ test("an alert in a custom state past Acknowledged is not acknowledged again, or moved back", async () => {
525
+ setSwitches(true, false);
526
+ link(INVESTIGATING_ALERT);
527
+
528
+ await cascade(INCIDENT_ACKNOWLEDGED);
529
+
530
+ expect(changeAlertState).not.toHaveBeenCalled();
531
+ });
532
+
533
+ test("an alert that is already resolved is left alone", async () => {
534
+ setSwitches(true, true);
535
+ link(RESOLVED_ALERT);
536
+
537
+ await cascade(INCIDENT_RESOLVED);
538
+
539
+ expect(changeAlertState).not.toHaveBeenCalled();
540
+ });
541
+
542
+ test("custom state orders are honoured even when the flags would say otherwise", async () => {
543
+ // A project whose Acknowledged alert state sits after a custom state.
544
+ tables.alertStates = [
545
+ ALERT_CREATED,
546
+ { ...ALERT_INVESTIGATING, order: 2 },
547
+ { ...ALERT_ACKNOWLEDGED, order: 3 },
548
+ ALERT_RESOLVED,
549
+ ];
550
+ setSwitches(true, false);
551
+ link(INVESTIGATING_ALERT);
552
+
553
+ await cascade(INCIDENT_ACKNOWLEDGED);
554
+
555
+ expect(moved()).toEqual([[INVESTIGATING_ALERT, "Acknowledged"]]);
556
+ });
557
+ });
558
+
559
+ describe("an alert linked to more than one incident", () => {
560
+ test("is not resolved while another linked incident is still open, but is acknowledged", async () => {
561
+ setSwitches(true, true);
562
+ link(CREATED_ALERT);
563
+ link(CREATED_ALERT, OPEN_INCIDENT_ID);
564
+ link(SECOND_CREATED_ALERT);
565
+ link(SECOND_CREATED_ALERT, RESOLVED_INCIDENT_ID);
566
+
567
+ await cascade(INCIDENT_RESOLVED);
568
+
569
+ expect(moved()).toEqual([
570
+ [CREATED_ALERT, "Acknowledged"],
571
+ [SECOND_CREATED_ALERT, "Resolved"],
572
+ ]);
573
+ });
574
+
575
+ test("with only the resolve switch on, an alert another open incident tracks is left alone", async () => {
576
+ setSwitches(false, true);
577
+ link(CREATED_ALERT);
578
+ link(CREATED_ALERT, OPEN_INCIDENT_ID);
579
+
580
+ await cascade(INCIDENT_RESOLVED);
581
+
582
+ expect(changeAlertState).not.toHaveBeenCalled();
583
+ });
584
+
585
+ test("an already-acknowledged alert held open by another incident stays acknowledged", async () => {
586
+ setSwitches(true, true);
587
+ link(ACKNOWLEDGED_ALERT);
588
+ link(ACKNOWLEDGED_ALERT, OPEN_INCIDENT_ID);
589
+
590
+ await cascade(INCIDENT_RESOLVED);
591
+
592
+ expect(changeAlertState).not.toHaveBeenCalled();
593
+ });
594
+
595
+ test("another incident whose state cannot be placed counts as open", async () => {
596
+ setSwitches(false, true);
597
+ tables.incidents.set(
598
+ OPEN_INCIDENT_ID.toString(),
599
+ "0194b2f8-0000-4000-8000-0000000000ff",
600
+ );
601
+ link(CREATED_ALERT);
602
+ link(CREATED_ALERT, OPEN_INCIDENT_ID);
603
+
604
+ await cascade(INCIDENT_RESOLVED);
605
+
606
+ expect(changeAlertState).not.toHaveBeenCalled();
607
+ });
608
+
609
+ test("acknowledging does not look at the other incidents at all", async () => {
610
+ setSwitches(true, true);
611
+ link(CREATED_ALERT);
612
+ link(CREATED_ALERT, OPEN_INCIDENT_ID);
613
+
614
+ await cascade(INCIDENT_ACKNOWLEDGED);
615
+
616
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
617
+ // Only the incident's own links were read.
618
+ expect(linkLookup).toHaveBeenCalledTimes(1);
619
+ });
620
+ });
621
+
622
+ describe("how each alert is moved", () => {
623
+ test("as root, without notifying owners, with a cause that names the incident", async () => {
624
+ setSwitches(true, true);
625
+ link(CREATED_ALERT);
626
+
627
+ await cascade(INCIDENT_RESOLVED);
628
+
629
+ expect(changes).toEqual([
630
+ {
631
+ alertId: CREATED_ALERT,
632
+ alertStateId: ALERT_RESOLVED.id,
633
+ rootCause: "Resolved because linked Incident INC-42 was resolved.",
634
+ notifyOwners: false,
635
+ stateChangeLog: undefined,
636
+ props: { isRoot: true },
637
+ projectId: PROJECT_ID.toString(),
638
+ },
639
+ ]);
640
+ });
641
+
642
+ test("an incident with no prefixed number is named by #number", async () => {
643
+ setSwitches(true, false);
644
+ link(CREATED_ALERT);
645
+ jest
646
+ .spyOn(IncidentService, "findOneById")
647
+ .mockImplementation((async (): Promise<Incident> => {
648
+ const incident: Incident = new Incident();
649
+ incident.incidentNumber = 9;
650
+ return incident;
651
+ }) as never);
652
+
653
+ await cascade(INCIDENT_ACKNOWLEDGED);
654
+
655
+ expect(changes[0]!.rootCause).toBe(
656
+ "Acknowledged because linked Incident #9 was acknowledged.",
657
+ );
658
+ });
659
+
660
+ test("one alert failing does not stop the others", async () => {
661
+ setSwitches(true, false);
662
+ link(CREATED_ALERT);
663
+ link(SECOND_CREATED_ALERT);
664
+
665
+ const record: jest.SpyInstance = changeAlertState;
666
+ const recordChange: (data: never) => Promise<void> =
667
+ record.getMockImplementation() as (data: never) => Promise<void>;
668
+
669
+ changeAlertState.mockImplementation((async (data: {
670
+ alertId: ObjectID;
671
+ }): Promise<void> => {
672
+ if (data.alertId.toString() === CREATED_ALERT) {
673
+ throw new Error("Alert state cannot be same as previous state.");
674
+ }
675
+ return recordChange(data as never);
676
+ }) as never);
677
+
678
+ await expect(cascade(INCIDENT_ACKNOWLEDGED)).resolves.toBeUndefined();
679
+
680
+ expect(changeAlertState).toHaveBeenCalledTimes(2);
681
+ expect(moved()).toEqual([[SECOND_CREATED_ALERT, "Acknowledged"]]);
682
+ expect(errorLog).toHaveBeenCalled();
683
+ });
684
+
685
+ test("an alert whose current state is unknown is skipped and logged", async () => {
686
+ setSwitches(true, false);
687
+ tables.alerts.set(CREATED_ALERT, "0194b2f8-0000-4000-8000-0000000000ee");
688
+ link(CREATED_ALERT);
689
+ link(SECOND_CREATED_ALERT);
690
+
691
+ await cascade(INCIDENT_ACKNOWLEDGED);
692
+
693
+ expect(moved()).toEqual([[SECOND_CREATED_ALERT, "Acknowledged"]]);
694
+ expect(errorLog).toHaveBeenCalled();
695
+ });
696
+
697
+ test("an incident with no linked alerts reads no alerts", async () => {
698
+ setSwitches(true, true);
699
+
700
+ await cascade(INCIDENT_RESOLVED);
701
+
702
+ expect(alertLookup).not.toHaveBeenCalled();
703
+ expect(changeAlertState).not.toHaveBeenCalled();
704
+ });
705
+ });
706
+
707
+ describe("a project missing a state never throws", () => {
708
+ test("no Acknowledged alert state: logged, nothing moved", async () => {
709
+ setSwitches(true, false);
710
+ tables.alertStates = [ALERT_CREATED, ALERT_RESOLVED];
711
+ link(CREATED_ALERT);
712
+
713
+ await expect(cascade(INCIDENT_ACKNOWLEDGED)).resolves.toBeUndefined();
714
+
715
+ expect(changeAlertState).not.toHaveBeenCalled();
716
+ expect(errorLog).toHaveBeenCalled();
717
+ });
718
+
719
+ test("no Resolved alert state: logged, and the alerts are still acknowledged", async () => {
720
+ setSwitches(true, true);
721
+ tables.alertStates = [ALERT_CREATED, ALERT_ACKNOWLEDGED];
722
+ link(CREATED_ALERT);
723
+
724
+ await cascade(INCIDENT_RESOLVED);
725
+
726
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
727
+ expect(errorLog).toHaveBeenCalled();
728
+ });
729
+
730
+ test("no Acknowledged incident state: acknowledging never triggers", async () => {
731
+ setSwitches(true, false);
732
+ tables.incidentStates = [
733
+ INCIDENT_CREATED,
734
+ INCIDENT_MONITORING,
735
+ INCIDENT_RESOLVED,
736
+ ];
737
+ link(CREATED_ALERT);
738
+
739
+ await expect(cascade(INCIDENT_MONITORING)).resolves.toBeUndefined();
740
+
741
+ expect(changeAlertState).not.toHaveBeenCalled();
742
+ });
743
+
744
+ test("an incident state that is not in the project: logged, nothing moved", async () => {
745
+ setSwitches(true, true);
746
+ link(CREATED_ALERT);
747
+
748
+ await IncidentAlertService.cascadeIncidentStateToLinkedAlerts({
749
+ projectId: PROJECT_ID,
750
+ incidentId: INCIDENT_ID,
751
+ incidentStateId: new ObjectID("0194b2f8-0000-4000-8000-0000000000fe"),
752
+ });
753
+
754
+ expect(changeAlertState).not.toHaveBeenCalled();
755
+ expect(errorLog).toHaveBeenCalled();
756
+ });
757
+
758
+ test("a lookup that throws is logged, not thrown", async () => {
759
+ setSwitches(true, true);
760
+ link(CREATED_ALERT);
761
+ alertStateLookup.mockRejectedValue(
762
+ new Error("Acknowledged Alert State not found for this project") as never,
763
+ );
764
+
765
+ await expect(cascade(INCIDENT_RESOLVED)).resolves.toBeUndefined();
766
+
767
+ expect(changeAlertState).not.toHaveBeenCalled();
768
+ expect(errorLog).toHaveBeenCalled();
769
+ });
770
+
771
+ test("an incident that no longer exists is skipped", async () => {
772
+ setSwitches(true, true);
773
+ link(CREATED_ALERT);
774
+ jest.spyOn(IncidentService, "findOneById").mockResolvedValue(null as never);
775
+
776
+ await cascade(INCIDENT_RESOLVED);
777
+
778
+ expect(changeAlertState).not.toHaveBeenCalled();
779
+ });
780
+ });
781
+
782
+ describe("link-time sync: linking an alert to an incident that has moved on", () => {
783
+ async function sync(alertId: string): Promise<void> {
784
+ await IncidentAlertService.syncAlertWithLinkedIncidentState({
785
+ projectId: PROJECT_ID,
786
+ incidentId: INCIDENT_ID,
787
+ alertId: new ObjectID(alertId),
788
+ });
789
+ }
790
+
791
+ test("an alert linked to an acknowledged incident is acknowledged", async () => {
792
+ setSwitches(true, true);
793
+ tables.incidents.set(INCIDENT_ID.toString(), INCIDENT_ACKNOWLEDGED.id);
794
+ link(CREATED_ALERT);
795
+
796
+ await sync(CREATED_ALERT);
797
+
798
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
799
+ expect(changes[0]!.rootCause).toBe(
800
+ "Acknowledged because linked Incident INC-42 was acknowledged.",
801
+ );
802
+ expect(changes[0]!.notifyOwners).toBe(false);
803
+ expect(changes[0]!.props).toEqual({ isRoot: true });
804
+ });
805
+
806
+ test("an alert linked to a resolved incident is resolved", async () => {
807
+ setSwitches(true, true);
808
+ tables.incidents.set(INCIDENT_ID.toString(), INCIDENT_RESOLVED.id);
809
+ link(CREATED_ALERT);
810
+
811
+ await sync(CREATED_ALERT);
812
+
813
+ expect(moved()).toEqual([[CREATED_ALERT, "Resolved"]]);
814
+ });
815
+
816
+ test("only the alert that was linked is touched", async () => {
817
+ setSwitches(true, true);
818
+ tables.incidents.set(INCIDENT_ID.toString(), INCIDENT_RESOLVED.id);
819
+ link(CREATED_ALERT);
820
+ link(SECOND_CREATED_ALERT);
821
+
822
+ await sync(SECOND_CREATED_ALERT);
823
+
824
+ expect(moved()).toEqual([[SECOND_CREATED_ALERT, "Resolved"]]);
825
+ });
826
+
827
+ test("an alert still tracked by another open incident is only acknowledged", async () => {
828
+ setSwitches(true, true);
829
+ tables.incidents.set(INCIDENT_ID.toString(), INCIDENT_RESOLVED.id);
830
+ link(CREATED_ALERT);
831
+ link(CREATED_ALERT, OPEN_INCIDENT_ID);
832
+
833
+ await sync(CREATED_ALERT);
834
+
835
+ expect(moved()).toEqual([[CREATED_ALERT, "Acknowledged"]]);
836
+ });
837
+
838
+ test("an incident in its created state (a newly declared one) changes nothing", async () => {
839
+ setSwitches(true, true);
840
+ link(CREATED_ALERT);
841
+
842
+ await sync(CREATED_ALERT);
843
+
844
+ expect(changeAlertState).not.toHaveBeenCalled();
845
+ expect(alertLookup).not.toHaveBeenCalled();
846
+ });
847
+
848
+ test("both switches off: the incident is not even read", async () => {
849
+ setSwitches(false, false);
850
+ const incidentRead: jest.SpyInstance = jest.spyOn(
851
+ IncidentService,
852
+ "findOneById",
853
+ );
854
+
855
+ await sync(CREATED_ALERT);
856
+
857
+ expect(incidentRead).not.toHaveBeenCalled();
858
+ expect(changeAlertState).not.toHaveBeenCalled();
859
+ });
860
+
861
+ test("an incident that cannot be read is skipped", async () => {
862
+ setSwitches(true, true);
863
+ jest.spyOn(IncidentService, "findOneById").mockResolvedValue(null as never);
864
+
865
+ await expect(sync(CREATED_ALERT)).resolves.toBeUndefined();
866
+
867
+ expect(changeAlertState).not.toHaveBeenCalled();
868
+ });
869
+
870
+ test("a failure is logged, never thrown", async () => {
871
+ setSwitches(true, true);
872
+ tables.incidents.set(INCIDENT_ID.toString(), INCIDENT_RESOLVED.id);
873
+ alertLookup.mockRejectedValue(new Error("database unavailable") as never);
874
+
875
+ await expect(sync(CREATED_ALERT)).resolves.toBeUndefined();
876
+
877
+ expect(errorLog).toHaveBeenCalled();
878
+ });
879
+ });
880
+
881
+ describe("the decision, as pure functions", () => {
882
+ test.each([
883
+ // [incident order, ack switch, resolve switch, expected]
884
+ [1, true, true, { acknowledge: false, resolve: false, reached: false }],
885
+ [2, true, true, { acknowledge: true, resolve: false, reached: false }],
886
+ [3, true, true, { acknowledge: true, resolve: false, reached: false }],
887
+ [4, true, true, { acknowledge: true, resolve: true, reached: true }],
888
+ [4, true, false, { acknowledge: true, resolve: false, reached: true }],
889
+ [4, false, true, { acknowledge: false, resolve: true, reached: true }],
890
+ [4, false, false, { acknowledge: false, resolve: false, reached: true }],
891
+ [2, false, true, { acknowledge: false, resolve: false, reached: false }],
892
+ ])(
893
+ "an incident at order %i with ack=%s resolve=%s",
894
+ (
895
+ incidentStateOrder: number,
896
+ acknowledgeSwitch: boolean,
897
+ resolveSwitch: boolean,
898
+ expected: { acknowledge: boolean; resolve: boolean; reached: boolean },
899
+ ) => {
900
+ const targets: LinkedAlertStateTargets = getLinkedAlertStateTargets({
901
+ incidentStateOrder: incidentStateOrder,
902
+ acknowledgedIncidentStateOrder: 2,
903
+ resolvedIncidentStateOrder: 4,
904
+ acknowledgeSwitch: acknowledgeSwitch,
905
+ resolveSwitch: resolveSwitch,
906
+ });
907
+
908
+ expect(targets).toEqual({
909
+ acknowledge: expected.acknowledge,
910
+ resolve: expected.resolve,
911
+ incidentReachedResolved: expected.reached,
912
+ });
913
+ },
914
+ );
915
+
916
+ test("a project without the incident state never reaches it", () => {
917
+ expect(
918
+ getLinkedAlertStateTargets({
919
+ incidentStateOrder: 99,
920
+ acknowledgedIncidentStateOrder: undefined,
921
+ resolvedIncidentStateOrder: undefined,
922
+ acknowledgeSwitch: true,
923
+ resolveSwitch: true,
924
+ }),
925
+ ).toEqual({
926
+ acknowledge: false,
927
+ resolve: false,
928
+ incidentReachedResolved: false,
929
+ });
930
+ });
931
+
932
+ const ack: { name: string; order: number } = { name: "ack", order: 2 };
933
+ const resolved: { name: string; order: number } = {
934
+ name: "resolved",
935
+ order: 4,
936
+ };
937
+
938
+ test.each([
939
+ // [alert order, ack target, resolve target, blocked, expected]
940
+ [1, ack, resolved, false, "resolved"],
941
+ [1, ack, resolved, true, "ack"],
942
+ [3, ack, resolved, true, null],
943
+ [3, ack, resolved, false, "resolved"],
944
+ [4, ack, resolved, false, null],
945
+ [5, ack, resolved, false, null],
946
+ [1, ack, undefined, false, "ack"],
947
+ [2, ack, undefined, false, null],
948
+ [1, undefined, resolved, true, null],
949
+ [1, undefined, undefined, false, null],
950
+ ])(
951
+ "an alert at order %i",
952
+ (
953
+ alertStateOrder: number,
954
+ acknowledgedAlertState: { name: string; order: number } | undefined,
955
+ resolvedAlertState: { name: string; order: number } | undefined,
956
+ isBlockedFromResolve: boolean,
957
+ expected: string | null,
958
+ ) => {
959
+ const target: { name: string; order: number } | null =
960
+ chooseLinkedAlertTargetState({
961
+ alertStateOrder: alertStateOrder,
962
+ acknowledgedAlertState: acknowledgedAlertState,
963
+ resolvedAlertState: resolvedAlertState,
964
+ isBlockedFromResolve: isBlockedFromResolve,
965
+ });
966
+
967
+ expect(target?.name ?? null).toBe(expected);
968
+ },
969
+ );
970
+ });