@oneuptime/common 12.0.0 → 12.0.2

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 (237) hide show
  1. package/Models/DatabaseModels/Host.ts +12 -4
  2. package/Models/DatabaseModels/KubernetesContainer.ts +5 -4
  3. package/Models/DatabaseModels/KubernetesResource.ts +8 -7
  4. package/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
  5. package/Models/DatabaseModels/PodmanResource.ts +6 -6
  6. package/Models/DatabaseModels/ServiceLevelObjective.ts +91 -0
  7. package/Models/DatabaseModels/StatusPage.ts +95 -0
  8. package/Server/API/TeamMemberAPI.ts +85 -0
  9. package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.ts +84 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.ts +40 -0
  12. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.ts +131 -0
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.ts +25 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  15. package/Server/Middleware/ProjectAuthorization.ts +66 -4
  16. package/Server/Services/BillingService.ts +600 -51
  17. package/Server/Services/CephClusterService.ts +47 -5
  18. package/Server/Services/CephResourceService.ts +43 -4
  19. package/Server/Services/CloudResourceService.ts +47 -5
  20. package/Server/Services/DatabaseService.ts +87 -6
  21. package/Server/Services/DockerHostService.ts +47 -5
  22. package/Server/Services/DockerResourceService.ts +62 -8
  23. package/Server/Services/DockerSwarmClusterService.ts +48 -5
  24. package/Server/Services/DockerSwarmResourceService.ts +53 -4
  25. package/Server/Services/HostService.ts +48 -5
  26. package/Server/Services/IoTDeviceService.ts +1 -17
  27. package/Server/Services/IoTFleetService.ts +47 -5
  28. package/Server/Services/KubernetesClusterService.ts +48 -5
  29. package/Server/Services/KubernetesContainerService.ts +50 -9
  30. package/Server/Services/KubernetesResourceService.ts +53 -7
  31. package/Server/Services/LabelService.ts +106 -1
  32. package/Server/Services/LlmProviderService.ts +69 -9
  33. package/Server/Services/MonitorService.ts +57 -0
  34. package/Server/Services/PodmanHostService.ts +47 -5
  35. package/Server/Services/PodmanResourceService.ts +62 -8
  36. package/Server/Services/ProjectService.ts +165 -35
  37. package/Server/Services/ProxmoxClusterService.ts +47 -5
  38. package/Server/Services/ProxmoxResourceService.ts +43 -4
  39. package/Server/Services/RumApplicationService.ts +48 -5
  40. package/Server/Services/RunnerService.ts +1 -7
  41. package/Server/Services/ServerlessFunctionService.ts +48 -5
  42. package/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.ts +426 -0
  43. package/Server/Services/ServiceLevelObjectiveService.ts +39 -0
  44. package/Server/Services/StatusPageService.ts +95 -36
  45. package/Server/Services/StatusPageSubscriberNotificationTemplateService.ts +18 -1
  46. package/Server/Services/StatusPageSubscriberService.ts +3 -0
  47. package/Server/Types/Database/JSONColumnQuery.ts +621 -0
  48. package/Server/Types/Database/Permissions/PublicPermission.ts +9 -2
  49. package/Server/Types/Database/QueryHelper.ts +17 -32
  50. package/Server/Utils/Database/TruncateColumnValue.ts +84 -0
  51. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
  52. package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
  53. package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
  54. package/Tests/App/Dashboard/UsersTableGroupsByPerson.test.tsx +448 -0
  55. package/Tests/Server/API/BaseAPIApiKeyAuth.test.ts +704 -0
  56. package/Tests/Server/API/TeamMemberRemoveUserFromProjectAPI.test.ts +303 -0
  57. package/Tests/Server/Middleware/MasterAdminAuthorization.test.ts +350 -0
  58. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyHeader.test.ts +362 -0
  59. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +687 -0
  60. package/Tests/Server/Services/BillingService.test.ts +78 -3
  61. package/Tests/Server/Services/BillingServiceChangePlanCancel.test.ts +645 -0
  62. package/Tests/Server/Services/BillingServiceTrialPlanChange.test.ts +1005 -0
  63. package/Tests/Server/Services/HookFreeWriteLengthClamp.test.ts +503 -0
  64. package/Tests/Server/Services/HostIpAddressesColumnWidth.test.ts +198 -0
  65. package/Tests/Server/Services/HostServiceUpdateLastSeen.test.ts +510 -0
  66. package/Tests/Server/Services/InventoryResourceNameColumnWidth.test.ts +500 -0
  67. package/Tests/Server/Services/InventoryUpsertSchemaParity.test.ts +586 -0
  68. package/Tests/Server/Services/InventoryUpsertTruncationGuard.test.ts +1242 -0
  69. package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
  70. package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +650 -0
  71. package/Tests/Server/Services/ProjectServiceExtendTrial.test.ts +1 -0
  72. package/Tests/Server/Services/ResourceUpdateLastSeenLivenessFallback.test.ts +387 -0
  73. package/Tests/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.test.ts +770 -0
  74. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +130 -0
  75. package/Tests/Server/Services/SloMonitorLabelRuleHooks.test.ts +352 -0
  76. package/Tests/Server/Services/StatusPageSubscriberReport.test.ts +29 -13
  77. package/Tests/Server/TestingUtils/Services/BillingServiceHelper.ts +69 -8
  78. package/Tests/Server/Types/Database/JSONColumnQuery.test.ts +593 -0
  79. package/Tests/Server/Types/Database/Permissions/PublicPermission.test.ts +495 -0
  80. package/Tests/Server/Types/Database/QueryUtil.test.ts +113 -0
  81. package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
  82. package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
  83. package/Tests/Server/Utils/Database/TruncateColumnValue.test.ts +179 -0
  84. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
  85. package/Tests/Types/Events/Recurring.test.ts +157 -0
  86. package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
  87. package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
  88. package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
  89. package/Tests/UI/Components/ModelTable/ModelTableGroupsProjectUsers.test.tsx +375 -0
  90. package/Tests/UI/Components/ModelTable/useCustomFieldColumns.test.tsx +196 -0
  91. package/Tests/UI/Utils/ProjectUsersModelAPI.test.ts +739 -0
  92. package/Tests/UI/Utils/TeamMembersByUser.test.ts +633 -0
  93. package/Tests/Utils/StatusPage/ReportPeriod.test.ts +218 -0
  94. package/Tests/Utils/Telemetry/HostIpAddresses.test.ts +291 -0
  95. package/Types/CustomField/CustomFieldDefinition.ts +24 -0
  96. package/Types/Date.ts +112 -0
  97. package/Types/Events/Recurring.ts +99 -1
  98. package/Types/Runbook/RunbookStep.ts +15 -0
  99. package/Types/Runner/RunnerLiveStatus.ts +114 -0
  100. package/Types/StatusPage/StatusPageReport.ts +11 -0
  101. package/Types/StatusPage/StatusPageReportPeriodType.ts +21 -0
  102. package/UI/Components/Detail/Detail.tsx +155 -0
  103. package/UI/Components/ModelTable/CustomFieldColumns.tsx +2 -6
  104. package/UI/Components/ModelTable/useCustomFieldColumns.ts +21 -2
  105. package/UI/Utils/ModelAPI/ProjectUsersModelAPI.ts +305 -0
  106. package/UI/Utils/TeamMembersByUser.ts +302 -0
  107. package/Utils/StatusPage/ReportPeriod.ts +236 -0
  108. package/Utils/Telemetry/HostIpAddresses.ts +98 -0
  109. package/build/dist/Models/DatabaseModels/Host.js +12 -4
  110. package/build/dist/Models/DatabaseModels/Host.js.map +1 -1
  111. package/build/dist/Models/DatabaseModels/KubernetesContainer.js +4 -4
  112. package/build/dist/Models/DatabaseModels/KubernetesContainer.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/KubernetesResource.js +7 -7
  114. package/build/dist/Models/DatabaseModels/KubernetesResource.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
  116. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/PodmanResource.js +6 -6
  118. package/build/dist/Models/DatabaseModels/PodmanResource.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js +87 -0
  120. package/build/dist/Models/DatabaseModels/ServiceLevelObjective.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPage.js +98 -0
  122. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  123. package/build/dist/Server/API/TeamMemberAPI.js +57 -2
  124. package/build/dist/Server/API/TeamMemberAPI.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
  126. package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js +45 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785900000000-AddSloMonitorLabelRule.js.map +1 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js +31 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785915638551-IncreaseHostIpAddressesLength.js.map +1 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js +90 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930000000-WidenInventoryResourceNameColumns.js.map +1 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js +14 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785930709405-AddStatusPageReportPeriod.js.map +1 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  136. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  137. package/build/dist/Server/Middleware/ProjectAuthorization.js +59 -4
  138. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  139. package/build/dist/Server/Services/BillingService.js +466 -36
  140. package/build/dist/Server/Services/BillingService.js.map +1 -1
  141. package/build/dist/Server/Services/CephClusterService.js +42 -5
  142. package/build/dist/Server/Services/CephClusterService.js.map +1 -1
  143. package/build/dist/Server/Services/CephResourceService.js +28 -3
  144. package/build/dist/Server/Services/CephResourceService.js.map +1 -1
  145. package/build/dist/Server/Services/CloudResourceService.js +42 -5
  146. package/build/dist/Server/Services/CloudResourceService.js.map +1 -1
  147. package/build/dist/Server/Services/DatabaseService.js +69 -2
  148. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  149. package/build/dist/Server/Services/DockerHostService.js +42 -5
  150. package/build/dist/Server/Services/DockerHostService.js.map +1 -1
  151. package/build/dist/Server/Services/DockerResourceService.js +34 -4
  152. package/build/dist/Server/Services/DockerResourceService.js.map +1 -1
  153. package/build/dist/Server/Services/DockerSwarmClusterService.js +43 -5
  154. package/build/dist/Server/Services/DockerSwarmClusterService.js.map +1 -1
  155. package/build/dist/Server/Services/DockerSwarmResourceService.js +28 -3
  156. package/build/dist/Server/Services/DockerSwarmResourceService.js.map +1 -1
  157. package/build/dist/Server/Services/HostService.js +43 -5
  158. package/build/dist/Server/Services/HostService.js.map +1 -1
  159. package/build/dist/Server/Services/IoTDeviceService.js +1 -8
  160. package/build/dist/Server/Services/IoTDeviceService.js.map +1 -1
  161. package/build/dist/Server/Services/IoTFleetService.js +42 -5
  162. package/build/dist/Server/Services/IoTFleetService.js.map +1 -1
  163. package/build/dist/Server/Services/KubernetesClusterService.js +43 -5
  164. package/build/dist/Server/Services/KubernetesClusterService.js.map +1 -1
  165. package/build/dist/Server/Services/KubernetesContainerService.js +29 -3
  166. package/build/dist/Server/Services/KubernetesContainerService.js.map +1 -1
  167. package/build/dist/Server/Services/KubernetesResourceService.js +32 -4
  168. package/build/dist/Server/Services/KubernetesResourceService.js.map +1 -1
  169. package/build/dist/Server/Services/LabelService.js +92 -0
  170. package/build/dist/Server/Services/LabelService.js.map +1 -1
  171. package/build/dist/Server/Services/LlmProviderService.js +61 -8
  172. package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
  173. package/build/dist/Server/Services/MonitorService.js +47 -0
  174. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  175. package/build/dist/Server/Services/PodmanHostService.js +42 -5
  176. package/build/dist/Server/Services/PodmanHostService.js.map +1 -1
  177. package/build/dist/Server/Services/PodmanResourceService.js +34 -4
  178. package/build/dist/Server/Services/PodmanResourceService.js.map +1 -1
  179. package/build/dist/Server/Services/ProjectService.js +135 -21
  180. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  181. package/build/dist/Server/Services/ProxmoxClusterService.js +42 -5
  182. package/build/dist/Server/Services/ProxmoxClusterService.js.map +1 -1
  183. package/build/dist/Server/Services/ProxmoxResourceService.js +28 -3
  184. package/build/dist/Server/Services/ProxmoxResourceService.js.map +1 -1
  185. package/build/dist/Server/Services/RumApplicationService.js +43 -5
  186. package/build/dist/Server/Services/RumApplicationService.js.map +1 -1
  187. package/build/dist/Server/Services/RunnerService.js +1 -6
  188. package/build/dist/Server/Services/RunnerService.js.map +1 -1
  189. package/build/dist/Server/Services/ServerlessFunctionService.js +43 -5
  190. package/build/dist/Server/Services/ServerlessFunctionService.js.map +1 -1
  191. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js +347 -0
  192. package/build/dist/Server/Services/ServiceLevelObjectiveMonitorRuleEngineService.js.map +1 -0
  193. package/build/dist/Server/Services/ServiceLevelObjectiveService.js +33 -1
  194. package/build/dist/Server/Services/ServiceLevelObjectiveService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +69 -46
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js +17 -1
  198. package/build/dist/Server/Services/StatusPageSubscriberNotificationTemplateService.js.map +1 -1
  199. package/build/dist/Server/Services/StatusPageSubscriberService.js +3 -0
  200. package/build/dist/Server/Services/StatusPageSubscriberService.js.map +1 -1
  201. package/build/dist/Server/Types/Database/JSONColumnQuery.js +402 -0
  202. package/build/dist/Server/Types/Database/JSONColumnQuery.js.map +1 -0
  203. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js +9 -2
  204. package/build/dist/Server/Types/Database/Permissions/PublicPermission.js.map +1 -1
  205. package/build/dist/Server/Types/Database/QueryHelper.js +17 -27
  206. package/build/dist/Server/Types/Database/QueryHelper.js.map +1 -1
  207. package/build/dist/Server/Utils/Database/TruncateColumnValue.js +32 -0
  208. package/build/dist/Server/Utils/Database/TruncateColumnValue.js.map +1 -0
  209. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
  210. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  211. package/build/dist/Types/CustomField/CustomFieldDefinition.js +2 -0
  212. package/build/dist/Types/CustomField/CustomFieldDefinition.js.map +1 -0
  213. package/build/dist/Types/Date.js +68 -0
  214. package/build/dist/Types/Date.js.map +1 -1
  215. package/build/dist/Types/Events/Recurring.js +63 -0
  216. package/build/dist/Types/Events/Recurring.js.map +1 -1
  217. package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
  218. package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
  219. package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
  220. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js +22 -0
  221. package/build/dist/Types/StatusPage/StatusPageReportPeriodType.js.map +1 -0
  222. package/build/dist/UI/Components/Detail/Detail.js +97 -0
  223. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  224. package/build/dist/UI/Components/ModelTable/CustomFieldColumns.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js +18 -2
  226. package/build/dist/UI/Components/ModelTable/useCustomFieldColumns.js.map +1 -1
  227. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js +213 -0
  228. package/build/dist/UI/Utils/ModelAPI/ProjectUsersModelAPI.js.map +1 -0
  229. package/build/dist/UI/Utils/TeamMembersByUser.js +195 -0
  230. package/build/dist/UI/Utils/TeamMembersByUser.js.map +1 -0
  231. package/build/dist/Utils/StatusPage/ReportPeriod.js +131 -0
  232. package/build/dist/Utils/StatusPage/ReportPeriod.js.map +1 -0
  233. package/build/dist/Utils/Telemetry/HostIpAddresses.js +82 -0
  234. package/build/dist/Utils/Telemetry/HostIpAddresses.js.map +1 -0
  235. package/jest.config.json +2 -0
  236. package/package.json +1 -1
  237. package/tsconfig.json +12 -1
@@ -0,0 +1,739 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ it,
7
+ jest,
8
+ } from "@jest/globals";
9
+ import ModelAPI, { ListResult } from "../../../UI/Utils/ModelAPI/ModelAPI";
10
+ import ProjectUsersModelAPI from "../../../UI/Utils/ModelAPI/ProjectUsersModelAPI";
11
+ import { ProjectUserRow } from "../../../UI/Utils/TeamMembersByUser";
12
+ import API from "../../../UI/Utils/API/API";
13
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
14
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
15
+ import { JSONObject } from "../../../Types/JSON";
16
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
17
+ import Project from "../../../Models/DatabaseModels/Project";
18
+ import Team from "../../../Models/DatabaseModels/Team";
19
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
20
+ import User from "../../../Models/DatabaseModels/User";
21
+ import Includes from "../../../Types/BaseDatabase/Includes";
22
+ import Query from "../../../Types/BaseDatabase/Query";
23
+ import Select from "../../../Types/BaseDatabase/Select";
24
+ import Name from "../../../Types/Name";
25
+ import ObjectID from "../../../Types/ObjectID";
26
+
27
+ /*
28
+ * This API is what makes the Users table page over people instead of over
29
+ * memberships. The table itself is unchanged, so every behaviour below is
30
+ * invisible from the component: if the skip/limit is applied to the wrong list
31
+ * the table pages over memberships again and duplicates come back; if `count`
32
+ * is a membership count the pager offers pages that render empty; and if
33
+ * deleting a row only deletes one membership the row stays on screen and the
34
+ * delete looks like it silently failed.
35
+ *
36
+ * ModelAPI's statics are spied rather than the module being mocked, because
37
+ * ProjectUsersModelAPI extends ModelAPI and delegates to it by name.
38
+ */
39
+
40
+ const PROJECT_ID: ObjectID = new ObjectID(
41
+ "00000000-0000-4000-8000-000000000001",
42
+ );
43
+
44
+ type MembershipSpec = {
45
+ id: string;
46
+ userId: string;
47
+ userName?: string | undefined;
48
+ teamId?: string | undefined;
49
+ teamName?: string | undefined;
50
+ hasAcceptedInvitation?: boolean | undefined;
51
+ projectId?: ObjectID | undefined;
52
+ };
53
+
54
+ const buildMembership: (spec: MembershipSpec) => TeamMember = (
55
+ spec: MembershipSpec,
56
+ ): TeamMember => {
57
+ const teamMember: TeamMember = new TeamMember();
58
+
59
+ teamMember._id = spec.id;
60
+ teamMember.userId = new ObjectID(spec.userId);
61
+ teamMember.projectId = spec.projectId || PROJECT_ID;
62
+ teamMember.hasAcceptedInvitation = spec.hasAcceptedInvitation ?? true;
63
+
64
+ const user: User = new User();
65
+ user._id = spec.userId;
66
+
67
+ if (spec.userName) {
68
+ user.name = new Name(spec.userName);
69
+ }
70
+
71
+ teamMember.user = user;
72
+
73
+ if (spec.teamId) {
74
+ teamMember.teamId = new ObjectID(spec.teamId);
75
+
76
+ const team: Team = new Team();
77
+ team._id = spec.teamId;
78
+ team.name = spec.teamName || spec.teamId;
79
+ teamMember.team = team;
80
+ }
81
+
82
+ return teamMember;
83
+ };
84
+
85
+ const asListResult: (
86
+ teamMembers: Array<TeamMember>,
87
+ ) => ListResult<TeamMember> = (
88
+ teamMembers: Array<TeamMember>,
89
+ ): ListResult<TeamMember> => {
90
+ return {
91
+ data: teamMembers,
92
+ count: teamMembers.length,
93
+ skip: 0,
94
+ limit: teamMembers.length,
95
+ };
96
+ };
97
+
98
+ type GetListArgs = {
99
+ modelType: { new (): TeamMember };
100
+ query: Query<TeamMember>;
101
+ limit: number;
102
+ skip: number;
103
+ select: Select<TeamMember>;
104
+ sort: Record<string, unknown>;
105
+ };
106
+
107
+ /*
108
+ * Hand-rolled rather than jest.SpiedFunction<typeof ModelAPI.getList>: both
109
+ * statics are generic, and the installed jest typings cannot describe a spy on
110
+ * a generic method without collapsing it to one instantiation.
111
+ */
112
+ interface Spy {
113
+ mockResolvedValue: (value: unknown) => Spy;
114
+ mockResolvedValueOnce: (value: unknown) => Spy;
115
+ mockImplementation: (fn: (...args: Array<unknown>) => unknown) => Spy;
116
+ mock: { calls: Array<Array<unknown>> };
117
+ }
118
+
119
+ let getListSpy: Spy;
120
+ let getItemSpy: Spy;
121
+ let deleteItemSpy: Spy;
122
+ let postSpy: Spy;
123
+
124
+ const getListCallArgs: (callIndex: number) => GetListArgs = (
125
+ callIndex: number,
126
+ ): GetListArgs => {
127
+ return getListSpy.mock.calls[callIndex]![0] as GetListArgs;
128
+ };
129
+
130
+ const postCallArgs: (callIndex: number) => {
131
+ url: { toString: () => string };
132
+ data: Record<string, unknown>;
133
+ headers?: Record<string, string> | undefined;
134
+ } = (
135
+ callIndex: number,
136
+ ): {
137
+ url: { toString: () => string };
138
+ data: Record<string, unknown>;
139
+ headers?: Record<string, string> | undefined;
140
+ } => {
141
+ return postSpy.mock.calls[callIndex]![0] as {
142
+ url: { toString: () => string };
143
+ data: Record<string, unknown>;
144
+ headers?: Record<string, string> | undefined;
145
+ };
146
+ };
147
+
148
+ beforeEach(() => {
149
+ getListSpy = jest.spyOn(ModelAPI, "getList") as unknown as Spy;
150
+ getItemSpy = jest.spyOn(ModelAPI, "getItem") as unknown as Spy;
151
+ deleteItemSpy = (
152
+ jest.spyOn(ModelAPI, "deleteItem") as unknown as Spy
153
+ ).mockImplementation(async () => {
154
+ return undefined;
155
+ });
156
+ postSpy = (jest.spyOn(API, "post") as unknown as Spy).mockImplementation(
157
+ async () => {
158
+ return new HTTPResponse<JSONObject>(
159
+ 200,
160
+ { numberOfMembershipsDeleted: 3 },
161
+ {},
162
+ );
163
+ },
164
+ );
165
+ jest
166
+ .spyOn(ModelAPI, "getCommonHeaders")
167
+ .mockReturnValue({ tenantid: PROJECT_ID.toString() });
168
+ });
169
+
170
+ afterEach(() => {
171
+ jest.restoreAllMocks();
172
+ });
173
+
174
+ describe("ProjectUsersModelAPI.getList", () => {
175
+ const listUsers: (data: {
176
+ skip?: number | undefined;
177
+ limit?: number | undefined;
178
+ query?: Query<TeamMember> | undefined;
179
+ sort?: Record<string, unknown> | undefined;
180
+ select?: Select<TeamMember> | undefined;
181
+ }) => Promise<ListResult<TeamMember>> = async (data: {
182
+ skip?: number | undefined;
183
+ limit?: number | undefined;
184
+ query?: Query<TeamMember> | undefined;
185
+ sort?: Record<string, unknown> | undefined;
186
+ select?: Select<TeamMember> | undefined;
187
+ }): Promise<ListResult<TeamMember>> => {
188
+ return ProjectUsersModelAPI.getList<TeamMember>({
189
+ modelType: TeamMember,
190
+ query: data.query || ({ projectId: PROJECT_ID } as Query<TeamMember>),
191
+ limit: data.limit ?? 10,
192
+ skip: data.skip ?? 0,
193
+ select: data.select || ({} as Select<TeamMember>),
194
+ sort: (data.sort || {}) as never,
195
+ });
196
+ };
197
+
198
+ it("returns one row per user and counts people, not memberships", async () => {
199
+ getListSpy.mockResolvedValue(
200
+ asListResult([
201
+ buildMembership({
202
+ id: "m1",
203
+ userId: "u1",
204
+ userName: "Thomas Stock",
205
+ teamId: "t1",
206
+ teamName: "NTW_Online Operations",
207
+ }),
208
+ buildMembership({
209
+ id: "m2",
210
+ userId: "u1",
211
+ userName: "Thomas Stock",
212
+ teamId: "t2",
213
+ teamName: "Owners",
214
+ }),
215
+ buildMembership({
216
+ id: "m3",
217
+ userId: "u2",
218
+ userName: "Jason Apel",
219
+ teamId: "t2",
220
+ teamName: "Owners",
221
+ }),
222
+ ]) as never,
223
+ );
224
+
225
+ const result: ListResult<TeamMember> = await listUsers({});
226
+
227
+ expect(result.count).toBe(2);
228
+ expect(result.data).toHaveLength(2);
229
+ expect(
230
+ (result.data as Array<ProjectUserRow>).map((row: ProjectUserRow) => {
231
+ return row.teamsForUser.map((team: Team) => {
232
+ return team.name?.toString();
233
+ });
234
+ }),
235
+ ).toEqual([["Owners"], ["NTW_Online Operations", "Owners"]]);
236
+ });
237
+
238
+ it("reads the whole membership list once, not one page of it", async () => {
239
+ getListSpy.mockResolvedValue(asListResult([]) as never);
240
+
241
+ await listUsers({ skip: 20, limit: 10 });
242
+
243
+ expect(getListCallArgs(0).skip).toBe(0);
244
+ expect(getListCallArgs(0).limit).toBeGreaterThan(10);
245
+ });
246
+
247
+ it("applies skip and limit to the users, so a page boundary never splits a person", async () => {
248
+ getListSpy.mockResolvedValue(
249
+ asListResult([
250
+ buildMembership({
251
+ id: "m1",
252
+ userId: "u1",
253
+ userName: "A",
254
+ teamId: "t1",
255
+ }),
256
+ buildMembership({
257
+ id: "m2",
258
+ userId: "u1",
259
+ userName: "A",
260
+ teamId: "t2",
261
+ }),
262
+ buildMembership({
263
+ id: "m3",
264
+ userId: "u2",
265
+ userName: "B",
266
+ teamId: "t1",
267
+ }),
268
+ buildMembership({
269
+ id: "m4",
270
+ userId: "u3",
271
+ userName: "C",
272
+ teamId: "t1",
273
+ }),
274
+ buildMembership({
275
+ id: "m5",
276
+ userId: "u4",
277
+ userName: "D",
278
+ teamId: "t1",
279
+ }),
280
+ ]) as never,
281
+ );
282
+
283
+ const firstPage: ListResult<TeamMember> = await listUsers({
284
+ skip: 0,
285
+ limit: 2,
286
+ });
287
+ const secondPage: ListResult<TeamMember> = await listUsers({
288
+ skip: 2,
289
+ limit: 2,
290
+ });
291
+
292
+ expect(firstPage.count).toBe(4);
293
+ expect(
294
+ firstPage.data.map((row: TeamMember) => {
295
+ return row.userId?.toString();
296
+ }),
297
+ ).toEqual(["u1", "u2"]);
298
+ expect(
299
+ secondPage.data.map((row: TeamMember) => {
300
+ return row.userId?.toString();
301
+ }),
302
+ ).toEqual(["u3", "u4"]);
303
+ expect(secondPage.skip).toBe(2);
304
+ expect(secondPage.limit).toBe(2);
305
+ });
306
+
307
+ it("returns an empty page past the end rather than wrapping around", async () => {
308
+ getListSpy.mockResolvedValue(
309
+ asListResult([
310
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }),
311
+ ]) as never,
312
+ );
313
+
314
+ const result: ListResult<TeamMember> = await listUsers({
315
+ skip: 50,
316
+ limit: 10,
317
+ });
318
+
319
+ expect(result.data).toEqual([]);
320
+ expect(result.count).toBe(1);
321
+ });
322
+
323
+ it("asks for the fields grouping needs on top of the ones the columns asked for", async () => {
324
+ getListSpy.mockResolvedValue(asListResult([]) as never);
325
+
326
+ await listUsers({
327
+ select: {
328
+ user: { name: true, profilePictureId: true },
329
+ team: { name: true },
330
+ } as Select<TeamMember>,
331
+ });
332
+
333
+ const select: Record<string, unknown> = getListCallArgs(0)
334
+ .select as unknown as Record<string, unknown>;
335
+
336
+ expect(select["userId"]).toBe(true);
337
+ expect(select["teamId"]).toBe(true);
338
+ expect(select["hasAcceptedInvitation"]).toBe(true);
339
+ // The columns' own fields survive.
340
+ expect(select["user"]).toEqual({
341
+ name: true,
342
+ profilePictureId: true,
343
+ _id: true,
344
+ });
345
+ expect(select["team"]).toEqual({ name: true, _id: true });
346
+ });
347
+
348
+ it("sorts users by name when the table has no sort of its own", async () => {
349
+ getListSpy.mockResolvedValue(
350
+ asListResult([
351
+ buildMembership({ id: "m1", userId: "u1", userName: "Zoe Baker" }),
352
+ buildMembership({ id: "m2", userId: "u2", userName: "aaron Falzon" }),
353
+ ]) as never,
354
+ );
355
+
356
+ const result: ListResult<TeamMember> = await listUsers({ sort: {} });
357
+
358
+ expect(
359
+ result.data.map((row: TeamMember) => {
360
+ return row.user?.name?.toString();
361
+ }),
362
+ ).toEqual(["aaron Falzon", "Zoe Baker"]);
363
+ });
364
+
365
+ it("keeps the server's order when the table asked for a sort", async () => {
366
+ getListSpy.mockResolvedValue(
367
+ asListResult([
368
+ buildMembership({ id: "m1", userId: "u1", userName: "Zoe Baker" }),
369
+ buildMembership({ id: "m2", userId: "u2", userName: "aaron Falzon" }),
370
+ ]) as never,
371
+ );
372
+
373
+ const result: ListResult<TeamMember> = await listUsers({
374
+ sort: { createdAt: "DESC" },
375
+ });
376
+
377
+ expect(
378
+ result.data.map((row: TeamMember) => {
379
+ return row.user?.name?.toString();
380
+ }),
381
+ ).toEqual(["Zoe Baker", "aaron Falzon"]);
382
+ });
383
+
384
+ it("does not re-read teams when nothing is filtering the table", async () => {
385
+ getListSpy.mockResolvedValue(
386
+ asListResult([
387
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }),
388
+ ]) as never,
389
+ );
390
+
391
+ await listUsers({ query: { projectId: PROJECT_ID } as Query<TeamMember> });
392
+
393
+ expect(getListSpy).toHaveBeenCalledTimes(1);
394
+ });
395
+
396
+ it("shows every team a user is on even when the table is filtered to one team", async () => {
397
+ const teamFilteredMemberships: ListResult<TeamMember> = asListResult([
398
+ buildMembership({
399
+ id: "m1",
400
+ userId: "u1",
401
+ userName: "Thomas Stock",
402
+ teamId: "t1",
403
+ teamName: "NTW_Online Operations",
404
+ }),
405
+ ]);
406
+
407
+ const allMembershipsOfThoseUsers: ListResult<TeamMember> = asListResult([
408
+ buildMembership({
409
+ id: "m1",
410
+ userId: "u1",
411
+ teamId: "t1",
412
+ teamName: "NTW_Online Operations",
413
+ }),
414
+ buildMembership({
415
+ id: "m2",
416
+ userId: "u1",
417
+ teamId: "t2",
418
+ teamName: "Owners",
419
+ }),
420
+ ]);
421
+
422
+ getListSpy
423
+ .mockResolvedValueOnce(teamFilteredMemberships as never)
424
+ .mockResolvedValueOnce(allMembershipsOfThoseUsers as never);
425
+
426
+ const result: ListResult<TeamMember> = await listUsers({
427
+ query: {
428
+ projectId: PROJECT_ID,
429
+ teamId: new ObjectID("t1"),
430
+ } as unknown as Query<TeamMember>,
431
+ });
432
+
433
+ expect(getListSpy).toHaveBeenCalledTimes(2);
434
+
435
+ const refetchQuery: Record<string, unknown> = getListCallArgs(1)
436
+ .query as unknown as Record<string, unknown>;
437
+
438
+ expect(refetchQuery["projectId"]).toEqual(PROJECT_ID);
439
+ expect(refetchQuery["userId"]).toBeInstanceOf(Includes);
440
+ expect((refetchQuery["userId"] as Includes).values.map(String)).toEqual([
441
+ "u1",
442
+ ]);
443
+ // The team filter itself must not be carried into the re-read.
444
+ expect(refetchQuery["teamId"]).toBeUndefined();
445
+
446
+ expect(
447
+ (result.data[0] as ProjectUserRow).teamsForUser.map((team: Team) => {
448
+ return team.name?.toString();
449
+ }),
450
+ ).toEqual(["NTW_Online Operations", "Owners"]);
451
+ });
452
+
453
+ it("only re-reads teams for the users on the current page", async () => {
454
+ getListSpy
455
+ .mockResolvedValueOnce(
456
+ asListResult([
457
+ buildMembership({ id: "m1", userId: "u1", userName: "A" }),
458
+ buildMembership({ id: "m2", userId: "u2", userName: "B" }),
459
+ buildMembership({ id: "m3", userId: "u3", userName: "C" }),
460
+ ]) as never,
461
+ )
462
+ .mockResolvedValueOnce(asListResult([]) as never);
463
+
464
+ await listUsers({
465
+ skip: 0,
466
+ limit: 1,
467
+ query: {
468
+ projectId: PROJECT_ID,
469
+ hasAcceptedInvitation: true,
470
+ } as unknown as Query<TeamMember>,
471
+ });
472
+
473
+ const refetchQuery: Record<string, unknown> = getListCallArgs(1)
474
+ .query as unknown as Record<string, unknown>;
475
+
476
+ expect((refetchQuery["userId"] as Includes).values.map(String)).toEqual([
477
+ "u1",
478
+ ]);
479
+ });
480
+
481
+ it("skips the re-read when a filtered page came back empty", async () => {
482
+ getListSpy.mockResolvedValue(asListResult([]) as never);
483
+
484
+ await listUsers({
485
+ query: {
486
+ projectId: PROJECT_ID,
487
+ hasAcceptedInvitation: true,
488
+ } as unknown as Query<TeamMember>,
489
+ });
490
+
491
+ expect(getListSpy).toHaveBeenCalledTimes(1);
492
+ });
493
+
494
+ it("hands other models straight to the plain API", async () => {
495
+ const projects: ListResult<Project> = {
496
+ data: [],
497
+ count: 0,
498
+ skip: 0,
499
+ limit: 10,
500
+ };
501
+
502
+ getListSpy.mockResolvedValue(projects as never);
503
+
504
+ const result: ListResult<Project> =
505
+ await ProjectUsersModelAPI.getList<Project>({
506
+ modelType: Project,
507
+ query: {} as Query<Project>,
508
+ limit: 10,
509
+ skip: 0,
510
+ select: {} as Select<Project>,
511
+ sort: {} as never,
512
+ });
513
+
514
+ expect(result).toBe(projects);
515
+ expect(getListSpy).toHaveBeenCalledTimes(1);
516
+ expect(getListCallArgs(0).limit).toBe(10);
517
+ });
518
+ });
519
+
520
+ describe("ProjectUsersModelAPI.deleteItem", () => {
521
+ /*
522
+ * The whole point of routing this through one endpoint is atomicity. The
523
+ * server refuses to remove the last accepted member of the Owners team, and
524
+ * it refuses per request - so a DELETE-per-membership loop destroys every
525
+ * other team first and only then reports the failure, leaving the person
526
+ * stripped of teams the admin was just told they had not lost. These tests
527
+ * pin "one request, and never a per-membership fan-out".
528
+ */
529
+ it("removes the user from the project in a single request", async () => {
530
+ getItemSpy.mockResolvedValue(
531
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }) as never,
532
+ );
533
+
534
+ await ProjectUsersModelAPI.deleteItem<TeamMember>({
535
+ modelType: TeamMember,
536
+ id: new ObjectID("m1"),
537
+ });
538
+
539
+ expect(postSpy.mock.calls).toHaveLength(1);
540
+ expect(postCallArgs(0).url.toString()).toContain(
541
+ "/team-member/remove-user-from-project",
542
+ );
543
+ expect(postCallArgs(0).data).toEqual({ userId: "u1" });
544
+ });
545
+
546
+ it("never issues a delete per membership", async () => {
547
+ getItemSpy.mockResolvedValue(
548
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }) as never,
549
+ );
550
+
551
+ await ProjectUsersModelAPI.deleteItem<TeamMember>({
552
+ modelType: TeamMember,
553
+ id: new ObjectID("m1"),
554
+ });
555
+
556
+ expect(deleteItemSpy.mock.calls).toHaveLength(0);
557
+ // No sibling enumeration either - the server resolves the set itself.
558
+ expect(getListSpy.mock.calls).toHaveLength(0);
559
+ });
560
+
561
+ it("resolves the person from the row it was handed", async () => {
562
+ getItemSpy.mockResolvedValue(
563
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }) as never,
564
+ );
565
+
566
+ await ProjectUsersModelAPI.deleteItem<TeamMember>({
567
+ modelType: TeamMember,
568
+ id: new ObjectID("m1"),
569
+ });
570
+
571
+ const lookup: { id: ObjectID; select: Record<string, unknown> } = getItemSpy
572
+ .mock.calls[0]![0] as { id: ObjectID; select: Record<string, unknown> };
573
+
574
+ expect(lookup.id.toString()).toBe("m1");
575
+ expect(lookup.select["userId"]).toBe(true);
576
+ });
577
+
578
+ it("sends the tenant headers so the server scopes the removal to this project", async () => {
579
+ getItemSpy.mockResolvedValue(
580
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }) as never,
581
+ );
582
+
583
+ await ProjectUsersModelAPI.deleteItem<TeamMember>({
584
+ modelType: TeamMember,
585
+ id: new ObjectID("m1"),
586
+ });
587
+
588
+ expect(postCallArgs(0).headers).toEqual({
589
+ tenantid: PROJECT_ID.toString(),
590
+ });
591
+ });
592
+
593
+ it("throws when the server rejects the removal, so the table reports the failure", async () => {
594
+ getItemSpy.mockResolvedValue(
595
+ buildMembership({ id: "m1", userId: "u1", teamId: "t1" }) as never,
596
+ );
597
+
598
+ const rejection: HTTPErrorResponse = new HTTPErrorResponse(
599
+ 400,
600
+ {
601
+ message:
602
+ "This team should have at least 1 member who has accepted the invitation.",
603
+ },
604
+ {},
605
+ );
606
+
607
+ postSpy.mockImplementation(async () => {
608
+ return rejection;
609
+ });
610
+
611
+ await expect(
612
+ ProjectUsersModelAPI.deleteItem<TeamMember>({
613
+ modelType: TeamMember,
614
+ id: new ObjectID("m1"),
615
+ }),
616
+ ).rejects.toBe(rejection);
617
+
618
+ // Nothing was removed on the way to the rejection.
619
+ expect(deleteItemSpy.mock.calls).toHaveLength(0);
620
+ });
621
+
622
+ it("falls back to deleting just the row when its user cannot be resolved", async () => {
623
+ getItemSpy.mockResolvedValue(null as never);
624
+
625
+ await ProjectUsersModelAPI.deleteItem<TeamMember>({
626
+ modelType: TeamMember,
627
+ id: new ObjectID("m1"),
628
+ });
629
+
630
+ expect(postSpy.mock.calls).toHaveLength(0);
631
+ expect(deleteItemSpy.mock.calls).toHaveLength(1);
632
+ expect(
633
+ (deleteItemSpy.mock.calls[0]![0] as { id: ObjectID }).id.toString(),
634
+ ).toBe("m1");
635
+ });
636
+
637
+ it("hands other models straight to the plain API", async () => {
638
+ await ProjectUsersModelAPI.deleteItem<Project>({
639
+ modelType: Project,
640
+ id: new ObjectID("p1"),
641
+ });
642
+
643
+ expect(getItemSpy.mock.calls).toHaveLength(0);
644
+ expect(postSpy.mock.calls).toHaveLength(0);
645
+ expect(deleteItemSpy.mock.calls).toHaveLength(1);
646
+ });
647
+ });
648
+
649
+ describe("ProjectUsersModelAPI.getList reading past the page cap", () => {
650
+ const buildMemberships: (
651
+ count: number,
652
+ fromIndex: number,
653
+ ) => Array<TeamMember> = (
654
+ count: number,
655
+ fromIndex: number,
656
+ ): Array<TeamMember> => {
657
+ const memberships: Array<TeamMember> = [];
658
+
659
+ for (let i: number = 0; i < count; i++) {
660
+ const index: number = fromIndex + i;
661
+ memberships.push(
662
+ buildMembership({
663
+ id: `m${index}`,
664
+ userId: `u${index}`,
665
+ userName: `User ${String(index).padStart(6, "0")}`,
666
+ teamId: "t1",
667
+ }),
668
+ );
669
+ }
670
+
671
+ return memberships;
672
+ };
673
+
674
+ it("stops after one request when the first page is short", async () => {
675
+ getListSpy.mockResolvedValue(asListResult(buildMemberships(3, 0)) as never);
676
+
677
+ const result: ListResult<TeamMember> =
678
+ await ProjectUsersModelAPI.getList<TeamMember>({
679
+ modelType: TeamMember,
680
+ query: { projectId: PROJECT_ID } as Query<TeamMember>,
681
+ limit: 10,
682
+ skip: 0,
683
+ select: {} as Select<TeamMember>,
684
+ sort: {} as never,
685
+ });
686
+
687
+ expect(getListSpy.mock.calls).toHaveLength(1);
688
+ expect(result.count).toBe(3);
689
+ });
690
+
691
+ /*
692
+ * A full page is indistinguishable from a truncated one. Stopping there
693
+ * would drop every user beyond the cap from the list AND report the
694
+ * truncated total as the number of people in the project, with nothing on
695
+ * screen saying so.
696
+ */
697
+ it("keeps reading while pages come back full, and counts everyone", async () => {
698
+ getListSpy
699
+ .mockResolvedValueOnce(
700
+ asListResult(buildMemberships(LIMIT_PER_PROJECT, 0)) as never,
701
+ )
702
+ .mockResolvedValueOnce(
703
+ asListResult(buildMemberships(5, LIMIT_PER_PROJECT)) as never,
704
+ );
705
+
706
+ const result: ListResult<TeamMember> =
707
+ await ProjectUsersModelAPI.getList<TeamMember>({
708
+ modelType: TeamMember,
709
+ query: { projectId: PROJECT_ID } as Query<TeamMember>,
710
+ limit: 10,
711
+ skip: 0,
712
+ select: {} as Select<TeamMember>,
713
+ sort: {} as never,
714
+ });
715
+
716
+ expect(getListSpy.mock.calls).toHaveLength(2);
717
+ expect(getListCallArgs(0).skip).toBe(0);
718
+ expect(getListCallArgs(1).skip).toBe(LIMIT_PER_PROJECT);
719
+ expect(result.count).toBe(LIMIT_PER_PROJECT + 5);
720
+ });
721
+
722
+ it("gives up rather than looping forever if pages never get shorter", async () => {
723
+ getListSpy.mockResolvedValue(
724
+ asListResult(buildMemberships(LIMIT_PER_PROJECT, 0)) as never,
725
+ );
726
+
727
+ await ProjectUsersModelAPI.getList<TeamMember>({
728
+ modelType: TeamMember,
729
+ query: { projectId: PROJECT_ID } as Query<TeamMember>,
730
+ limit: 10,
731
+ skip: 0,
732
+ select: {} as Select<TeamMember>,
733
+ sort: {} as never,
734
+ });
735
+
736
+ expect(getListSpy.mock.calls.length).toBeLessThanOrEqual(10);
737
+ expect(getListSpy.mock.calls.length).toBeGreaterThan(1);
738
+ });
739
+ });