@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.
- package/Models/DatabaseModels/NetworkDevice.ts +44 -0
- package/Models/DatabaseModels/NetworkDeviceLink.ts +116 -0
- package/Models/DatabaseModels/RunnerJob.ts +17 -2
- package/Server/API/LlmProviderAPI.ts +8 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.ts +52 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
- package/Server/Services/NetworkDeviceLabelRuleEngineService.ts +240 -0
- package/Server/Services/NetworkDeviceLinkService.ts +338 -2
- package/Server/Services/NetworkDeviceService.ts +265 -12
- package/Server/Services/RunnerJobService.ts +24 -1
- package/Server/Types/Billing/MeteredPlan/AllMeteredPlans.ts +19 -5
- package/Server/Types/Database/JSONColumnQuery.ts +42 -4
- package/Server/Utils/LLM/LLMService.ts +108 -19
- package/Server/Utils/Monitor/MonitorAlert.ts +22 -115
- package/Server/Utils/Monitor/MonitorIncident.ts +7 -337
- package/Server/Utils/Monitor/MonitorMaintenanceSuppression.ts +27 -2
- package/Server/Utils/Monitor/SeriesResourceLabels.ts +32 -0
- package/Server/Utils/Monitor/SeriesResourceLinker.ts +469 -0
- package/Server/Utils/SSRFProtection.ts +5 -4
- package/Server/Utils/Telemetry/InventoryEntityRegistry.ts +186 -38
- package/Tests/App/AdminDashboard/AdminUsersBulkAddToProject.test.tsx +955 -0
- package/Tests/App/AdminDashboard/BulkAddUsersToProject.test.ts +778 -0
- package/Tests/App/AdminDashboard/BulkAddUsersToProjectModal.test.tsx +1051 -0
- package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +420 -112
- package/Tests/App/Dashboard/OnCallReadinessSurfaces.test.tsx +81 -6
- package/Tests/App/Dashboard/PayAsYouGoConsentGate.test.tsx +543 -0
- package/Tests/App/Dashboard/PayAsYouGoFreePlan.test.ts +125 -0
- package/Tests/App/Dashboard/PayAsYouGoNotices.test.tsx +612 -0
- package/Tests/App/Dashboard/SloWidgetChartRendering.test.tsx +536 -0
- package/Tests/App/Dashboard/UserElementEmail.test.tsx +447 -0
- package/Tests/App/Dashboard/UserEmailCallSites.test.tsx +284 -0
- package/Tests/Models/AnalyticsModels/CommonModel.test.ts +312 -0
- package/Tests/Server/Infrastructure/Postgres/DeviceRoleAndDeclaredLinkParentMigration.test.ts +250 -0
- package/Tests/Server/Infrastructure/Postgres/EpisodeMemberNotifyIndexesMigration.test.ts +46 -18
- package/Tests/Server/Services/NetworkDeviceLabelRuleRun.test.ts +618 -0
- package/Tests/Server/Services/NetworkDeviceLinkParentValidation.test.ts +1043 -0
- package/Tests/Server/Services/NetworkSiteAssignmentRuleRun.test.ts +706 -0
- package/Tests/Server/Services/RunnerJobEnqueue.test.ts +412 -0
- package/Tests/Server/Types/Billing/PayAsYouGoMeteredPricing.test.ts +125 -0
- package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +508 -0
- package/Tests/Server/Utils/AI/LLMServiceRequestPolicy.test.ts +2 -2
- package/Tests/Server/Utils/AI/LLMServiceRetryPolicy.test.ts +450 -0
- package/Tests/Server/Utils/Monitor/MonitorAlertResourceLinking.test.ts +556 -0
- package/Tests/Server/Utils/Monitor/MonitorIncidentResourceLinking.test.ts +547 -0
- package/Tests/Server/Utils/Monitor/MonitorMaintenanceSuppression.test.ts +30 -0
- package/Tests/Server/Utils/Monitor/SeriesResourceLinker.test.ts +699 -0
- package/Tests/Server/Utils/SeriesResourceLabels.test.ts +55 -0
- package/Tests/Server/Utils/Telemetry/InventoryEntityRegistry.test.ts +668 -0
- package/Tests/Types/API/URLMalformedTolerance.test.ts +301 -0
- package/Tests/Types/API/URLTelScheme.test.ts +261 -0
- package/Tests/Types/API/URLUnsupportedScheme.test.ts +148 -0
- package/Tests/Types/Billing/PayAsYouGoPricing.test.ts +64 -0
- package/Tests/Types/CustomField/CustomFieldTypeCorrespondence.test.ts +107 -0
- package/Tests/Types/Monitor/MonitorType.test.ts +62 -0
- package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +184 -0
- package/Tests/Types/Runbook/RunbookStepType.test.ts +124 -0
- package/Tests/Types/Workflow/BaseModelComponents.test.ts +17 -0
- package/Tests/UI/Components/Button.test.tsx +279 -0
- package/Tests/UI/Components/Charts/ChartXAxisDataKey.test.tsx +256 -0
- package/Tests/UI/Components/Charts/DataPointUtil.test.ts +323 -0
- package/Tests/UI/Components/DictionaryValuePlaceholderAndSuggestions.test.tsx +595 -0
- package/Tests/UI/Components/Icon.test.tsx +57 -0
- package/Tests/UI/Components/List.test.tsx +20 -3
- package/Tests/UI/Components/LogsSavedViewsDropdown.test.tsx +304 -0
- package/Tests/UI/Components/LogsViewerClearSavedView.test.tsx +179 -0
- package/Tests/UI/Components/ModelTable/CustomFieldColumns.test.tsx +349 -1
- package/Tests/UI/Components/Pagination.test.tsx +721 -146
- package/Tests/UI/Components/PaginationIntegration.test.tsx +386 -0
- package/Tests/UI/Components/PaginationUtil.test.ts +417 -0
- package/Tests/UI/Components/TelemetrySavedViewsDropdown.test.tsx +343 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnControl.test.ts +289 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnOperators.test.ts +271 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRow.test.ts +119 -0
- package/Tests/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.test.ts +968 -0
- package/Tests/UI/Components/Workflow/ModelColumnEditor.render.test.tsx +766 -0
- package/Tests/UI/Utils/RunNetworkRule.test.ts +320 -0
- package/Tests/Utils/APIRetryPolicy.test.ts +823 -0
- package/Tests/Utils/Monitor/NetworkDeviceRoleUtil.test.ts +502 -0
- package/Tests/Utils/Monitor/NetworkTopologyDeclaredHierarchy.test.ts +759 -0
- package/Tests/Utils/NetworkAutomation/RuleRunSummary.test.ts +286 -0
- package/Tests/Utils/Schema/BaseSchema.test.ts +651 -0
- package/Types/API/Protocol.ts +9 -0
- package/Types/API/URL.ts +286 -20
- package/Types/Billing/PayAsYouGoPricing.ts +47 -0
- package/Types/CustomField/CustomFieldType.ts +28 -0
- package/Types/Monitor/MonitorType.ts +11 -0
- package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
- package/Types/NetworkAutomation/RuleRunResult.ts +121 -0
- package/Types/Runbook/RunbookStepType.ts +23 -0
- package/UI/Components/AutocompleteTextInput/AutocompleteTextInput.tsx +7 -0
- package/UI/Components/Button/Button.tsx +14 -22
- package/UI/Components/Charts/Area/AreaChart.tsx +4 -2
- package/UI/Components/Charts/Bar/BarChart.tsx +4 -2
- package/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.ts +13 -0
- package/UI/Components/Charts/Line/LineChart.tsx +4 -2
- package/UI/Components/Charts/Types/XAxis/XAxis.ts +6 -0
- package/UI/Components/Charts/Utils/DataPoint.ts +15 -12
- package/UI/Components/Dictionary/Dictionary.tsx +4 -39
- package/UI/Components/Icon/Icon.tsx +1 -1
- package/UI/Components/LogsViewer/LogsViewer.tsx +5 -1
- package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +8 -0
- package/UI/Components/LogsViewer/components/LogsPagination.tsx +29 -91
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +2 -0
- package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +66 -1
- package/UI/Components/ModelTable/CustomFieldColumns.tsx +55 -0
- package/UI/Components/Pagination/Pagination.tsx +373 -328
- package/UI/Components/Pagination/PaginationUtil.ts +263 -0
- package/UI/Components/Pagination/Screenshots/README.md +15 -0
- package/UI/Components/Pagination/Screenshots/pagination-before-after.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-go-to-page-modal.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-long-list.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-mobile.png +0 -0
- package/UI/Components/Pagination/Screenshots/pagination-telemetry.png +0 -0
- package/UI/Components/TelemetryViewer/components/SavedViewsDropdown.tsx +66 -1
- package/UI/Components/TelemetryViewer/components/TelemetryPagination.tsx +38 -92
- package/UI/Components/TextArea/TextArea.tsx +3 -0
- package/UI/Components/Workflow/ArgumentsForm.tsx +18 -1
- package/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.tsx +177 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.tsx +243 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnControl.ts +255 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.tsx +178 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnOperators.ts +206 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnRow.ts +157 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.ts +517 -0
- package/UI/Components/Workflow/ColumnEditor/ColumnValueInput.tsx +482 -0
- package/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.tsx +131 -0
- package/UI/Components/Workflow/ColumnEditor/ModelRecordForm.tsx +163 -0
- package/UI/Components/Workflow/ModelColumnEditor.tsx +284 -100
- package/UI/Utils/NetworkAutomation/RunNetworkRule.ts +122 -0
- package/Utils/API.ts +243 -36
- package/Utils/Monitor/NetworkDeviceRoleUtil.ts +45 -0
- package/Utils/Monitor/NetworkTopologyUtil.ts +42 -2
- package/Utils/NetworkAutomation/RuleRunSummary.ts +147 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
- package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js +115 -0
- package/build/dist/Models/DatabaseModels/NetworkDeviceLink.js.map +1 -1
- package/build/dist/Models/DatabaseModels/RunnerJob.js +17 -2
- package/build/dist/Models/DatabaseModels/RunnerJob.js.map +1 -1
- package/build/dist/Server/API/LlmProviderAPI.js +9 -1
- package/build/dist/Server/API/LlmProviderAPI.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js +33 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1787400000000-AddDeviceRoleAndDeclaredLinkParent.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js +202 -0
- package/build/dist/Server/Services/NetworkDeviceLabelRuleEngineService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js +253 -1
- package/build/dist/Server/Services/NetworkDeviceLinkService.js.map +1 -1
- package/build/dist/Server/Services/NetworkDeviceService.js +223 -12
- package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
- package/build/dist/Server/Services/RunnerJobService.js +20 -2
- package/build/dist/Server/Services/RunnerJobService.js.map +1 -1
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js +12 -5
- package/build/dist/Server/Types/Billing/MeteredPlan/AllMeteredPlans.js.map +1 -1
- package/build/dist/Server/Types/Database/JSONColumnQuery.js +40 -4
- package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -1
- package/build/dist/Server/Utils/LLM/LLMService.js +78 -22
- package/build/dist/Server/Utils/LLM/LLMService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js +22 -98
- package/build/dist/Server/Utils/Monitor/MonitorAlert.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js +7 -254
- package/build/dist/Server/Utils/Monitor/MonitorIncident.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js +14 -2
- package/build/dist/Server/Utils/Monitor/MonitorMaintenanceSuppression.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js +27 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLabels.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js +285 -0
- package/build/dist/Server/Utils/Monitor/SeriesResourceLinker.js.map +1 -0
- package/build/dist/Server/Utils/SSRFProtection.js +5 -4
- package/build/dist/Server/Utils/SSRFProtection.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js +128 -35
- package/build/dist/Server/Utils/Telemetry/InventoryEntityRegistry.js.map +1 -1
- package/build/dist/Types/API/Protocol.js +9 -0
- package/build/dist/Types/API/Protocol.js.map +1 -1
- package/build/dist/Types/API/URL.js +241 -18
- package/build/dist/Types/API/URL.js.map +1 -1
- package/build/dist/Types/Billing/PayAsYouGoPricing.js +41 -0
- package/build/dist/Types/Billing/PayAsYouGoPricing.js.map +1 -0
- package/build/dist/Types/CustomField/CustomFieldType.js +26 -0
- package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
- package/build/dist/Types/Monitor/MonitorType.js +10 -0
- package/build/dist/Types/Monitor/MonitorType.js.map +1 -1
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js +39 -0
- package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -0
- package/build/dist/Types/Runbook/RunbookStepType.js +21 -0
- package/build/dist/Types/Runbook/RunbookStepType.js.map +1 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js +1 -1
- package/build/dist/UI/Components/AutocompleteTextInput/AutocompleteTextInput.js.map +1 -1
- package/build/dist/UI/Components/Button/Button.js +10 -16
- package/build/dist/UI/Components/Button/Button.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +2 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +2 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js +12 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Types/ChartDataPoint.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +2 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js +17 -10
- package/build/dist/UI/Components/Charts/Utils/DataPoint.js.map +1 -1
- package/build/dist/UI/Components/Dictionary/Dictionary.js +4 -18
- package/build/dist/UI/Components/Dictionary/Dictionary.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +1 -1
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +3 -3
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +5 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js +14 -44
- package/build/dist/UI/Components/LogsViewer/components/LogsPagination.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +20 -2
- package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -1
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js +42 -0
- package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
- package/build/dist/UI/Components/Pagination/Pagination.js +174 -199
- package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
- package/build/dist/UI/Components/Pagination/PaginationUtil.js +160 -0
- package/build/dist/UI/Components/Pagination/PaginationUtil.js.map +1 -0
- package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js +20 -2
- package/build/dist/UI/Components/TelemetryViewer/components/SavedViewsDropdown.js.map +1 -1
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js +23 -47
- package/build/dist/UI/Components/TelemetryViewer/components/TelemetryPagination.js.map +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js +1 -1
- package/build/dist/UI/Components/TextArea/TextArea.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js +14 -2
- package/build/dist/UI/Components/Workflow/ArgumentsForm.js.map +1 -1
- package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js +86 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/AddColumnDropdown.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js +119 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnConditionRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js +206 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnControl.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js +57 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnFieldRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js +150 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnOperators.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js +67 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRow.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js +309 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnRowSerialization.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js +204 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ColumnValueInput.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js +66 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelQueryBuilder.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js +83 -0
- package/build/dist/UI/Components/Workflow/ColumnEditor/ModelRecordForm.js.map +1 -0
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js +187 -68
- package/build/dist/UI/Components/Workflow/ModelColumnEditor.js.map +1 -1
- package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js +75 -0
- package/build/dist/UI/Utils/NetworkAutomation/RunNetworkRule.js.map +1 -0
- package/build/dist/Utils/API.js +175 -34
- package/build/dist/Utils/API.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +37 -0
- package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +25 -3
- package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
- package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js +78 -0
- package/build/dist/Utils/NetworkAutomation/RuleRunSummary.js.map +1 -0
- 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
|
+
});
|