@oneuptime/common 12.0.9 → 12.0.11

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 (262) hide show
  1. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  2. package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
  3. package/Models/DatabaseModels/RunnerJob.ts +17 -2
  4. package/Server/API/LlmProviderAPI.ts +8 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
  8. package/Server/Services/NetworkDeviceLinkService.ts +338 -2
  9. package/Server/Services/NetworkDeviceService.ts +265 -12
  10. package/Server/Services/RunnerJobService.ts +24 -1
  11. package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
  12. package/Server/Types/Database/JSONColumnQuery.ts +42 -4
  13. package/Server/Utils/LLM/LLMService.ts +108 -19
  14. package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
  15. package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
  16. package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
  17. package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
  18. package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
  19. package/Server/Utils/SSRFProtection.ts +5 -4
  20. package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
  21. package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
  22. package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
  23. package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
  24. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
  25. package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
  26. package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
  27. package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
  28. package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
  29. package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
  30. package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
  31. package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
  32. package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
  33. package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
  34. package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
  35. package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
  36. package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
  37. package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
  38. package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
  39. package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
  40. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
  41. package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
  42. package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
  43. package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
  44. package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
  45. package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
  46. package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
  47. package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
  48. package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
  49. package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
  50. package/Tests/Types/API/URLTelScheme.test.ts +261 -0
  51. package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
  52. package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
  53. package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
  54. package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
  55. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
  56. package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
  57. package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
  58. package/Tests/UI/Components/Button.test.tsx +279 -0
  59. package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
  60. package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
  61. package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
  62. package/Tests/UI/Components/Icon.test.tsx +57 -0
  63. package/Tests/UI/Components/List.test.tsx +20 -3
  64. package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
  65. package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
  66. package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
  67. package/Tests/UI/Components/Pagination.test.tsx +721 -146
  68. package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
  69. package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
  70. package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
  71. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
  72. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
  73. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
  74. package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
  75. package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
  76. package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
  77. package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
  78. package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
  79. package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
  80. package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
  81. package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
  82. package/Types/API/Protocol.ts +9 -0
  83. package/Types/API/URL.ts +286 -20
  84. package/Types/Billing/PayAsYouGoPricing.ts +47 -0
  85. package/Types/CustomField/CustomFieldType.ts +28 -0
  86. package/Types/Monitor/MonitorType.ts +11 -0
  87. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  88. package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
  89. package/Types/Runbook/RunbookStepType.ts +23 -0
  90. package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
  91. package/UI/Components/Button/Button.tsx +14 -22
  92. package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
  93. package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
  94. package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
  95. package/UI/Components/Charts/Line/LineChart.tsx +4 -2
  96. package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
  97. package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
  98. package/UI/Components/Dictionary/Dictionary.tsx +4 -39
  99. package/UI/Components/Icon/Icon.tsx +1 -1
  100. package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
  101. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
  102. package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
  103. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
  104. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
  105. package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
  106. package/UI/Components/Pagination/Pagination.tsx +373 -328
  107. package/UI/Components/Pagination/PaginationUtil.ts +263 -0
  108. package/UI/Components/Pagination/Screenshots/README.md +15 -0
  109. package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
  110. package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
  111. package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
  112. package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
  113. package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
  114. package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
  115. package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
  116. package/UI/Components/TextArea/TextArea.tsx +3 -0
  117. package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
  118. package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
  119. package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
  120. package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
  121. package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
  122. package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
  123. package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
  124. package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
  125. package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
  126. package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
  127. package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
  128. package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
  129. package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
  130. package/Utils/API.ts +243 -36
  131. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
  132. package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
  133. package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
  134. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  135. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
  137. package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
  139. package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
  140. package/build/dist/Server/API/LlmProviderAPI.js +9 -1
  141. package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
  142. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
  143. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
  144. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  145. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  146. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
  147. package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
  148. package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
  149. package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
  150. package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
  151. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  152. package/build/dist/Server/Services/RunnerJobService.js +20 -2
  153. package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
  154. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
  155. package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
  156. package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
  157. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
  158. package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
  159. package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
  160. package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
  161. package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
  162. package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
  163. package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
  164. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
  165. package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
  166. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
  167. package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
  168. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
  169. package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
  170. package/build/dist/Server/Utils/SSRFProtection.js +5 -4
  171. package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
  172. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
  173. package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
  174. package/build/dist/Types/API/Protocol.js +9 -0
  175. package/build/dist/Types/API/Protocol.js.map +1 -1
  176. package/build/dist/Types/API/URL.js +241 -18
  177. package/build/dist/Types/API/URL.js.map +1 -1
  178. package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
  179. package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
  180. package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
  181. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  182. package/build/dist/Types/Monitor/MonitorType.js +10 -0
  183. package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
  184. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
  185. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
  186. package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
  187. package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
  188. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
  189. package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
  190. package/build/dist/UI/Components/Button/Button.js +10 -16
  191. package/build/dist/UI/Components/Button/Button.js.map +1 -1
  192. package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
  193. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  194. package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
  195. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  196. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
  197. package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
  198. package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
  199. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  200. package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
  201. package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
  202. package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
  203. package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
  204. package/build/dist/UI/Components/Icon/Icon.js +1 -1
  205. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  206. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
  207. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  208. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
  209. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  210. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
  211. package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
  212. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
  213. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  214. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
  215. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
  216. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
  217. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  218. package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
  219. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  220. package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
  221. package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
  222. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
  223. package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
  224. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
  225. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
  226. package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
  227. package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
  228. package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
  229. package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
  230. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
  231. package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
  232. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
  233. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
  234. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
  235. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
  236. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
  237. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
  238. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
  239. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
  240. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
  241. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
  242. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
  243. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
  244. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
  245. package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
  246. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
  247. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
  248. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
  249. package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
  250. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
  251. package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
  252. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
  253. package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
  254. package/build/dist/Utils/API.js +175 -34
  255. package/build/dist/Utils/API.js.map +1 -1
  256. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
  257. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  258. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
  259. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  260. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
  261. package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
  262. package/package.json +1 -1
@@ -0,0 +1,778 @@
1
+ import { describe, expect, jest, test } from "@jest/globals";
2
+
3
+ /*
4
+ * Without an injected `createTeamMember`, bulkAddUsersToProject issues the real
5
+ * create through the admin ModelAPI - the one that sends no tenant header,
6
+ * because a master admin is not inside any project. Every test below except
7
+ * "sends the create through the admin ModelAPI..." injects its own create, so
8
+ * this mock only stands in for the one test that exercises the production path.
9
+ */
10
+ jest.mock(
11
+ "../../../../App/FeatureSet/AdminDashboard/src/Utils/ModelAPI",
12
+ () => {
13
+ return {
14
+ __esModule: true,
15
+ default: {
16
+ create: jest.fn(),
17
+ },
18
+ };
19
+ },
20
+ );
21
+
22
+ import bulkAddUsersToProject, {
23
+ BulkAddUsersToProjectProgress,
24
+ BulkAddUsersToProjectResult,
25
+ } from "../../../../App/FeatureSet/AdminDashboard/src/Components/User/BulkAddUsersToProject";
26
+ import AdminModelAPI from "../../../../App/FeatureSet/AdminDashboard/src/Utils/ModelAPI";
27
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
28
+ import User from "../../../Models/DatabaseModels/User";
29
+ import Errors from "../../../Server/Utils/Errors";
30
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
31
+ import ObjectID from "../../../Types/ObjectID";
32
+
33
+ const PROJECT_ID: ObjectID = new ObjectID(
34
+ "0198c8ec-2a1d-7f0c-9e75-384194170001",
35
+ );
36
+ const TEAM_ID: ObjectID = new ObjectID("0198c8ec-2a1d-7f0c-9e75-384194170002");
37
+
38
+ const FIRST_USER_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194170010";
39
+ const SECOND_USER_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194170011";
40
+ const THIRD_USER_ID: string = "0198c8ec-2a1d-7f0c-9e75-384194170012";
41
+
42
+ const makeUser: (id: string | null) => User = (id: string | null): User => {
43
+ const user: User = new User();
44
+
45
+ if (id) {
46
+ user._id = id;
47
+ }
48
+
49
+ return user;
50
+ };
51
+
52
+ const adminModelAPICreateMock: jest.MockedFunction<any> =
53
+ AdminModelAPI.create as unknown as jest.MockedFunction<any>;
54
+
55
+ interface AdminModelAPICreateArgument {
56
+ model: TeamMember;
57
+ modelType: unknown;
58
+ }
59
+
60
+ type DeferredResolve = () => void;
61
+ type DeferredReject = (error: Error) => void;
62
+
63
+ // The parameter types the Promise constructor hands its executor.
64
+ type PromiseVoidResolve = (value: void | PromiseLike<void>) => void;
65
+ type PromiseReject = (reason?: unknown) => void;
66
+
67
+ interface Deferred {
68
+ promise: Promise<void>;
69
+ resolve: DeferredResolve;
70
+ reject: DeferredReject;
71
+ }
72
+
73
+ /*
74
+ * A create the test decides the timing of. Instantly-resolving mocks cannot
75
+ * tell a sequential loop apart from `Promise.all(users.map(...))` - both record
76
+ * their calls in selection order - so the sequencing tests hold each create
77
+ * open and look at what has NOT happened yet.
78
+ */
79
+ const makeDeferred: () => Deferred = (): Deferred => {
80
+ let resolveDeferred: DeferredResolve = (): void => {};
81
+ let rejectDeferred: DeferredReject = (): void => {};
82
+
83
+ const promise: Promise<void> = new Promise<void>(
84
+ (resolve: PromiseVoidResolve, reject: PromiseReject): void => {
85
+ resolveDeferred = (): void => {
86
+ resolve();
87
+ };
88
+ rejectDeferred = (error: Error): void => {
89
+ reject(error);
90
+ };
91
+ },
92
+ );
93
+
94
+ return {
95
+ promise: promise,
96
+ resolve: resolveDeferred,
97
+ reject: rejectDeferred,
98
+ };
99
+ };
100
+
101
+ /*
102
+ * Two full macrotask turns, not a microtask flush. A concurrent implementation
103
+ * starts every create in the tick it is called, so anything it was going to
104
+ * start has definitely started by the time this resolves - which is what makes
105
+ * "create #2 has not started" a real assertion rather than a race.
106
+ */
107
+ const flushPendingWork: () => Promise<void> = async (): Promise<void> => {
108
+ for (let turn: number = 0; turn < 2; turn++) {
109
+ await new Promise<void>((resolve: PromiseVoidResolve): void => {
110
+ setTimeout(resolve, 0);
111
+ });
112
+ }
113
+ };
114
+
115
+ type MakeGatesFunction = (userIds: Array<string>) => Map<string, Deferred>;
116
+
117
+ const makeGates: MakeGatesFunction = (
118
+ userIds: Array<string>,
119
+ ): Map<string, Deferred> => {
120
+ const gates: Map<string, Deferred> = new Map<string, Deferred>();
121
+
122
+ for (const userId of userIds) {
123
+ gates.set(userId, makeDeferred());
124
+ }
125
+
126
+ return gates;
127
+ };
128
+
129
+ describe("bulkAddUsersToProject", () => {
130
+ test("creates one membership per selected user, in selection order", async (): Promise<void> => {
131
+ const first: User = makeUser(FIRST_USER_ID);
132
+ const second: User = makeUser(SECOND_USER_ID);
133
+ const third: User = makeUser(THIRD_USER_ID);
134
+ const createdUserIds: Array<string> = [];
135
+
136
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
137
+ users: [first, second, third],
138
+ projectId: PROJECT_ID,
139
+ teamId: TEAM_ID,
140
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
141
+ createdUserIds.push(teamMember.userId!.toString());
142
+ },
143
+ });
144
+
145
+ /*
146
+ * Pins coverage and attribution, NOT sequencing: exactly one create per
147
+ * selected user, and each user landing in succeededUsers under their own
148
+ * name, which is the row the progress modal prints the outcome against.
149
+ * The ordering here proves nothing about concurrency - these creates
150
+ * resolve instantly, so `Promise.all(users.map(...))` would record the
151
+ * same three ids in the same order. Sequencing is pinned by "does not
152
+ * start the next create until the previous one has settled" below.
153
+ */
154
+ expect(createdUserIds).toEqual([
155
+ FIRST_USER_ID,
156
+ SECOND_USER_ID,
157
+ THIRD_USER_ID,
158
+ ]);
159
+ expect(result.succeededUsers).toEqual([first, second, third]);
160
+ expect(result.failedUsers).toEqual([]);
161
+ });
162
+
163
+ test("does not start the next create until the previous one has settled", async (): Promise<void> => {
164
+ const first: User = makeUser(FIRST_USER_ID);
165
+ const second: User = makeUser(SECOND_USER_ID);
166
+ const third: User = makeUser(THIRD_USER_ID);
167
+
168
+ const startedUserIds: Array<string> = [];
169
+ const settledUserIds: Array<string> = [];
170
+ const gates: Map<string, Deferred> = makeGates([
171
+ FIRST_USER_ID,
172
+ SECOND_USER_ID,
173
+ THIRD_USER_ID,
174
+ ]);
175
+
176
+ const runPromise: Promise<BulkAddUsersToProjectResult> =
177
+ bulkAddUsersToProject({
178
+ users: [first, second, third],
179
+ projectId: PROJECT_ID,
180
+ teamId: TEAM_ID,
181
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
182
+ const userId: string = teamMember.userId!.toString();
183
+ startedUserIds.push(userId);
184
+ await gates.get(userId)!.promise;
185
+ settledUserIds.push(userId);
186
+ },
187
+ });
188
+
189
+ /*
190
+ * The seat accounting is why this has to be one at a time.
191
+ * TeamMemberService.onBeforeCreate reads the project's current seat count
192
+ * and compares it to the seat limit, so two creates in flight together
193
+ * both read the same pre-increment count and both pass a check that only
194
+ * one of them should have - taking the project past the seat it paid for.
195
+ * A concurrent rewrite is invisible to every other test in this file
196
+ * because instantly-resolving mocks record in selection order either way;
197
+ * it is only visible here, where create #1 is held open and create #2 must
198
+ * still be unstarted.
199
+ */
200
+ await flushPendingWork();
201
+ expect(startedUserIds).toEqual([FIRST_USER_ID]);
202
+ expect(settledUserIds).toEqual([]);
203
+
204
+ gates.get(FIRST_USER_ID)!.resolve();
205
+ await flushPendingWork();
206
+ expect(settledUserIds).toEqual([FIRST_USER_ID]);
207
+ expect(startedUserIds).toEqual([FIRST_USER_ID, SECOND_USER_ID]);
208
+
209
+ gates.get(SECOND_USER_ID)!.resolve();
210
+ await flushPendingWork();
211
+ expect(settledUserIds).toEqual([FIRST_USER_ID, SECOND_USER_ID]);
212
+ expect(startedUserIds).toEqual([
213
+ FIRST_USER_ID,
214
+ SECOND_USER_ID,
215
+ THIRD_USER_ID,
216
+ ]);
217
+
218
+ gates.get(THIRD_USER_ID)!.resolve();
219
+
220
+ const result: BulkAddUsersToProjectResult = await runPromise;
221
+
222
+ expect(settledUserIds).toEqual([
223
+ FIRST_USER_ID,
224
+ SECOND_USER_ID,
225
+ THIRD_USER_ID,
226
+ ]);
227
+ expect(result.succeededUsers).toEqual([first, second, third]);
228
+ expect(result.failedUsers).toEqual([]);
229
+ });
230
+
231
+ test("stamps userId, teamId and projectId onto every created membership", async (): Promise<void> => {
232
+ const users: Array<User> = [
233
+ makeUser(FIRST_USER_ID),
234
+ makeUser(SECOND_USER_ID),
235
+ ];
236
+ const created: Array<TeamMember> = [];
237
+
238
+ await bulkAddUsersToProject({
239
+ users: users,
240
+ projectId: PROJECT_ID,
241
+ teamId: TEAM_ID,
242
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
243
+ created.push(teamMember);
244
+ },
245
+ });
246
+
247
+ expect(created).toHaveLength(2);
248
+ expect(created[0]!.userId?.toString()).toBe(FIRST_USER_ID);
249
+ expect(created[1]!.userId?.toString()).toBe(SECOND_USER_ID);
250
+
251
+ for (const teamMember of created) {
252
+ expect(teamMember.teamId).toEqual(TEAM_ID);
253
+
254
+ /*
255
+ * projectId is asserted on its own because nothing upstream can supply
256
+ * it: a master admin sends no tenant header, so dropping this line still
257
+ * type-checks and still renders fine, and only shows up as every single
258
+ * create coming back 400 once an admin actually runs the bulk action.
259
+ */
260
+ expect(teamMember.projectId).toEqual(PROJECT_ID);
261
+ }
262
+ });
263
+
264
+ test("sends the create through the admin ModelAPI when no create is injected", async (): Promise<void> => {
265
+ adminModelAPICreateMock.mockClear();
266
+
267
+ const user: User = makeUser(FIRST_USER_ID);
268
+
269
+ /*
270
+ * Every other test in this file replaces the create, so the real one -
271
+ * the only code path that ever runs in the browser - would otherwise be
272
+ * executed by nothing at all. A wrong call shape here (ModelAPI.create
273
+ * takes `{ model, modelType }`, not the `{ id, modelType, data }` of
274
+ * updateById that the sibling bulk actions on this page use) type-checks
275
+ * against nothing at runtime and fails on the wire for every user.
276
+ */
277
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
278
+ users: [user],
279
+ projectId: PROJECT_ID,
280
+ teamId: TEAM_ID,
281
+ hasAcceptedInvitation: true,
282
+ });
283
+
284
+ expect(adminModelAPICreateMock).toHaveBeenCalledTimes(1);
285
+
286
+ const createArgument: AdminModelAPICreateArgument = adminModelAPICreateMock
287
+ .mock.calls[0]![0] as AdminModelAPICreateArgument;
288
+
289
+ expect(Object.keys(createArgument).sort()).toEqual(["model", "modelType"]);
290
+ expect(createArgument.modelType).toBe(TeamMember);
291
+ expect(createArgument.model).toBeInstanceOf(TeamMember);
292
+ expect(createArgument.model.userId?.toString()).toBe(FIRST_USER_ID);
293
+ expect(createArgument.model.teamId).toEqual(TEAM_ID);
294
+ expect(createArgument.model.projectId).toEqual(PROJECT_ID);
295
+ expect(createArgument.model.hasAcceptedInvitation).toBe(true);
296
+
297
+ expect(result.succeededUsers).toEqual([user]);
298
+ expect(result.failedUsers).toEqual([]);
299
+ });
300
+
301
+ test("leaves the membership unaccepted when the checkbox was not ticked", async (): Promise<void> => {
302
+ let created: TeamMember | null = null;
303
+
304
+ await bulkAddUsersToProject({
305
+ users: [makeUser(FIRST_USER_ID)],
306
+ projectId: PROJECT_ID,
307
+ teamId: TEAM_ID,
308
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
309
+ created = teamMember;
310
+ },
311
+ });
312
+
313
+ /*
314
+ * Explicitly false, not undefined. Passing the raw optional straight
315
+ * through would leave the column unset on a normal invite, which is the
316
+ * one value TeamMemberService.onBeforeCreate reads to decide whether the
317
+ * user still owes an acceptance.
318
+ */
319
+ expect(created!.hasAcceptedInvitation).toBe(false);
320
+ });
321
+
322
+ test("marks the membership accepted when the admin asked for it", async (): Promise<void> => {
323
+ let created: TeamMember | null = null;
324
+
325
+ await bulkAddUsersToProject({
326
+ users: [makeUser(FIRST_USER_ID)],
327
+ projectId: PROJECT_ID,
328
+ teamId: TEAM_ID,
329
+ hasAcceptedInvitation: true,
330
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
331
+ created = teamMember;
332
+ },
333
+ });
334
+
335
+ /*
336
+ * The whole point of the checkbox: without it forwarded, the admin picks
337
+ * "accept automatically" and every user still lands in the invited-only
338
+ * state with no way to tell the two runs apart afterwards.
339
+ */
340
+ expect(created!.hasAcceptedInvitation).toBe(true);
341
+ });
342
+
343
+ test("keeps going after a create fails and attributes the failure to that user", async (): Promise<void> => {
344
+ const first: User = makeUser(FIRST_USER_ID);
345
+ const failing: User = makeUser(SECOND_USER_ID);
346
+ const last: User = makeUser(THIRD_USER_ID);
347
+ const attemptedUserIds: Array<string> = [];
348
+
349
+ const createTeamMember: (teamMember: TeamMember) => Promise<void> = jest.fn(
350
+ async (teamMember: TeamMember): Promise<void> => {
351
+ const userId: string = teamMember.userId!.toString();
352
+ attemptedUserIds.push(userId);
353
+
354
+ if (userId === SECOND_USER_ID) {
355
+ throw new Error("Create denied");
356
+ }
357
+ },
358
+ );
359
+
360
+ /*
361
+ * One bad user must not abort the run. An early return - or a single
362
+ * try/catch wrapped around the whole loop - would silently drop every user
363
+ * queued behind the first failure, and the progress modal would show them
364
+ * as neither succeeded nor failed.
365
+ *
366
+ * This create throws synchronously, so the recorded order says nothing
367
+ * about whether the loop is sequential; it is here only to name which
368
+ * users were attempted. The same isolation under a create that rejects
369
+ * asynchronously is pinned by "a create that rejects for the first user
370
+ * does not stop the users queued behind it" below.
371
+ */
372
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
373
+ users: [first, failing, last],
374
+ projectId: PROJECT_ID,
375
+ teamId: TEAM_ID,
376
+ createTeamMember: createTeamMember,
377
+ });
378
+
379
+ expect(attemptedUserIds).toEqual([
380
+ FIRST_USER_ID,
381
+ SECOND_USER_ID,
382
+ THIRD_USER_ID,
383
+ ]);
384
+ expect(result.succeededUsers).toEqual([first, last]);
385
+ expect(result.failedUsers).toHaveLength(1);
386
+ expect(result.failedUsers[0]!.user).toBe(failing);
387
+ expect(result.failedUsers[0]!.failedMessage).toBe("Create denied");
388
+ });
389
+
390
+ test("a create that rejects for the first user does not stop the users queued behind it", async (): Promise<void> => {
391
+ const failing: User = makeUser(FIRST_USER_ID);
392
+ const second: User = makeUser(SECOND_USER_ID);
393
+ const third: User = makeUser(THIRD_USER_ID);
394
+
395
+ const startedUserIds: Array<string> = [];
396
+ const gates: Map<string, Deferred> = makeGates([
397
+ FIRST_USER_ID,
398
+ SECOND_USER_ID,
399
+ THIRD_USER_ID,
400
+ ]);
401
+
402
+ const runPromise: Promise<BulkAddUsersToProjectResult> =
403
+ bulkAddUsersToProject({
404
+ users: [failing, second, third],
405
+ projectId: PROJECT_ID,
406
+ teamId: TEAM_ID,
407
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
408
+ const userId: string = teamMember.userId!.toString();
409
+ startedUserIds.push(userId);
410
+ await gates.get(userId)!.promise;
411
+ },
412
+ });
413
+
414
+ /*
415
+ * A real create fails on the wire, i.e. by rejecting a promise that was
416
+ * already in flight, not by throwing before it ever awaits. Driving the
417
+ * rejection from the test rather than from the resolution order proves the
418
+ * loop resumes from the catch and reaches the next user: an implementation
419
+ * that let the rejection escape the loop body would leave users #2 and #3
420
+ * unstarted forever and this run would never settle.
421
+ */
422
+ await flushPendingWork();
423
+ expect(startedUserIds).toEqual([FIRST_USER_ID]);
424
+
425
+ gates.get(FIRST_USER_ID)!.reject(new Error("Create denied"));
426
+ await flushPendingWork();
427
+ expect(startedUserIds).toEqual([FIRST_USER_ID, SECOND_USER_ID]);
428
+
429
+ gates.get(SECOND_USER_ID)!.resolve();
430
+ await flushPendingWork();
431
+ expect(startedUserIds).toEqual([
432
+ FIRST_USER_ID,
433
+ SECOND_USER_ID,
434
+ THIRD_USER_ID,
435
+ ]);
436
+
437
+ gates.get(THIRD_USER_ID)!.resolve();
438
+
439
+ const result: BulkAddUsersToProjectResult = await runPromise;
440
+
441
+ expect(result.succeededUsers).toEqual([second, third]);
442
+ expect(result.failedUsers).toHaveLength(1);
443
+ expect(result.failedUsers[0]!.user).toBe(failing);
444
+ expect(result.failedUsers[0]!.failedMessage).toBe("Create denied");
445
+ expect(result.inProgressUsers).toEqual([]);
446
+ });
447
+
448
+ test("reports an already-invited user as a per-user failure instead of throwing", async (): Promise<void> => {
449
+ const alreadyInvited: User = makeUser(FIRST_USER_ID);
450
+ const newMember: User = makeUser(SECOND_USER_ID);
451
+
452
+ const createTeamMember: (teamMember: TeamMember) => Promise<void> = jest.fn(
453
+ async (teamMember: TeamMember): Promise<void> => {
454
+ if (teamMember.userId!.toString() === FIRST_USER_ID) {
455
+ throw new HTTPErrorResponse(
456
+ 400,
457
+ { message: Errors.TeamMemberService.ALREADY_INVITED },
458
+ {},
459
+ );
460
+ }
461
+ },
462
+ );
463
+
464
+ /*
465
+ * There is deliberately no client-side pre-check for existing membership,
466
+ * so re-running the action over an overlapping selection is expected and
467
+ * routine. If this call rejected, the page would never reach
468
+ * onBulkActionEnd and the progress modal would stay open forever with its
469
+ * close button disabled.
470
+ */
471
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
472
+ users: [alreadyInvited, newMember],
473
+ projectId: PROJECT_ID,
474
+ teamId: TEAM_ID,
475
+ createTeamMember: createTeamMember,
476
+ });
477
+
478
+ expect(result.failedUsers).toHaveLength(1);
479
+ expect(result.failedUsers[0]!.user).toBe(alreadyInvited);
480
+ expect(result.failedUsers[0]!.failedMessage).toBe(
481
+ Errors.TeamMemberService.ALREADY_INVITED,
482
+ );
483
+ expect(result.succeededUsers).toEqual([newMember]);
484
+ });
485
+
486
+ test("counts a repeated user once instead of failing the second copy", async (): Promise<void> => {
487
+ const user: User = makeUser(FIRST_USER_ID);
488
+ const sameUserAgain: User = makeUser(FIRST_USER_ID);
489
+ const other: User = makeUser(SECOND_USER_ID);
490
+ const createdUserIds: Array<string> = [];
491
+
492
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
493
+ users: [user, sameUserAgain, other],
494
+ projectId: PROJECT_ID,
495
+ teamId: TEAM_ID,
496
+ createTeamMember: async (teamMember: TeamMember): Promise<void> => {
497
+ createdUserIds.push(teamMember.userId!.toString());
498
+ },
499
+ });
500
+
501
+ /*
502
+ * Without the de-duplication the second copy is a real create that the
503
+ * server rejects, so the admin is told a user they picked once "has
504
+ * already been invited" - a failure their selection did not cause and
505
+ * that they cannot act on.
506
+ */
507
+ expect(createdUserIds).toEqual([FIRST_USER_ID, SECOND_USER_ID]);
508
+ expect(result.totalUsers).toHaveLength(2);
509
+ expect(result.succeededUsers).toHaveLength(2);
510
+ expect(result.succeededUsers[0]).toBe(user);
511
+ expect(result.failedUsers).toEqual([]);
512
+ });
513
+
514
+ test("fails a user with no id without sending a create for them", async (): Promise<void> => {
515
+ const missingId: User = makeUser(null);
516
+ const valid: User = makeUser(FIRST_USER_ID);
517
+
518
+ const createTeamMember: (teamMember: TeamMember) => Promise<void> = jest.fn(
519
+ async (_teamMember: TeamMember): Promise<void> => {},
520
+ );
521
+
522
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
523
+ users: [missingId, valid],
524
+ projectId: PROJECT_ID,
525
+ teamId: TEAM_ID,
526
+ createTeamMember: createTeamMember,
527
+ });
528
+
529
+ /*
530
+ * The id has to be checked before the create is issued. A TeamMember with
531
+ * no userId is a round trip that can only come back as an opaque server
532
+ * error, and on a self-hosted install it is a write attempt that should
533
+ * never have left the browser.
534
+ */
535
+ expect(createTeamMember).toHaveBeenCalledTimes(1);
536
+ expect(result.failedUsers).toHaveLength(1);
537
+ expect(result.failedUsers[0]!.user).toBe(missingId);
538
+ expect(result.failedUsers[0]!.failedMessage).toBe("User ID not found.");
539
+ expect(result.succeededUsers).toEqual([valid]);
540
+ });
541
+
542
+ test("emits progress once per user with the pending count draining to zero", async (): Promise<void> => {
543
+ const users: Array<User> = [
544
+ makeUser(FIRST_USER_ID),
545
+ makeUser(SECOND_USER_ID),
546
+ makeUser(THIRD_USER_ID),
547
+ ];
548
+ const inProgressCounts: Array<number> = [];
549
+
550
+ await bulkAddUsersToProject({
551
+ users: users,
552
+ projectId: PROJECT_ID,
553
+ teamId: TEAM_ID,
554
+ createTeamMember: async (): Promise<void> => {},
555
+ onProgress: (progress: BulkAddUsersToProjectProgress): void => {
556
+ inProgressCounts.push(progress.inProgressUsers.length);
557
+ },
558
+ });
559
+
560
+ /*
561
+ * Two separate contracts, neither of them the progress bar's arithmetic.
562
+ *
563
+ * The bar itself does not read inProgressUsers at all: BulkUpdateForm
564
+ * renders ProgressBar with count={successItems.length + failed.length} and
565
+ * totalCount={totalItems.length}, and inProgressItems is only ever used to
566
+ * seed the state in onBulkActionStart. What the bar needs from here is
567
+ * simply that an emission arrives per user - a skipped one parks the bar
568
+ * short of done even though the run finished.
569
+ *
570
+ * The drain to zero is read by Pages/Users/Index.tsx instead: it keeps the
571
+ * last emission, and if the run itself throws mid-way it reports exactly
572
+ * the users still sitting in inProgressUsers as failed. An inProgressUsers
573
+ * that never drained would blame users who had already been added.
574
+ */
575
+ expect(inProgressCounts).toEqual([2, 1, 0]);
576
+ });
577
+
578
+ test("emits an independent snapshot each time rather than the live arrays", async (): Promise<void> => {
579
+ const users: Array<User> = [
580
+ makeUser(FIRST_USER_ID),
581
+ makeUser(SECOND_USER_ID),
582
+ makeUser(THIRD_USER_ID),
583
+ ];
584
+ const emissions: Array<BulkAddUsersToProjectProgress> = [];
585
+
586
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
587
+ users: users,
588
+ projectId: PROJECT_ID,
589
+ teamId: TEAM_ID,
590
+ createTeamMember: async (): Promise<void> => {},
591
+ onProgress: (progress: BulkAddUsersToProjectProgress): void => {
592
+ emissions.push(progress);
593
+ },
594
+ });
595
+
596
+ /*
597
+ * Read the earlier emissions AFTER the run: if the callback were handed
598
+ * the loop's own arrays, every stored emission would now show the final
599
+ * state. React would also see the same array identity on each setState
600
+ * and skip the re-render, so the modal would sit at 0% and then jump.
601
+ */
602
+ expect(emissions).toHaveLength(3);
603
+ expect(emissions[0]!.inProgressUsers).toHaveLength(2);
604
+ expect(emissions[1]!.inProgressUsers).toHaveLength(1);
605
+ expect(emissions[2]!.inProgressUsers).toHaveLength(0);
606
+ expect(emissions[0]!.succeededUsers).toHaveLength(1);
607
+ expect(emissions[1]!.succeededUsers).toHaveLength(2);
608
+ expect(emissions[2]!.succeededUsers).toHaveLength(3);
609
+ expect(emissions[0]!.inProgressUsers).not.toBe(
610
+ emissions[1]!.inProgressUsers,
611
+ );
612
+ expect(emissions[0]!.succeededUsers).not.toBe(emissions[1]!.succeededUsers);
613
+
614
+ /*
615
+ * All FOUR arrays, across all three emissions and the returned result, so
616
+ * that a field which is merely aliased rather than copied cannot hide
617
+ * behind the two that are checked by name above. totalUsers is the one
618
+ * that used to be passed straight through: it never changes value, so no
619
+ * length assertion anywhere in this file can see the difference, and only
620
+ * the reference tells you that the page is holding the run's own array.
621
+ */
622
+ const snapshots: Array<BulkAddUsersToProjectProgress> = [
623
+ ...emissions,
624
+ result,
625
+ ];
626
+ const allEmittedArrays: Array<Array<unknown>> = [];
627
+
628
+ for (const snapshot of snapshots) {
629
+ allEmittedArrays.push(
630
+ snapshot.totalUsers,
631
+ snapshot.inProgressUsers,
632
+ snapshot.succeededUsers,
633
+ snapshot.failedUsers,
634
+ );
635
+ }
636
+
637
+ expect(allEmittedArrays).toHaveLength(16);
638
+
639
+ for (let index: number = 0; index < allEmittedArrays.length; index++) {
640
+ const array: Array<unknown> = allEmittedArrays[index]!;
641
+
642
+ /*
643
+ * Not the caller's own array either. Handing `options.users` back out
644
+ * would let the page mutate the run's input through what it thinks is a
645
+ * report.
646
+ */
647
+ expect(array).not.toBe(users);
648
+
649
+ for (
650
+ let other: number = index + 1;
651
+ other < allEmittedArrays.length;
652
+ other++
653
+ ) {
654
+ expect(array).not.toBe(allEmittedArrays[other]!);
655
+ }
656
+ }
657
+ });
658
+
659
+ test("is unaffected by a receiver that mutates the arrays it was handed", async (): Promise<void> => {
660
+ const users: Array<User> = [
661
+ makeUser(FIRST_USER_ID),
662
+ makeUser(SECOND_USER_ID),
663
+ makeUser(THIRD_USER_ID),
664
+ ];
665
+ const sentinel: User = makeUser(null);
666
+ const emissions: Array<BulkAddUsersToProjectProgress> = [];
667
+
668
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
669
+ users: users,
670
+ projectId: PROJECT_ID,
671
+ teamId: TEAM_ID,
672
+ createTeamMember: async (): Promise<void> => {},
673
+ onProgress: (progress: BulkAddUsersToProjectProgress): void => {
674
+ emissions.push(progress);
675
+
676
+ if (emissions.length > 1) {
677
+ return;
678
+ }
679
+
680
+ /*
681
+ * Only the first emission is vandalised, so everything after it is
682
+ * evidence about the run's own state rather than about this callback.
683
+ */
684
+ progress.totalUsers.push(sentinel);
685
+ progress.inProgressUsers.push(sentinel);
686
+ progress.succeededUsers.push(sentinel);
687
+ progress.failedUsers.push({
688
+ user: sentinel,
689
+ failedMessage: "sentinel",
690
+ });
691
+ },
692
+ });
693
+
694
+ /*
695
+ * The reference checks in the previous test show the arrays are copies;
696
+ * this shows the copy is the thing that is handed out rather than the
697
+ * thing the loop keeps. The page really does write to what it is given -
698
+ * BulkUpdateForm stores the emission in React state and the sibling bulk
699
+ * actions on this page splice their own progress arrays in place - so an
700
+ * emission that aliased the loop's state would let a receiver corrupt the
701
+ * run: inProgressUsers would stop draining to zero and the final report
702
+ * would carry rows that no create ever produced.
703
+ */
704
+ expect(emissions).toHaveLength(3);
705
+ expect(emissions[1]!.totalUsers).toHaveLength(3);
706
+ expect(emissions[1]!.inProgressUsers).toHaveLength(1);
707
+ expect(emissions[1]!.succeededUsers).toHaveLength(2);
708
+ expect(emissions[1]!.failedUsers).toEqual([]);
709
+ expect(emissions[2]!.totalUsers).toEqual(users);
710
+ expect(emissions[2]!.inProgressUsers).toEqual([]);
711
+ expect(emissions[2]!.succeededUsers).toEqual(users);
712
+ expect(emissions[2]!.failedUsers).toEqual([]);
713
+
714
+ expect(result.totalUsers).toEqual(users);
715
+ expect(result.inProgressUsers).toEqual([]);
716
+ expect(result.succeededUsers).toEqual(users);
717
+ expect(result.failedUsers).toEqual([]);
718
+ });
719
+
720
+ test("returns exactly what the last progress emission reported", async (): Promise<void> => {
721
+ const first: User = makeUser(FIRST_USER_ID);
722
+ const failing: User = makeUser(SECOND_USER_ID);
723
+ const last: User = makeUser(THIRD_USER_ID);
724
+ const emissions: Array<BulkAddUsersToProjectProgress> = [];
725
+
726
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
727
+ users: [first, failing, last],
728
+ projectId: PROJECT_ID,
729
+ teamId: TEAM_ID,
730
+ createTeamMember: jest.fn(
731
+ async (teamMember: TeamMember): Promise<void> => {
732
+ if (teamMember.userId!.toString() === SECOND_USER_ID) {
733
+ throw new Error("Create denied");
734
+ }
735
+ },
736
+ ),
737
+ onProgress: (progress: BulkAddUsersToProjectProgress): void => {
738
+ emissions.push(progress);
739
+ },
740
+ });
741
+
742
+ /*
743
+ * The page renders the live progress while the run is going and then
744
+ * swaps in the returned result when it ends. If the two disagreed, a
745
+ * user's row would flip between succeeded and failed at the moment the
746
+ * run finished.
747
+ */
748
+ expect(result).toEqual(emissions[emissions.length - 1]);
749
+ expect(result.totalUsers).toHaveLength(3);
750
+ expect(result.succeededUsers).toEqual([first, last]);
751
+ expect(result.failedUsers).toHaveLength(1);
752
+ expect(result.inProgressUsers).toEqual([]);
753
+ });
754
+
755
+ test("does nothing when the selection is empty", async (): Promise<void> => {
756
+ const createTeamMember: (teamMember: TeamMember) => Promise<void> = jest.fn(
757
+ async (_teamMember: TeamMember): Promise<void> => {},
758
+ );
759
+
760
+ const result: BulkAddUsersToProjectResult = await bulkAddUsersToProject({
761
+ users: [],
762
+ projectId: PROJECT_ID,
763
+ teamId: TEAM_ID,
764
+ createTeamMember: createTeamMember,
765
+ });
766
+
767
+ /*
768
+ * The modal can be submitted with nothing usable selected, and this still
769
+ * has to resolve so the page reaches onBulkActionEnd - the only thing that
770
+ * re-enables the progress modal's close button.
771
+ */
772
+ expect(createTeamMember).not.toHaveBeenCalled();
773
+ expect(result.totalUsers).toEqual([]);
774
+ expect(result.inProgressUsers).toEqual([]);
775
+ expect(result.succeededUsers).toEqual([]);
776
+ expect(result.failedUsers).toEqual([]);
777
+ });
778
+ });