@oneuptime/common 14.0.5 → 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 (192) 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/MetricService.ts +1 -1
  10. package/Server/Services/ScheduledMaintenanceService.ts +50 -46
  11. package/Server/Services/UserNotificationRuleService.ts +55 -53
  12. package/Server/Services/WorkspaceNotificationSummaryService.ts +38 -20
  13. package/Server/Types/Database/QueryHelper.ts +54 -1
  14. package/Server/Utils/AI/Toolbox/MetricTools.ts +13 -2
  15. package/Server/Utils/AI/Toolbox/ResourceTools.ts +12 -1
  16. package/Server/Utils/AffectedResources/LinkedAffectedResources.ts +576 -0
  17. package/Server/Utils/Alert/AlertStateChangeAuthorization.ts +129 -0
  18. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +1 -1
  19. package/Server/Utils/Inventory/InventoryOverviewAggregation.ts +111 -0
  20. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +33 -0
  21. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +4 -3
  22. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +3 -1
  23. package/Server/Utils/Response.ts +31 -0
  24. package/Server/Utils/Topology/TopologyConcurrencyLimiter.ts +229 -0
  25. package/Server/Utils/Topology/TopologyQueries.ts +1350 -0
  26. package/Server/Utils/Topology/TopologyRequest.ts +463 -0
  27. package/Server/Utils/Topology/TopologyResponseCache.ts +183 -0
  28. package/Server/Utils/Topology/TopologySql.ts +1024 -0
  29. package/Tests/App/Dashboard/AcknowledgeAlertsOnDeclare.test.ts +369 -0
  30. package/Tests/App/Dashboard/AffectedResourcesSlo.test.tsx +1 -1
  31. package/Tests/App/Dashboard/AffectedResourcesSummaryStep.test.tsx +510 -123
  32. package/Tests/App/Dashboard/AlertsTableAffectedResources.test.tsx +490 -0
  33. package/Tests/App/Dashboard/ChangeAlertStateDeclareIncident.test.tsx +886 -0
  34. package/Tests/App/Dashboard/CreateReviewStepRoleSummary.test.tsx +1250 -0
  35. package/Tests/App/Dashboard/CreateReviewStepSelectionSummary.test.tsx +738 -0
  36. package/Tests/App/Dashboard/DeclareIncidentFromAlert.test.ts +417 -0
  37. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +1948 -229
  38. package/Tests/App/Dashboard/EntityDetailPanelLifecycle.test.tsx +493 -0
  39. package/Tests/App/Dashboard/EntityDetailPanelNavigation.test.tsx +439 -0
  40. package/Tests/App/Dashboard/EventOverviewPages.test.tsx +292 -1
  41. package/Tests/App/Dashboard/EventStatusPanel.test.tsx +798 -2
  42. package/Tests/App/Dashboard/FetchStateAndSeverityElements.test.tsx +669 -0
  43. package/Tests/App/Dashboard/FetchUsers.test.tsx +230 -0
  44. package/Tests/App/Dashboard/IncidentCreateAcknowledgeAlerts.test.tsx +1611 -0
  45. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +1365 -57
  46. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +964 -23
  47. package/Tests/App/Dashboard/InventoryRelationships.test.tsx +885 -0
  48. package/Tests/App/Dashboard/NetworkTopologyExplorerSearch.test.tsx +1267 -0
  49. package/Tests/App/Dashboard/ResourceOverviewTileTooltips.test.tsx +4 -1
  50. package/Tests/App/Dashboard/RumOverviewPage.test.tsx +127 -0
  51. package/Tests/App/Dashboard/ServiceMapDatabaseLink.test.tsx +201 -15
  52. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +508 -36
  53. package/Tests/App/Dashboard/SiteSearchBox.test.tsx +2110 -0
  54. package/Tests/App/Dashboard/TelemetryMetricsSignals.test.ts +168 -8
  55. package/Tests/App/Dashboard/TopologyDataLifecycle.test.tsx +583 -0
  56. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +982 -218
  57. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +818 -114
  58. package/Tests/Server/Infrastructure/Postgres/RenameInventoryItemMigration.test.ts +90 -21
  59. package/Tests/Server/Services/DomainService.test.ts +525 -0
  60. package/Tests/Server/Services/IncidentAlertAcknowledgeOnDeclare.test.ts +2630 -0
  61. package/Tests/Server/Services/IncidentAlertPostgres.test.ts +1625 -1
  62. package/Tests/Server/Services/IncidentDeclareAcknowledgeAlerts.test.ts +2189 -0
  63. package/Tests/Server/Services/InventoryItemServiceOverviewCounts.test.ts +345 -0
  64. package/Tests/Server/Services/InventoryOverviewPostgres.test.ts +254 -0
  65. package/Tests/Server/Services/IoTDeviceCredentialService.test.ts +819 -0
  66. package/Tests/Server/Services/LinkedAffectedResourcesCreatedFeed.test.ts +420 -0
  67. package/Tests/Server/Services/MetricServiceAggregate.test.ts +12 -0
  68. package/Tests/Server/Services/SavedViewDefaults.test.ts +325 -0
  69. package/Tests/Server/Services/SloAffectedResourceCreatedFeed.test.ts +92 -36
  70. package/Tests/Server/Services/StatusPageCertificateService.test.ts +169 -0
  71. package/Tests/Server/Services/TeamComplianceSettingService.test.ts +191 -0
  72. package/Tests/Server/Types/Database/QueryHelperSearchAllWords.test.ts +651 -0
  73. package/Tests/Server/Utils/AI/AlertWriteTools.test.ts +256 -0
  74. package/Tests/Server/Utils/AI/IncidentWriteTools.test.ts +482 -0
  75. package/Tests/Server/Utils/AffectedResources/LinkedAffectedResources.test.ts +858 -0
  76. package/Tests/Server/Utils/Alert/AlertStateChangeAuthorization.test.ts +1172 -0
  77. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +54 -0
  78. package/Tests/Server/Utils/Monitor/MetricDecimalThreshold.test.ts +417 -0
  79. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorAffectedResourceEdgeCases.test.ts +96 -0
  80. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigration.test.ts +386 -0
  81. package/Tests/Server/Utils/Topology/InventoryItemEntityKeyIndexMigrationPostgres.test.ts +430 -0
  82. package/Tests/Server/Utils/Topology/TopologyActivityParityPostgres.test.ts +279 -0
  83. package/Tests/Server/Utils/Topology/TopologyConcurrencyLimiter.test.ts +534 -0
  84. package/Tests/Server/Utils/Topology/TopologyEstateGenerator.ts +624 -0
  85. package/Tests/Server/Utils/Topology/TopologyJsonResponse.test.ts +90 -0
  86. package/Tests/Server/Utils/Topology/TopologyQueries.test.ts +1634 -0
  87. package/Tests/Server/Utils/Topology/TopologyQueriesDifferentialPostgres.test.ts +683 -0
  88. package/Tests/Server/Utils/Topology/TopologyQueriesPostgres.test.ts +2624 -0
  89. package/Tests/Server/Utils/Topology/TopologyReference.ts +1018 -0
  90. package/Tests/Server/Utils/Topology/TopologyRequest.test.ts +643 -0
  91. package/Tests/Server/Utils/Topology/TopologyResponseCache.test.ts +305 -0
  92. package/Tests/Server/Utils/Topology/TopologySql.test.ts +973 -0
  93. package/Tests/Types/BaseDatabase/AggregationType.test.ts +5 -1
  94. package/Tests/Types/Dashboard/KubernetesDashboardTemplate.test.ts +184 -0
  95. package/Tests/Types/Dashboard/KubernetesDashboardTemplates.test.ts +107 -0
  96. package/Tests/Types/Monitor/KubernetesAlertTemplates.test.ts +8 -14
  97. package/Tests/Types/Monitor/KubernetesMetricCatalog.test.ts +4 -3
  98. package/Tests/Types/Monitor/Utils/KubernetesAgentEmittedMetrics.ts +27 -6
  99. package/Tests/Types/Topology/TopologyTypeRules.test.ts +176 -0
  100. package/Tests/UI/Rum/ChunkLoader.test.ts +76 -0
  101. package/Tests/UI/Rum/ReplayRailDetail.test.tsx +53 -0
  102. package/Tests/UI/Rum/ReplaySignals.test.ts +54 -0
  103. package/Tests/Utils/Dashboard/KubernetesClusterCpuTileRepair.test.ts +370 -0
  104. package/Tests/Utils/Incident/IncidentFromAlerts.test.ts +302 -0
  105. package/Tests/Utils/Rules/MonitorTypeRuleCriteria.test.ts +181 -0
  106. package/Types/BaseDatabase/AggregationType.ts +7 -0
  107. package/Types/Dashboard/DashboardTemplates.ts +20 -7
  108. package/Types/Incident/IncidentAlertLink.ts +19 -0
  109. package/Types/Rum/SessionReplayCustomEvents.ts +29 -3
  110. package/Types/Rum/WebVitals.ts +13 -0
  111. package/Types/Telemetry/InventoryLiveness.ts +26 -0
  112. package/Types/Topology/TopologyApi.ts +428 -0
  113. package/Types/Topology/TopologyTypeRules.ts +191 -0
  114. package/UI/Components/Input/Input.tsx +11 -0
  115. package/Utils/Dashboard/KubernetesClusterCpuTileRepair.ts +147 -0
  116. package/Utils/Incident/IncidentFromAlerts.ts +78 -0
  117. package/build/dist/Models/DatabaseModels/InventoryItem.js +20 -11
  118. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js +165 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1795200000000-AddInventoryItemProjectEntityKeyIndex.js.map +1 -0
  121. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  122. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  123. package/build/dist/Server/Services/AlertService.js +21 -33
  124. package/build/dist/Server/Services/AlertService.js.map +1 -1
  125. package/build/dist/Server/Services/IncidentAlertService.js +457 -3
  126. package/build/dist/Server/Services/IncidentAlertService.js.map +1 -1
  127. package/build/dist/Server/Services/IncidentService.js +96 -42
  128. package/build/dist/Server/Services/IncidentService.js.map +1 -1
  129. package/build/dist/Server/Services/InventoryItemService.js +31 -0
  130. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  131. package/build/dist/Server/Services/IoTDeviceCredentialService.js +12 -5
  132. package/build/dist/Server/Services/IoTDeviceCredentialService.js.map +1 -1
  133. package/build/dist/Server/Services/MetricService.js +1 -1
  134. package/build/dist/Server/Services/ScheduledMaintenanceService.js +37 -40
  135. package/build/dist/Server/Services/ScheduledMaintenanceService.js.map +1 -1
  136. package/build/dist/Server/Services/UserNotificationRuleService.js +55 -51
  137. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  138. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js +42 -25
  139. package/build/dist/Server/Services/WorkspaceNotificationSummaryService.js.map +1 -1
  140. package/build/dist/Server/Types/Database/QueryHelper.js +47 -1
  141. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  142. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js +13 -2
  143. package/build/dist/Server/Utils/AI/Toolbox/MetricTools.js.map +1 -1
  144. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js +12 -1
  145. package/build/dist/Server/Utils/AI/Toolbox/ResourceTools.js.map +1 -1
  146. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js +405 -0
  147. package/build/dist/Server/Utils/AffectedResources/LinkedAffectedResources.js.map +1 -0
  148. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js +112 -0
  149. package/build/dist/Server/Utils/Alert/AlertStateChangeAuthorization.js.map +1 -0
  150. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +1 -1
  151. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js +81 -0
  152. package/build/dist/Server/Utils/Inventory/InventoryOverviewAggregation.js.map +1 -0
  153. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +31 -0
  154. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  155. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +1 -1
  156. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  157. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +1 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  159. package/build/dist/Server/Utils/Response.js +30 -0
  160. package/build/dist/Server/Utils/Response.js.map +1 -1
  161. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js +178 -0
  162. package/build/dist/Server/Utils/Topology/TopologyConcurrencyLimiter.js.map +1 -0
  163. package/build/dist/Server/Utils/Topology/TopologyQueries.js +884 -0
  164. package/build/dist/Server/Utils/Topology/TopologyQueries.js.map +1 -0
  165. package/build/dist/Server/Utils/Topology/TopologyRequest.js +275 -0
  166. package/build/dist/Server/Utils/Topology/TopologyRequest.js.map +1 -0
  167. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js +146 -0
  168. package/build/dist/Server/Utils/Topology/TopologyResponseCache.js.map +1 -0
  169. package/build/dist/Server/Utils/Topology/TopologySql.js +688 -0
  170. package/build/dist/Server/Utils/Topology/TopologySql.js.map +1 -0
  171. package/build/dist/Types/BaseDatabase/AggregationType.js +7 -0
  172. package/build/dist/Types/BaseDatabase/AggregationType.js.map +1 -1
  173. package/build/dist/Types/Dashboard/DashboardTemplates.js +20 -7
  174. package/build/dist/Types/Dashboard/DashboardTemplates.js.map +1 -1
  175. package/build/dist/Types/Incident/IncidentAlertLink.js +17 -0
  176. package/build/dist/Types/Incident/IncidentAlertLink.js.map +1 -1
  177. package/build/dist/Types/Rum/SessionReplayCustomEvents.js.map +1 -1
  178. package/build/dist/Types/Rum/WebVitals.js +12 -0
  179. package/build/dist/Types/Rum/WebVitals.js.map +1 -1
  180. package/build/dist/Types/Telemetry/InventoryLiveness.js +23 -0
  181. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -1
  182. package/build/dist/Types/Topology/TopologyApi.js +55 -0
  183. package/build/dist/Types/Topology/TopologyApi.js.map +1 -0
  184. package/build/dist/Types/Topology/TopologyTypeRules.js +165 -0
  185. package/build/dist/Types/Topology/TopologyTypeRules.js.map +1 -0
  186. package/build/dist/UI/Components/Input/Input.js +1 -1
  187. package/build/dist/UI/Components/Input/Input.js.map +1 -1
  188. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js +110 -0
  189. package/build/dist/Utils/Dashboard/KubernetesClusterCpuTileRepair.js.map +1 -0
  190. package/build/dist/Utils/Incident/IncidentFromAlerts.js +38 -0
  191. package/build/dist/Utils/Incident/IncidentFromAlerts.js.map +1 -1
  192. package/package.json +1 -1
@@ -0,0 +1,1250 @@
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
+ render,
14
+ RenderResult,
15
+ screen,
16
+ waitFor,
17
+ within,
18
+ } from "@testing-library/react";
19
+ import * as React from "react";
20
+ import { MemoryRouter } from "react-router-dom";
21
+ import getJestMockFunction, { MockFunction } from "../../MockType";
22
+
23
+ /*
24
+ * The incident and episode create wizards have a step that assigns people to
25
+ * incident roles, and end on a review step that lists what each step picked.
26
+ * The roles editor hands the page only ids - a role's id and the ids of the
27
+ * users assigned to it - and the review step used to boil them down to one
28
+ * sentence, "3 users assigned to 2 roles.", naming neither the roles nor the
29
+ * people. Make Alice the Incident Commander and Bob the Scribe, and the one
30
+ * screen meant to confirm it said "2 users assigned to 2 roles."
31
+ *
32
+ * The review step now looks the roles up in one request and lists each one in
33
+ * its color, with the people assigned to it beneath. These tests drive both
34
+ * real pages with ModelForm mocked to capture the fields it is handed (as
35
+ * CreateReviewStepSelectionSummary.test.tsx does), assign roles the way the
36
+ * editor does - through the onChange of the element the roles field builds,
37
+ * the only thing that fills the ref the summary reads - and render the
38
+ * summary against a ModelAPI that knows a few roles and users by id. They
39
+ * check what a user reads there, which person is listed under which role, and
40
+ * what is asked of the API.
41
+ */
42
+
43
+ jest.mock("react-i18next", () => {
44
+ return {
45
+ useTranslation: () => {
46
+ return {
47
+ t: (key: string, options?: { defaultValue?: string }): string => {
48
+ return options?.defaultValue ?? key;
49
+ },
50
+ };
51
+ },
52
+ };
53
+ });
54
+
55
+ type SummaryItem = Record<string, unknown>;
56
+
57
+ type EditorElementProps = {
58
+ onChange?: ((value: unknown) => void) | undefined;
59
+ };
60
+
61
+ type CapturedField = {
62
+ field?: Record<string, unknown> | undefined;
63
+ overrideField?: Record<string, unknown> | undefined;
64
+ overrideFieldKey?: string | undefined;
65
+ getCustomElement?:
66
+ | ((value: SummaryItem, props: EditorElementProps) => React.ReactElement)
67
+ | undefined;
68
+ getSummaryElement?:
69
+ | ((item: SummaryItem) => React.ReactElement | undefined)
70
+ | undefined;
71
+ };
72
+
73
+ type CapturedFormProps = {
74
+ fields: Array<CapturedField>;
75
+ };
76
+
77
+ let capturedForm: CapturedFormProps | null = null;
78
+
79
+ jest.mock("../../../UI/Components/Forms/ModelForm", () => {
80
+ // Only the component is stubbed: the pages import FormType from here too.
81
+ const actual: Record<string, unknown> = jest.requireActual(
82
+ "../../../UI/Components/Forms/ModelForm",
83
+ ) as Record<string, unknown>;
84
+
85
+ return {
86
+ __esModule: true,
87
+ ...actual,
88
+ default: (props: CapturedFormProps): React.ReactElement => {
89
+ capturedForm = props;
90
+ return <div data-testid="model-form" />;
91
+ },
92
+ };
93
+ });
94
+
95
+ const getListMock: MockFunction = getJestMockFunction();
96
+ const getItemMock: MockFunction = getJestMockFunction();
97
+
98
+ /*
99
+ * The arrow wrappers are load bearing: jest.mock is hoisted above the
100
+ * compiled requires, so the mocks are still unassigned when the factory runs.
101
+ */
102
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
103
+ return {
104
+ __esModule: true,
105
+ default: {
106
+ getList: (...args: Array<unknown>) => {
107
+ return getListMock(...args);
108
+ },
109
+ getItem: (...args: Array<unknown>) => {
110
+ return getItemMock(...args);
111
+ },
112
+ },
113
+ };
114
+ });
115
+
116
+ import IncidentRoleFormField, {
117
+ RoleAssignment,
118
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/Incident/IncidentRoleFormField";
119
+ import IncidentEpisodeRoleFormField from "../../../../App/FeatureSet/Dashboard/src/Components/IncidentEpisode/IncidentEpisodeRoleFormField";
120
+ import FetchIncidentRoleAssignments from "../../../../App/FeatureSet/Dashboard/src/Components/IncidentRole/FetchIncidentRoleAssignments";
121
+ import IncidentCreate from "../../../../App/FeatureSet/Dashboard/src/Pages/Incidents/Create";
122
+ import IncidentEpisodeCreate from "../../../../App/FeatureSet/Dashboard/src/Pages/Incidents/EpisodeCreate";
123
+ import PageComponentProps from "../../../../App/FeatureSet/Dashboard/src/Pages/PageComponentProps";
124
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
125
+ import IncidentRole from "../../../Models/DatabaseModels/IncidentRole";
126
+ import IncidentState from "../../../Models/DatabaseModels/IncidentState";
127
+ import Project from "../../../Models/DatabaseModels/Project";
128
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
129
+ import User from "../../../Models/DatabaseModels/User";
130
+ import Route from "../../../Types/API/Route";
131
+ import Includes from "../../../Types/BaseDatabase/Includes";
132
+ import Color, { RGB } from "../../../Types/Color";
133
+ import Email from "../../../Types/Email";
134
+ import Name from "../../../Types/Name";
135
+ import ObjectID from "../../../Types/ObjectID";
136
+ import Navigation from "../../../UI/Utils/Navigation";
137
+ import ProjectUtil from "../../../UI/Utils/Project";
138
+
139
+ const PROJECT_ID: string = "3b1d7c2e-5f4a-4e6b-9c8d-7a6f5e4d3c2b";
140
+
141
+ // What the episode page preselects on mount: the first state by order.
142
+ const FIRST_INCIDENT_STATE_ID: string = "11111111-1111-4111-8111-000000000002";
143
+
144
+ type RoleFixture = {
145
+ id: string;
146
+ name: string;
147
+ color: string;
148
+ };
149
+
150
+ type UserFixture = {
151
+ id: string;
152
+ name: string;
153
+ email: string;
154
+ };
155
+
156
+ const COMMUNICATIONS_LEAD: RoleFixture = {
157
+ id: "44444444-4444-4444-8444-000000000001",
158
+ name: "Communications Lead",
159
+ color: "#0ea5e9",
160
+ };
161
+
162
+ const INCIDENT_COMMANDER: RoleFixture = {
163
+ id: "44444444-4444-4444-8444-000000000002",
164
+ name: "Incident Commander",
165
+ color: "#7c3aed",
166
+ };
167
+
168
+ const SCRIBE: RoleFixture = {
169
+ id: "44444444-4444-4444-8444-000000000003",
170
+ name: "Scribe",
171
+ color: "#16a34a",
172
+ };
173
+
174
+ /*
175
+ * The roles the API knows, in the order it answers them: the lookup sorts by
176
+ * name, so this is not the order the tests assign them in.
177
+ */
178
+ const ROLE_FIXTURES: Array<RoleFixture> = [
179
+ COMMUNICATIONS_LEAD,
180
+ INCIDENT_COMMANDER,
181
+ SCRIBE,
182
+ ];
183
+
184
+ // A role that was assigned in the editor and deleted before the review step.
185
+ const DELETED_ROLE_ID: string = "44444444-4444-4444-8444-0000000000ff";
186
+
187
+ const ALICE: UserFixture = {
188
+ id: "55555555-5555-4555-8555-000000000001",
189
+ name: "Alice Nakamura",
190
+ email: "alice@example.com",
191
+ };
192
+
193
+ const BOB: UserFixture = {
194
+ id: "55555555-5555-4555-8555-000000000002",
195
+ name: "Bob Okafor",
196
+ email: "bob@example.com",
197
+ };
198
+
199
+ const CAROL: UserFixture = {
200
+ id: "55555555-5555-4555-8555-000000000003",
201
+ name: "Carol Mendes",
202
+ email: "carol@example.com",
203
+ };
204
+
205
+ const DEV: UserFixture = {
206
+ id: "55555555-5555-4555-8555-000000000004",
207
+ name: "Dev Patel",
208
+ email: "dev@example.com",
209
+ };
210
+
211
+ const USER_FIXTURES: Array<UserFixture> = [ALICE, BOB, CAROL, DEV];
212
+
213
+ function assignmentOf(
214
+ roleId: string,
215
+ users: Array<UserFixture>,
216
+ ): RoleAssignment {
217
+ return {
218
+ roleId: roleId,
219
+ userIds: users.map((user: UserFixture): string => {
220
+ return user.id;
221
+ }),
222
+ };
223
+ }
224
+
225
+ /*
226
+ * Two people under one role, so a list that drops or regroups one of them
227
+ * shows, and roles assigned in an order that is not the API's name order.
228
+ */
229
+ const ASSIGNMENTS: Array<RoleAssignment> = [
230
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE, BOB]),
231
+ assignmentOf(COMMUNICATIONS_LEAD.id, [CAROL]),
232
+ assignmentOf(SCRIBE.id, [DEV]),
233
+ ];
234
+
235
+ enum LookupAnswer {
236
+ Found = "found",
237
+ Failed = "failed",
238
+ }
239
+
240
+ // How the role lookup answers; the user lookups always answer.
241
+ let roleLookupAnswer: LookupAnswer = LookupAnswer.Found;
242
+
243
+ const LOOKUP_FAILURE_MESSAGE: string = "Could not reach the server right now.";
244
+
245
+ const ROLE_NOT_FOUND_MESSAGE: string = "The selected role could not be found.";
246
+
247
+ // The shape of the sentence this replaced: "3 users assigned to 2 roles."
248
+ const COUNT_SENTENCE_PATTERNS: Array<RegExp> = [
249
+ /assigned to/i,
250
+ /\d+ users?\b/i,
251
+ ];
252
+
253
+ type ListRequest = {
254
+ modelType: unknown;
255
+ query: Record<string, unknown>;
256
+ select: Record<string, unknown>;
257
+ };
258
+
259
+ type ItemRequest = {
260
+ modelType: unknown;
261
+ id: ObjectID;
262
+ select: Record<string, unknown>;
263
+ };
264
+
265
+ type ListAnswer = {
266
+ data: Array<BaseModel>;
267
+ count: number;
268
+ skip: number;
269
+ limit: number;
270
+ };
271
+
272
+ function listOf(data: Array<BaseModel>): ListAnswer {
273
+ return { data: data, count: data.length, skip: 0, limit: data.length };
274
+ }
275
+
276
+ function makeRole(fixture: RoleFixture): IncidentRole {
277
+ const role: IncidentRole = new IncidentRole();
278
+ role._id = fixture.id;
279
+ role.name = fixture.name;
280
+ role.color = new Color(fixture.color);
281
+ return role;
282
+ }
283
+
284
+ function makeUser(fixture: UserFixture): User {
285
+ const user: User = new User();
286
+ user._id = fixture.id;
287
+ user.name = new Name(fixture.name);
288
+ user.email = new Email(fixture.email);
289
+ return user;
290
+ }
291
+
292
+ function rolesAnswer(fixtures: Array<RoleFixture>): ListAnswer {
293
+ return listOf(fixtures.map(makeRole));
294
+ }
295
+
296
+ // The ids a lookup asks for: roles by `_id`, people through TeamMember's `userId`.
297
+ function idsIn(query: Record<string, unknown>): Array<string> | null {
298
+ const idQuery: unknown = query["_id"] ?? query["userId"];
299
+
300
+ if (!(idQuery instanceof Includes)) {
301
+ return null;
302
+ }
303
+
304
+ return idQuery.values.map((value: string | ObjectID | number): string => {
305
+ return value.toString();
306
+ });
307
+ }
308
+
309
+ async function answerList(request: ListRequest): Promise<ListAnswer> {
310
+ const ids: Array<string> | null = idsIn(request.query);
311
+
312
+ // A lookup of roles by ids: ids the API does not know are simply absent.
313
+ if (request.modelType === IncidentRole && ids) {
314
+ if (roleLookupAnswer === LookupAnswer.Failed) {
315
+ throw new Error(LOOKUP_FAILURE_MESSAGE);
316
+ }
317
+
318
+ return rolesAnswer(
319
+ ROLE_FIXTURES.filter((fixture: RoleFixture): boolean => {
320
+ return ids.includes(fixture.id);
321
+ }),
322
+ );
323
+ }
324
+
325
+ /*
326
+ * People are looked up through TeamMember, the project-scoped link to User:
327
+ * the server refuses a list of other users by id from the User model
328
+ * itself. A person in several teams comes back once per team - Alice is in
329
+ * two - so a summary that lists every row would name her twice.
330
+ */
331
+ if (request.modelType === TeamMember && ids) {
332
+ return listOf(
333
+ USER_FIXTURES.filter((fixture: UserFixture): boolean => {
334
+ return ids.includes(fixture.id);
335
+ }).flatMap((fixture: UserFixture): Array<TeamMember> => {
336
+ return Array.from(
337
+ { length: fixture.id === ALICE.id ? 2 : 1 },
338
+ (): TeamMember => {
339
+ const teamMember: TeamMember = new TeamMember();
340
+ teamMember._id = ObjectID.generate().toString();
341
+ teamMember.user = makeUser(fixture);
342
+ return teamMember;
343
+ },
344
+ );
345
+ }),
346
+ );
347
+ }
348
+
349
+ // The episode page holds a loader up until this lookup lands.
350
+ if (request.modelType === IncidentState) {
351
+ const firstState: IncidentState = new IncidentState();
352
+ firstState._id = FIRST_INCIDENT_STATE_ID;
353
+ firstState.name = "Created";
354
+ firstState.color = new Color("#ef4444");
355
+ return listOf([firstState]);
356
+ }
357
+
358
+ // Labels, teams, severities and the rest: nothing to show.
359
+ return listOf([]);
360
+ }
361
+
362
+ type Lookup = {
363
+ modelType: unknown;
364
+ ids: Array<string>;
365
+ select: unknown;
366
+ };
367
+
368
+ /*
369
+ * Every request made since the last clear, whichever of getItem or getList
370
+ * carried it, as the model type, ids and columns it asked for.
371
+ */
372
+ function lookupsMade(): Array<Lookup> {
373
+ const itemLookups: Array<Lookup> = getItemMock.mock.calls.map(
374
+ (call: Array<unknown>): Lookup => {
375
+ const request: ItemRequest = call[0] as ItemRequest;
376
+ return {
377
+ modelType: request.modelType,
378
+ ids: [request.id.toString()],
379
+ select: request.select,
380
+ };
381
+ },
382
+ );
383
+
384
+ const listLookups: Array<Lookup> = getListMock.mock.calls.map(
385
+ (call: Array<unknown>): Lookup => {
386
+ const request: ListRequest = call[0] as ListRequest;
387
+ return {
388
+ modelType: request.modelType,
389
+ ids: idsIn(request.query) || [],
390
+ select: request.select,
391
+ };
392
+ },
393
+ );
394
+
395
+ return [...itemLookups, ...listLookups];
396
+ }
397
+
398
+ function lookupsOf(modelType: unknown): Array<Lookup> {
399
+ return lookupsMade().filter((lookup: Lookup): boolean => {
400
+ return lookup.modelType === modelType;
401
+ });
402
+ }
403
+
404
+ // Id lists compared as sets: the order ids are asked for in is not the point.
405
+ function asIdSet(ids: Array<string>): string {
406
+ return [...ids]
407
+ .sort((left: string, right: string): number => {
408
+ return left.localeCompare(right);
409
+ })
410
+ .join(",");
411
+ }
412
+
413
+ function asIdSets(idLists: Array<Array<string>>): Array<string> {
414
+ return idLists.map(asIdSet).sort((left: string, right: string): number => {
415
+ return left.localeCompare(right);
416
+ });
417
+ }
418
+
419
+ type PageUnderTest = {
420
+ name: string;
421
+ component: React.FunctionComponent<PageComponentProps>;
422
+ route: string;
423
+ rolesFieldKey: string;
424
+ editorType: unknown;
425
+ emptyMessage: string;
426
+ };
427
+
428
+ const INCIDENT_CREATE: PageUnderTest = {
429
+ name: "incident create",
430
+ component: IncidentCreate,
431
+ route: "/dashboard/incidents/create",
432
+ rolesFieldKey: "incidentRoles",
433
+ editorType: IncidentRoleFormField,
434
+ emptyMessage: "No incident roles assigned.",
435
+ };
436
+
437
+ const INCIDENT_EPISODE_CREATE: PageUnderTest = {
438
+ name: "incident episode create",
439
+ component: IncidentEpisodeCreate,
440
+ route: "/dashboard/incidents/episodes/create",
441
+ rolesFieldKey: "episodeRoles",
442
+ editorType: IncidentEpisodeRoleFormField,
443
+ emptyMessage: "No episode roles assigned.",
444
+ };
445
+
446
+ const PAGES: Array<PageUnderTest> = [INCIDENT_CREATE, INCIDENT_EPISODE_CREATE];
447
+
448
+ async function openForm(page: PageUnderTest): Promise<Array<CapturedField>> {
449
+ render(
450
+ <MemoryRouter>
451
+ <page.component
452
+ pageRoute={new Route(page.route)}
453
+ currentProject={new Project()}
454
+ hasPaymentMethod={true}
455
+ />
456
+ </MemoryRouter>,
457
+ );
458
+
459
+ // The episode page holds a loader up until its first-state lookup lands.
460
+ await waitFor(() => {
461
+ expect(capturedForm).not.toBeNull();
462
+ });
463
+
464
+ /*
465
+ * Whatever the page asks for on mount has been asked by now, so from here
466
+ * on every request is one the summary made.
467
+ */
468
+ getListMock.mockClear();
469
+ getItemMock.mockClear();
470
+
471
+ return capturedForm!.fields;
472
+ }
473
+
474
+ function keyOf(field: CapturedField): string | undefined {
475
+ return (
476
+ field.overrideFieldKey ||
477
+ Object.keys(field.field || field.overrideField || {})[0]
478
+ );
479
+ }
480
+
481
+ function findField(
482
+ fields: Array<CapturedField>,
483
+ fieldKey: string,
484
+ ): CapturedField {
485
+ const field: CapturedField | undefined = fields.find(
486
+ (candidate: CapturedField): boolean => {
487
+ return keyOf(candidate) === fieldKey;
488
+ },
489
+ );
490
+
491
+ expect(field).toBeDefined();
492
+
493
+ return field!;
494
+ }
495
+
496
+ /*
497
+ * Assigns roles as the editor does. The roles field hands the form an editor
498
+ * element whose onChange is the only thing that fills the ref the summary
499
+ * reads - the form values carry nothing for this field. The element is never
500
+ * rendered (its own role and user lookups are not what these tests are
501
+ * about); its onChange is called as the editor would call it.
502
+ */
503
+ function assignInEditor(
504
+ page: PageUnderTest,
505
+ field: CapturedField,
506
+ assignments: Array<RoleAssignment>,
507
+ ): void {
508
+ expect(field.getCustomElement).toBeDefined();
509
+
510
+ const editor: React.ReactElement = field.getCustomElement!(
511
+ {},
512
+ {
513
+ onChange: (): void => {
514
+ // The form's own copy of the value is not what the summary reads.
515
+ },
516
+ },
517
+ );
518
+
519
+ expect(editor.type).toBe(page.editorType);
520
+
521
+ const editorProps: {
522
+ onChange: (value: Array<RoleAssignment>) => void;
523
+ } = editor.props as { onChange: (value: Array<RoleAssignment>) => void };
524
+
525
+ editorProps.onChange(assignments);
526
+ }
527
+
528
+ function summaryOf(field: CapturedField): React.ReactElement {
529
+ expect(field.getSummaryElement).toBeDefined();
530
+
531
+ // The summary reads the editor's ref, not the form values it is given.
532
+ return <MemoryRouter>{field.getSummaryElement!({})}</MemoryRouter>;
533
+ }
534
+
535
+ async function waitForLookupsToSettle(container: HTMLElement): Promise<void> {
536
+ await waitFor(() => {
537
+ expect(within(container).queryAllByTestId("component-loader")).toHaveLength(
538
+ 0,
539
+ );
540
+ });
541
+ }
542
+
543
+ // Renders the field's summary and waits for every lookup it starts to settle.
544
+ async function renderSummary(field: CapturedField): Promise<RenderResult> {
545
+ const result: RenderResult = render(summaryOf(field));
546
+
547
+ await waitForLookupsToSettle(result.container);
548
+
549
+ return result;
550
+ }
551
+
552
+ function rgbOf(hex: string): string {
553
+ const rgb: RGB = Color.colorToRgb(new Color(hex));
554
+ return `rgb(${rgb.red}, ${rgb.green}, ${rgb.blue})`;
555
+ }
556
+
557
+ /*
558
+ * Whether anything in the summary is painted in the given color: the pills
559
+ * put it on the pill itself or, in their minimal form, on a dot beside the
560
+ * name.
561
+ */
562
+ function paintsColor(container: HTMLElement, hex: string): boolean {
563
+ const expected: string = rgbOf(hex);
564
+
565
+ return Array.from(container.querySelectorAll<HTMLElement>("*")).some(
566
+ (element: HTMLElement): boolean => {
567
+ return element.style.backgroundColor === expected;
568
+ },
569
+ );
570
+ }
571
+
572
+ /*
573
+ * The color of the pill that carries this name, so each role is checked
574
+ * against its own color and two roles cannot trade theirs. The role pill is
575
+ * the minimal one: a rounded outline, with the color on a dot beside the name.
576
+ */
577
+ function pillColorOf(container: HTMLElement, name: string): string {
578
+ const pill: HTMLElement | null = within(container)
579
+ .getByText(name)
580
+ .closest(".rounded-full");
581
+
582
+ expect(pill).not.toBeNull();
583
+
584
+ const dot: HTMLElement | null = pill!.querySelector('[aria-hidden="true"]');
585
+
586
+ return (dot || pill!).style.backgroundColor;
587
+ }
588
+
589
+ function precedes(earlier: HTMLElement, later: HTMLElement): boolean {
590
+ return Boolean(
591
+ earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING,
592
+ );
593
+ }
594
+
595
+ type PlacedText = {
596
+ text: string;
597
+ element: HTMLElement;
598
+ };
599
+
600
+ // Where each text sits, in document order. getByText fails on a duplicate.
601
+ function inDocumentOrder(
602
+ container: HTMLElement,
603
+ texts: Array<string>,
604
+ ): Array<PlacedText> {
605
+ return texts
606
+ .map((text: string): PlacedText => {
607
+ return { text: text, element: within(container).getByText(text) };
608
+ })
609
+ .sort((left: PlacedText, right: PlacedText): number => {
610
+ return precedes(left.element, right.element) ? -1 : 1;
611
+ });
612
+ }
613
+
614
+ /*
615
+ * Which heading - a role's name, or the sentence that stands in for a role
616
+ * that is gone - each person is listed under: the nearest one above them.
617
+ * Each person must appear exactly once, so nobody is also listed under a
618
+ * role they were not assigned to.
619
+ */
620
+ function listedUnder(
621
+ container: HTMLElement,
622
+ headings: Array<string>,
623
+ people: Array<string>,
624
+ ): Record<string, string | null> {
625
+ const placedHeadings: Array<PlacedText> = inDocumentOrder(
626
+ container,
627
+ headings,
628
+ );
629
+ const result: Record<string, string | null> = {};
630
+
631
+ for (const person of people) {
632
+ const row: HTMLElement = within(container).getByText(person);
633
+ let heading: string | null = null;
634
+
635
+ for (const placed of placedHeadings) {
636
+ if (precedes(placed.element, row)) {
637
+ heading = placed.text;
638
+ }
639
+ }
640
+
641
+ result[person] = heading;
642
+ }
643
+
644
+ return result;
645
+ }
646
+
647
+ function namesOf(fixtures: Array<RoleFixture | UserFixture>): Array<string> {
648
+ return fixtures.map((fixture: RoleFixture | UserFixture): string => {
649
+ return fixture.name;
650
+ });
651
+ }
652
+
653
+ /*
654
+ * Asserting on the DOM after each step can miss text that was painted and
655
+ * then replaced inside a single act(). This keeps every text the container
656
+ * ever held - added nodes and the old value of every edited text node - so a
657
+ * test can say a sentence never reached the screen, not just that it is gone.
658
+ */
659
+ interface TextHistory {
660
+ everShown: (text: string) => boolean;
661
+ stop: () => void;
662
+ }
663
+
664
+ function recordTextHistory(root: HTMLElement): TextHistory {
665
+ const seen: Array<string> = [root.textContent || ""];
666
+
667
+ const collect: (records: Array<MutationRecord>) => void = (
668
+ records: Array<MutationRecord>,
669
+ ): void => {
670
+ for (const record of records) {
671
+ if (record.oldValue) {
672
+ seen.push(record.oldValue);
673
+ }
674
+
675
+ record.addedNodes.forEach((node: Node): void => {
676
+ seen.push(node.textContent || "");
677
+ });
678
+ }
679
+
680
+ seen.push(root.textContent || "");
681
+ };
682
+
683
+ const observer: MutationObserver = new MutationObserver(collect);
684
+
685
+ observer.observe(root, {
686
+ childList: true,
687
+ subtree: true,
688
+ characterData: true,
689
+ characterDataOldValue: true,
690
+ });
691
+
692
+ return {
693
+ everShown: (text: string): boolean => {
694
+ collect(observer.takeRecords());
695
+ return seen.some((value: string): boolean => {
696
+ return value.includes(text);
697
+ });
698
+ },
699
+ stop: (): void => {
700
+ observer.disconnect();
701
+ },
702
+ };
703
+ }
704
+
705
+ /*
706
+ * A promise the test settles by hand, so it decides which role lookup
707
+ * answers first - that ordering is the whole point of the stale-answer tests.
708
+ */
709
+ interface Deferred<T> {
710
+ promise: Promise<T>;
711
+ resolve: (value: T) => void;
712
+ reject: (reason: unknown) => void;
713
+ }
714
+
715
+ function createDeferred<T>(): Deferred<T> {
716
+ let resolve: (value: T) => void = (): void => {};
717
+ let reject: (reason: unknown) => void = (): void => {};
718
+
719
+ const promise: Promise<T> = new Promise<T>(
720
+ (
721
+ promiseResolve: (value: T) => void,
722
+ promiseReject: (reason: unknown) => void,
723
+ ): void => {
724
+ resolve = promiseResolve;
725
+ reject = promiseReject;
726
+ },
727
+ );
728
+
729
+ return { promise, resolve, reject };
730
+ }
731
+
732
+ beforeEach(() => {
733
+ capturedForm = null;
734
+ roleLookupAnswer = LookupAnswer.Found;
735
+ getListMock.mockReset();
736
+ getItemMock.mockReset();
737
+ getListMock.mockImplementation(answerList as never);
738
+ getItemMock.mockImplementation((async (): Promise<null> => {
739
+ return null;
740
+ }) as never);
741
+
742
+ jest
743
+ .spyOn(ProjectUtil, "getCurrentProjectId")
744
+ .mockReturnValue(new ObjectID(PROJECT_ID));
745
+ jest.spyOn(Navigation, "getQueryStringByName").mockReturnValue(null);
746
+ });
747
+
748
+ afterEach(() => {
749
+ cleanup();
750
+ jest.restoreAllMocks();
751
+ });
752
+
753
+ describe.each(PAGES)(
754
+ "the roles summary on the $name page's review step",
755
+ (page: PageUnderTest) => {
756
+ test("names each assigned role in its color, with the people assigned to it beneath", async () => {
757
+ const field: CapturedField = findField(
758
+ await openForm(page),
759
+ page.rolesFieldKey,
760
+ );
761
+
762
+ assignInEditor(page, field, ASSIGNMENTS);
763
+
764
+ const { container } = await renderSummary(field);
765
+
766
+ for (const role of [INCIDENT_COMMANDER, COMMUNICATIONS_LEAD, SCRIBE]) {
767
+ expect(within(container).getByText(role.name)).toBeInTheDocument();
768
+ expect(paintsColor(container, role.color)).toBe(true);
769
+ expect(pillColorOf(container, role.name)).toBe(rgbOf(role.color));
770
+ }
771
+
772
+ // In the order they were assigned, not the order the API answered.
773
+ expect(
774
+ inDocumentOrder(
775
+ container,
776
+ namesOf([COMMUNICATIONS_LEAD, SCRIBE, INCIDENT_COMMANDER]),
777
+ ).map((placed: PlacedText): string => {
778
+ return placed.text;
779
+ }),
780
+ ).toEqual(namesOf([INCIDENT_COMMANDER, COMMUNICATIONS_LEAD, SCRIBE]));
781
+
782
+ expect(
783
+ listedUnder(
784
+ container,
785
+ namesOf([INCIDENT_COMMANDER, COMMUNICATIONS_LEAD, SCRIBE]),
786
+ namesOf([ALICE, BOB, CAROL, DEV]),
787
+ ),
788
+ ).toEqual({
789
+ [ALICE.name]: INCIDENT_COMMANDER.name,
790
+ [BOB.name]: INCIDENT_COMMANDER.name,
791
+ [CAROL.name]: COMMUNICATIONS_LEAD.name,
792
+ [DEV.name]: SCRIBE.name,
793
+ });
794
+ });
795
+
796
+ test("never counts users and roles instead of naming them", async () => {
797
+ const field: CapturedField = findField(
798
+ await openForm(page),
799
+ page.rolesFieldKey,
800
+ );
801
+
802
+ assignInEditor(page, field, ASSIGNMENTS);
803
+
804
+ const { container } = await renderSummary(field);
805
+
806
+ // Not vacuous: the roles and people are on screen.
807
+ expect(container).toHaveTextContent(INCIDENT_COMMANDER.name);
808
+ expect(container).toHaveTextContent(ALICE.name);
809
+
810
+ for (const pattern of COUNT_SENTENCE_PATTERNS) {
811
+ expect(container.textContent).not.toMatch(pattern);
812
+ }
813
+ });
814
+
815
+ test("looks the roles up once, by exactly the assigned ids, and each role's people by exactly theirs", async () => {
816
+ const field: CapturedField = findField(
817
+ await openForm(page),
818
+ page.rolesFieldKey,
819
+ );
820
+
821
+ assignInEditor(page, field, ASSIGNMENTS);
822
+
823
+ await renderSummary(field);
824
+
825
+ const roleLookups: Array<Lookup> = lookupsOf(IncidentRole);
826
+ const userLookups: Array<Lookup> = lookupsOf(TeamMember);
827
+
828
+ expect(roleLookups).toHaveLength(1);
829
+ expect(asIdSet(roleLookups[0]!.ids)).toBe(
830
+ asIdSet(
831
+ ASSIGNMENTS.map((assignment: RoleAssignment): string => {
832
+ return assignment.roleId;
833
+ }),
834
+ ),
835
+ );
836
+ expect(roleLookups[0]!.select).toEqual(
837
+ expect.objectContaining({ _id: true, name: true, color: true }),
838
+ );
839
+
840
+ // One lookup per role, each for exactly the people assigned to it.
841
+ expect(
842
+ asIdSets(
843
+ userLookups.map((lookup: Lookup): Array<string> => {
844
+ return lookup.ids;
845
+ }),
846
+ ),
847
+ ).toEqual(
848
+ asIdSets(
849
+ ASSIGNMENTS.map((assignment: RoleAssignment): Array<string> => {
850
+ return assignment.userIds;
851
+ }),
852
+ ),
853
+ );
854
+
855
+ // And nothing else - never the User model, which would refuse it.
856
+ expect(lookupsOf(User)).toEqual([]);
857
+ expect(lookupsMade()).toHaveLength(
858
+ roleLookups.length + userLookups.length,
859
+ );
860
+ });
861
+
862
+ test.each([
863
+ {
864
+ how: "the editor was never touched",
865
+ edits: [] as Array<Array<RoleAssignment>>,
866
+ },
867
+ {
868
+ how: "the editor took back every role it had assigned",
869
+ edits: [ASSIGNMENTS, []] as Array<Array<RoleAssignment>>,
870
+ },
871
+ ])(
872
+ "keeps its empty sentence and looks nothing up when $how",
873
+ async ({ edits }: { edits: Array<Array<RoleAssignment>> }) => {
874
+ const field: CapturedField = findField(
875
+ await openForm(page),
876
+ page.rolesFieldKey,
877
+ );
878
+
879
+ for (const assignments of edits) {
880
+ assignInEditor(page, field, assignments);
881
+ }
882
+
883
+ const { container } = await renderSummary(field);
884
+
885
+ // Let anything the summary might have started get as far as the API.
886
+ await new Promise<void>((resolve: () => void) => {
887
+ setTimeout(resolve, 0);
888
+ });
889
+
890
+ expect(container).toHaveTextContent(page.emptyMessage);
891
+ expect(container).not.toHaveTextContent(INCIDENT_COMMANDER.name);
892
+ expect(lookupsMade()).toEqual([]);
893
+ },
894
+ );
895
+
896
+ test("shows the error when the role lookup fails, and no role name", async () => {
897
+ roleLookupAnswer = LookupAnswer.Failed;
898
+
899
+ const field: CapturedField = findField(
900
+ await openForm(page),
901
+ page.rolesFieldKey,
902
+ );
903
+
904
+ assignInEditor(page, field, ASSIGNMENTS);
905
+
906
+ const { container } = await renderSummary(field);
907
+
908
+ expect(container).toHaveTextContent(LOOKUP_FAILURE_MESSAGE);
909
+
910
+ for (const role of [INCIDENT_COMMANDER, COMMUNICATIONS_LEAD, SCRIBE]) {
911
+ expect(container).not.toHaveTextContent(role.name);
912
+ }
913
+
914
+ for (const pattern of COUNT_SENTENCE_PATTERNS) {
915
+ expect(container.textContent).not.toMatch(pattern);
916
+ }
917
+
918
+ /*
919
+ * The error stands in for the whole list: drawn beneath it, every role
920
+ * would read as deleted and each would still look its people up.
921
+ */
922
+ expect(container).not.toHaveTextContent(ROLE_NOT_FOUND_MESSAGE);
923
+
924
+ for (const user of USER_FIXTURES) {
925
+ expect(container).not.toHaveTextContent(user.name);
926
+ }
927
+
928
+ expect(lookupsOf(TeamMember)).toEqual([]);
929
+ });
930
+
931
+ test("says a role that no longer exists could not be found, still lists its people, and names the others", async () => {
932
+ const field: CapturedField = findField(
933
+ await openForm(page),
934
+ page.rolesFieldKey,
935
+ );
936
+
937
+ assignInEditor(page, field, [
938
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE, BOB]),
939
+ assignmentOf(DELETED_ROLE_ID, [CAROL]),
940
+ assignmentOf(SCRIBE.id, [DEV]),
941
+ ]);
942
+
943
+ const { container } = await renderSummary(field);
944
+
945
+ expect(pillColorOf(container, INCIDENT_COMMANDER.name)).toBe(
946
+ rgbOf(INCIDENT_COMMANDER.color),
947
+ );
948
+ expect(pillColorOf(container, SCRIBE.name)).toBe(rgbOf(SCRIBE.color));
949
+
950
+ expect(
951
+ listedUnder(
952
+ container,
953
+ [INCIDENT_COMMANDER.name, ROLE_NOT_FOUND_MESSAGE, SCRIBE.name],
954
+ namesOf([ALICE, BOB, CAROL, DEV]),
955
+ ),
956
+ ).toEqual({
957
+ [ALICE.name]: INCIDENT_COMMANDER.name,
958
+ [BOB.name]: INCIDENT_COMMANDER.name,
959
+ [CAROL.name]: ROLE_NOT_FOUND_MESSAGE,
960
+ [DEV.name]: SCRIBE.name,
961
+ });
962
+
963
+ // The gone role was still asked for, in the same one lookup.
964
+ expect(lookupsOf(IncidentRole)).toHaveLength(1);
965
+ expect(asIdSet(lookupsOf(IncidentRole)[0]!.ids)).toBe(
966
+ asIdSet([INCIDENT_COMMANDER.id, DELETED_ROLE_ID, SCRIBE.id]),
967
+ );
968
+ });
969
+
970
+ test("after the editor changes a role's people, shows the new people and not the old", async () => {
971
+ const field: CapturedField = findField(
972
+ await openForm(page),
973
+ page.rolesFieldKey,
974
+ );
975
+
976
+ assignInEditor(page, field, [
977
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE]),
978
+ ]);
979
+
980
+ const result: RenderResult = await renderSummary(field);
981
+
982
+ expect(
983
+ within(result.container).getByText(ALICE.name),
984
+ ).toBeInTheDocument();
985
+
986
+ getListMock.mockClear();
987
+
988
+ // Back to the roles step, and back to the review step.
989
+ assignInEditor(page, field, [
990
+ assignmentOf(INCIDENT_COMMANDER.id, [BOB, CAROL]),
991
+ ]);
992
+
993
+ result.rerender(summaryOf(field));
994
+
995
+ await waitForLookupsToSettle(result.container);
996
+
997
+ expect(
998
+ listedUnder(
999
+ result.container,
1000
+ [INCIDENT_COMMANDER.name],
1001
+ namesOf([BOB, CAROL]),
1002
+ ),
1003
+ ).toEqual({
1004
+ [BOB.name]: INCIDENT_COMMANDER.name,
1005
+ [CAROL.name]: INCIDENT_COMMANDER.name,
1006
+ });
1007
+ expect(result.container).not.toHaveTextContent(ALICE.name);
1008
+
1009
+ /*
1010
+ * The new people were looked up; the role, the same one as before,
1011
+ * was not asked for again.
1012
+ */
1013
+ expect(lookupsOf(TeamMember)).toHaveLength(1);
1014
+ expect(asIdSet(lookupsOf(TeamMember)[0]!.ids)).toBe(
1015
+ asIdSet([BOB.id, CAROL.id]),
1016
+ );
1017
+ expect(lookupsOf(IncidentRole)).toEqual([]);
1018
+ });
1019
+
1020
+ test("after the editor assigns another role, looks the new set up and names it", async () => {
1021
+ const field: CapturedField = findField(
1022
+ await openForm(page),
1023
+ page.rolesFieldKey,
1024
+ );
1025
+
1026
+ assignInEditor(page, field, [
1027
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE]),
1028
+ ]);
1029
+
1030
+ const result: RenderResult = await renderSummary(field);
1031
+
1032
+ expect(result.container).not.toHaveTextContent(SCRIBE.name);
1033
+
1034
+ getListMock.mockClear();
1035
+
1036
+ assignInEditor(page, field, [
1037
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE]),
1038
+ assignmentOf(SCRIBE.id, [DEV]),
1039
+ ]);
1040
+
1041
+ result.rerender(summaryOf(field));
1042
+
1043
+ await waitForLookupsToSettle(result.container);
1044
+
1045
+ expect(pillColorOf(result.container, SCRIBE.name)).toBe(
1046
+ rgbOf(SCRIBE.color),
1047
+ );
1048
+ expect(
1049
+ listedUnder(
1050
+ result.container,
1051
+ [INCIDENT_COMMANDER.name, SCRIBE.name],
1052
+ namesOf([ALICE, DEV]),
1053
+ ),
1054
+ ).toEqual({
1055
+ [ALICE.name]: INCIDENT_COMMANDER.name,
1056
+ [DEV.name]: SCRIBE.name,
1057
+ });
1058
+
1059
+ expect(lookupsOf(IncidentRole)).toHaveLength(1);
1060
+ expect(asIdSet(lookupsOf(IncidentRole)[0]!.ids)).toBe(
1061
+ asIdSet([INCIDENT_COMMANDER.id, SCRIBE.id]),
1062
+ );
1063
+ });
1064
+ },
1065
+ );
1066
+
1067
+ describe("FetchIncidentRoleAssignments", () => {
1068
+ const COMMANDER_WITH_ALICE: Array<RoleAssignment> = [
1069
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE]),
1070
+ ];
1071
+
1072
+ const LEAD_WITH_BOB: Array<RoleAssignment> = [
1073
+ assignmentOf(COMMUNICATIONS_LEAD.id, [BOB]),
1074
+ ];
1075
+
1076
+ function assignmentsElement(
1077
+ assignments: Array<RoleAssignment>,
1078
+ ): React.ReactElement {
1079
+ return <FetchIncidentRoleAssignments assignments={assignments} />;
1080
+ }
1081
+
1082
+ /*
1083
+ * Role lookups answer with these, in order, when the test settles them;
1084
+ * user lookups keep answering from the fixtures straight away.
1085
+ */
1086
+ function holdRoleLookups(lookups: Array<Deferred<ListAnswer>>): void {
1087
+ const queue: Array<Deferred<ListAnswer>> = [...lookups];
1088
+
1089
+ getListMock.mockImplementation(((
1090
+ request: ListRequest,
1091
+ ): Promise<ListAnswer> => {
1092
+ const held: Deferred<ListAnswer> | undefined =
1093
+ request.modelType === IncidentRole ? queue.shift() : undefined;
1094
+
1095
+ return held ? held.promise : answerList(request);
1096
+ }) as never);
1097
+ }
1098
+
1099
+ function roleLookupIds(): Array<Array<string>> {
1100
+ return lookupsOf(IncidentRole).map((lookup: Lookup): Array<string> => {
1101
+ return lookup.ids;
1102
+ });
1103
+ }
1104
+
1105
+ test("an answer for the old roles that arrives after the new roles' answer never replaces it", async () => {
1106
+ const firstLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1107
+ const secondLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1108
+ holdRoleLookups([firstLookup, secondLookup]);
1109
+
1110
+ const view: RenderResult = render(assignmentsElement(COMMANDER_WITH_ALICE));
1111
+ const history: TextHistory = recordTextHistory(view.container);
1112
+
1113
+ view.rerender(assignmentsElement(LEAD_WITH_BOB));
1114
+
1115
+ expect(roleLookupIds()).toEqual([
1116
+ [INCIDENT_COMMANDER.id],
1117
+ [COMMUNICATIONS_LEAD.id],
1118
+ ]);
1119
+
1120
+ await act(async () => {
1121
+ secondLookup.resolve(rolesAnswer([COMMUNICATIONS_LEAD]));
1122
+ });
1123
+
1124
+ expect(await screen.findByText(BOB.name)).toBeInTheDocument();
1125
+ expect(screen.getByText(COMMUNICATIONS_LEAD.name)).toBeInTheDocument();
1126
+
1127
+ /*
1128
+ * The old answer holds only the Incident Commander. Were it let in, it
1129
+ * would be filed under the Commander's roles, and the summary would fall
1130
+ * back to the loader for good: nothing asks for the Communications Lead
1131
+ * again.
1132
+ */
1133
+ await act(async () => {
1134
+ firstLookup.resolve(rolesAnswer([INCIDENT_COMMANDER]));
1135
+ });
1136
+
1137
+ expect(screen.queryByTestId("component-loader")).toBeNull();
1138
+
1139
+ expect(screen.getByText(COMMUNICATIONS_LEAD.name)).toBeInTheDocument();
1140
+ expect(pillColorOf(view.container, COMMUNICATIONS_LEAD.name)).toBe(
1141
+ rgbOf(COMMUNICATIONS_LEAD.color),
1142
+ );
1143
+ expect(screen.getByText(BOB.name)).toBeInTheDocument();
1144
+ expect(history.everShown(ROLE_NOT_FOUND_MESSAGE)).toBe(false);
1145
+
1146
+ history.stop();
1147
+ });
1148
+
1149
+ test("an answer for the old roles that arrives while the new roles are loading is ignored", async () => {
1150
+ const firstLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1151
+ const secondLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1152
+ holdRoleLookups([firstLookup, secondLookup]);
1153
+
1154
+ const view: RenderResult = render(assignmentsElement(COMMANDER_WITH_ALICE));
1155
+ const history: TextHistory = recordTextHistory(view.container);
1156
+
1157
+ view.rerender(assignmentsElement(LEAD_WITH_BOB));
1158
+
1159
+ await act(async () => {
1160
+ firstLookup.resolve(rolesAnswer([INCIDENT_COMMANDER]));
1161
+ });
1162
+
1163
+ // Still waiting on the roles that are actually assigned.
1164
+ expect(screen.getByTestId("component-loader")).toBeInTheDocument();
1165
+
1166
+ await act(async () => {
1167
+ secondLookup.resolve(rolesAnswer([COMMUNICATIONS_LEAD]));
1168
+ });
1169
+
1170
+ expect(await screen.findByText(BOB.name)).toBeInTheDocument();
1171
+ expect(screen.getByText(COMMUNICATIONS_LEAD.name)).toBeInTheDocument();
1172
+ expect(history.everShown(ROLE_NOT_FOUND_MESSAGE)).toBe(false);
1173
+
1174
+ history.stop();
1175
+ });
1176
+
1177
+ test("a failure for the old roles that arrives after the new roles' answer is ignored too", async () => {
1178
+ const firstLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1179
+ const secondLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1180
+ holdRoleLookups([firstLookup, secondLookup]);
1181
+
1182
+ const view: RenderResult = render(assignmentsElement(COMMANDER_WITH_ALICE));
1183
+
1184
+ view.rerender(assignmentsElement(LEAD_WITH_BOB));
1185
+
1186
+ await act(async () => {
1187
+ secondLookup.resolve(rolesAnswer([COMMUNICATIONS_LEAD]));
1188
+ });
1189
+
1190
+ expect(await screen.findByText(BOB.name)).toBeInTheDocument();
1191
+
1192
+ await act(async () => {
1193
+ firstLookup.reject(new Error(LOOKUP_FAILURE_MESSAGE));
1194
+ });
1195
+
1196
+ expect(screen.queryByTestId("component-loader")).toBeNull();
1197
+ expect(screen.getByText(COMMUNICATIONS_LEAD.name)).toBeInTheDocument();
1198
+ expect(screen.getByText(BOB.name)).toBeInTheDocument();
1199
+ expect(screen.queryByText(LOOKUP_FAILURE_MESSAGE)).toBeNull();
1200
+ });
1201
+
1202
+ test("a new set of roles waits for its own answer instead of drawing from the old one", async () => {
1203
+ const firstLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1204
+ const secondLookup: Deferred<ListAnswer> = createDeferred<ListAnswer>();
1205
+ holdRoleLookups([firstLookup, secondLookup]);
1206
+
1207
+ const view: RenderResult = render(assignmentsElement(COMMANDER_WITH_ALICE));
1208
+
1209
+ await act(async () => {
1210
+ firstLookup.resolve(rolesAnswer([INCIDENT_COMMANDER]));
1211
+ });
1212
+
1213
+ expect(await screen.findByText(ALICE.name)).toBeInTheDocument();
1214
+
1215
+ getListMock.mockClear();
1216
+ const history: TextHistory = recordTextHistory(view.container);
1217
+
1218
+ view.rerender(
1219
+ assignmentsElement([
1220
+ assignmentOf(INCIDENT_COMMANDER.id, [ALICE]),
1221
+ assignmentOf(SCRIBE.id, [DEV]),
1222
+ ]),
1223
+ );
1224
+
1225
+ /*
1226
+ * The old answer holds no Scribe. Drawn from it, the Scribe would read as
1227
+ * not found for a render, and Dev would be looked up for a list that is
1228
+ * about to be replaced.
1229
+ */
1230
+ expect(screen.getByTestId("component-loader")).toBeInTheDocument();
1231
+ expect(lookupsOf(TeamMember)).toEqual([]);
1232
+
1233
+ await act(async () => {
1234
+ secondLookup.resolve(rolesAnswer([INCIDENT_COMMANDER, SCRIBE]));
1235
+ });
1236
+
1237
+ expect(await screen.findByText(DEV.name)).toBeInTheDocument();
1238
+ expect(pillColorOf(view.container, SCRIBE.name)).toBe(rgbOf(SCRIBE.color));
1239
+ expect(history.everShown(ROLE_NOT_FOUND_MESSAGE)).toBe(false);
1240
+ expect(
1241
+ asIdSets(
1242
+ lookupsOf(TeamMember).map((lookup: Lookup): Array<string> => {
1243
+ return lookup.ids;
1244
+ }),
1245
+ ),
1246
+ ).toEqual(asIdSets([[ALICE.id], [DEV.id]]));
1247
+
1248
+ history.stop();
1249
+ });
1250
+ });