@oneuptime/common 12.0.34 → 13.0.0

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 (259) hide show
  1. package/Models/DatabaseModels/GlobalConfig.ts +12 -12
  2. package/Models/DatabaseModels/InventoryItem.ts +44 -1
  3. package/Models/DatabaseModels/NetworkDevice.ts +126 -0
  4. package/Server/EnvironmentConfig.ts +47 -16
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.ts +25 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Infrastructure/Redis.ts +8 -8
  8. package/Server/Infrastructure/Status.ts +10 -4
  9. package/Server/Services/DatabaseService.ts +65 -13
  10. package/Server/Services/InventoryItemService.ts +31 -1
  11. package/Server/Services/MonitorTemplateService.ts +140 -19
  12. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +349 -41
  13. package/Server/Services/NetworkDeviceDiscoveryScanService.ts +87 -2
  14. package/Server/Services/NetworkDeviceService.ts +69 -0
  15. package/Server/Services/UserNotificationRuleService.ts +76 -25
  16. package/Server/Types/Database/Permissions/ReadPermission.ts +45 -5
  17. package/Server/Types/Database/QueryUtil.ts +56 -0
  18. package/Server/Types/Markdown.ts +93 -1
  19. package/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.ts +82 -17
  20. package/Server/Utils/EmailRollup/EmailRollupFlushRunner.ts +10 -1
  21. package/Server/Utils/EmailRollup/EmailRollupRenderer.ts +2 -2
  22. package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +82 -35
  23. package/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.ts +31 -5
  24. package/Server/Utils/Monitor/MonitorCriteriaEvaluator.ts +202 -37
  25. package/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.ts +82 -9
  26. package/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.ts +16 -7
  27. package/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.ts +29 -17
  28. package/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.ts +107 -48
  29. package/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.ts +158 -0
  30. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +27 -0
  31. package/Server/Utils/Telemetry/EntityRegistry.ts +11 -1
  32. package/Server/Utils/Telemetry/TelemetryEntity.ts +83 -9
  33. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +32 -0
  34. package/Tests/App/Dashboard/AdoptedDeviceDrawer.test.tsx +374 -0
  35. package/Tests/App/Dashboard/DashboardLabelVariables.test.tsx +580 -0
  36. package/Tests/App/Dashboard/DashboardMonitorLabelVariable.test.tsx +438 -0
  37. package/Tests/App/Dashboard/DashboardVariableAllContract.test.tsx +5 -2
  38. package/Tests/App/Dashboard/DeviceAttachmentCard.test.tsx +273 -0
  39. package/Tests/App/Dashboard/DiscoveryReviewInventoryRefresh.test.tsx +505 -0
  40. package/Tests/App/Dashboard/EntityDetailPanel.test.tsx +163 -0
  41. package/Tests/App/Dashboard/InfrastructureExplorer.test.tsx +401 -0
  42. package/Tests/App/Dashboard/InfrastructureGraph.test.tsx +232 -0
  43. package/Tests/App/Dashboard/InventoryItems.test.tsx +201 -0
  44. package/Tests/App/Dashboard/InventoryTypeAndStatusFacets.test.tsx +798 -0
  45. package/Tests/App/Dashboard/NetworkTopologyLiveView.test.tsx +320 -0
  46. package/Tests/App/Dashboard/NetworkTopologyToolbar.test.tsx +275 -0
  47. package/Tests/App/Dashboard/OnCallCalendarFeedEmptyState.test.tsx +975 -0
  48. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +63 -0
  49. package/Tests/App/Dashboard/ServiceMapGraph.test.tsx +626 -0
  50. package/Tests/App/Dashboard/TopologyDataLoading.test.tsx +348 -0
  51. package/Tests/App/Dashboard/TopologyPageNavigation.test.tsx +331 -0
  52. package/Tests/App/Dashboard/UserSettingsEmailPreferences.test.tsx +603 -0
  53. package/Tests/App/Dashboard/UserSettingsNotificationSettings.test.tsx +324 -0
  54. package/Tests/App/PublicDashboard/DashboardVariableSelector.test.tsx +13 -4
  55. package/Tests/App/StatusPage/StatusPageOidcOrigin.test.tsx +223 -0
  56. package/Tests/Models/InventoryItemStatus.test.ts +147 -0
  57. package/Tests/Models/NetworkDeviceMacAddressColumn.test.ts +294 -0
  58. package/Tests/Server/API/DashboardPublicResourceListAPI.test.ts +129 -0
  59. package/Tests/Server/EnvironmentConfigValkey.test.ts +227 -0
  60. package/Tests/Server/Infrastructure/Postgres/AddMacAddressToNetworkDeviceMigration.test.ts +365 -0
  61. package/Tests/Server/Infrastructure/Postgres/CustomFieldValueMappingMigration.test.ts +62 -23
  62. package/Tests/Server/Infrastructure/ValkeyDeploymentConfig.test.ts +265 -0
  63. package/Tests/Server/Middleware/HttpMetricsMiddleware.test.ts +382 -0
  64. package/Tests/Server/Middleware/SCIMAuthorization.test.ts +525 -0
  65. package/Tests/Server/Services/DatabaseServiceAggregateBy.test.ts +291 -56
  66. package/Tests/Server/Services/DatabaseServiceSortTiebreaker.test.ts +151 -0
  67. package/Tests/Server/Services/InventoryItemManualCreate.test.ts +105 -0
  68. package/Tests/Server/Services/InventoryItemPromotionGate.test.ts +178 -0
  69. package/Tests/Server/Services/MonitorTemplateServiceCustomFieldSync.test.ts +502 -0
  70. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +1133 -81
  71. package/Tests/Server/Services/NetworkDeviceDiscoveryScanRegistration.test.ts +520 -0
  72. package/Tests/Server/Services/NetworkDeviceMacAddressNormalization.test.ts +572 -0
  73. package/Tests/Server/Types/Database/InventoryStatusPostgres.test.ts +368 -0
  74. package/Tests/Server/Types/Database/Permissions/ReadBlockPermission.test.ts +261 -14
  75. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroups.test.ts +169 -0
  76. package/Tests/Server/Types/Database/QueryUtilIncludesAnyOfGroupsPostgres.test.ts +297 -0
  77. package/Tests/Server/Types/Markdown.test.ts +194 -1
  78. package/Tests/Server/Types/Workflow/Components/IncomingWebhookUtils.test.ts +279 -0
  79. package/Tests/Server/Types/Workflow/Components/TextToJson.test.ts +212 -0
  80. package/Tests/Server/Utils/CustomField/CustomFieldMappingRegistry.test.ts +436 -0
  81. package/Tests/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.test.ts +214 -0
  82. package/Tests/Server/Utils/EmailRollup/EmailRollupFlushRunnerBehaviour.test.ts +1 -1
  83. package/Tests/Server/Utils/EmailRollup/EmailRollupRenderer.test.ts +10 -3
  84. package/Tests/Server/Utils/EventLoop.test.ts +359 -0
  85. package/Tests/Server/Utils/JsonWebToken.test.ts +30 -0
  86. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +242 -0
  87. package/Tests/Server/Utils/Monitor/Criteria/CompareCriteriaAggregation.test.ts +13 -7
  88. package/Tests/Server/Utils/Monitor/Criteria/DatabaseMonitorCriteria.test.ts +29 -0
  89. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.test.ts +30 -6
  90. package/Tests/Server/Utils/Monitor/Criteria/MetricMonitorCriteriaAnomalyUnits.test.ts +246 -0
  91. package/Tests/Server/Utils/Monitor/MonitorCriteriaEmailEvalLogAgreement.test.ts +280 -0
  92. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluator.test.ts +224 -10
  93. package/Tests/Server/Utils/Monitor/MonitorCriteriaEvaluatorMetricUnits.test.ts +570 -0
  94. package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilderUnits.test.ts +30 -5
  95. package/Tests/Server/Utils/Monitor/MonitorCriteriaMessageBuilderUnits.test.ts +68 -21
  96. package/Tests/Server/Utils/Monitor/MonitorCriteriaObservationBuilderUnits.test.ts +36 -19
  97. package/Tests/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.test.ts +573 -0
  98. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +317 -0
  99. package/Tests/Server/Utils/Telemetry/ContextSpanProcessor.test.ts +281 -0
  100. package/Tests/Server/Utils/Telemetry/InventoryDuplicateIdentity.test.ts +316 -0
  101. package/Tests/Server/Utils/Telemetry/TelemetryEntity.test.ts +111 -8
  102. package/Tests/Types/Database/IncludesAnyOfGroups.test.ts +106 -0
  103. package/Tests/Types/JSONFunctions.test.ts +40 -0
  104. package/Tests/Types/NetworkAutomation/AutoImportRunMerge.test.ts +235 -0
  105. package/Tests/Types/NetworkAutomation/RuleRunResult.test.ts +66 -0
  106. package/Tests/Types/NetworkAutomation/RuleRunResultDescribe.test.ts +309 -1
  107. package/Tests/Types/Telemetry/InventoryLiveness.test.ts +99 -0
  108. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +160 -0
  109. package/Tests/UI/ReactRouterSingletonBuild.test.ts +133 -0
  110. package/Tests/UI/Rum/ReplayHeader.test.tsx +10 -2
  111. package/Tests/UI/Rum/ReplayPlayerChrome.test.tsx +514 -0
  112. package/Tests/UI/Rum/ReplayTimeFormat.test.ts +94 -0
  113. package/Tests/UI/Rum/ReplayUi.test.tsx +559 -0
  114. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +5 -0
  115. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +1 -0
  116. package/Tests/Utils/Dashboard/Components/DashboardMonitorListComponent.test.ts +11 -1
  117. package/Tests/Utils/Dashboard/LabelVariable.test.ts +257 -0
  118. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +144 -14
  119. package/Tests/Utils/MetricUnitUtil.test.ts +81 -0
  120. package/Tests/Utils/Monitor/DeviceMacLearningUtil.test.ts +497 -0
  121. package/Tests/Utils/Monitor/MetricValueFormatter.test.ts +546 -0
  122. package/Tests/Utils/Monitor/MonitorTemplateCustomFieldUtil.test.ts +218 -0
  123. package/Tests/Utils/Monitor/NetworkTopologyEndpointAdoption.test.ts +1755 -0
  124. package/Tests/Utils/NetworkAutomation/AutoImportRunChain.test.ts +320 -0
  125. package/Types/BaseDatabase/IncludesAnyOfGroups.ts +77 -0
  126. package/Types/Dashboard/DashboardComponents/ComponentArgument.ts +1 -0
  127. package/Types/Dashboard/DashboardComponents/DashboardMonitorListComponent.ts +1 -0
  128. package/Types/Dashboard/DashboardVariable.ts +8 -0
  129. package/Types/JSON.ts +4 -0
  130. package/Types/JSONFunctions.ts +16 -0
  131. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +13 -0
  132. package/Types/NetworkAutomation/RuleRunResult.ts +402 -13
  133. package/Types/SerializableObjectDictionary.ts +4 -0
  134. package/Types/Telemetry/InventoryLiveness.ts +56 -0
  135. package/UI/Components/CSVFileUpload/CSVFileUpload.tsx +9 -2
  136. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +46 -21
  137. package/UI/Components/Dashboard/DashboardVariableControl.tsx +285 -0
  138. package/UI/esbuild-config.js +6 -0
  139. package/Utils/Dashboard/Components/DashboardMonitorListComponent.ts +12 -1
  140. package/Utils/Dashboard/LabelVariable.ts +152 -0
  141. package/Utils/Dashboard/VariableUrlState.ts +27 -5
  142. package/Utils/MetricUnitUtil.ts +34 -0
  143. package/Utils/Monitor/DeviceMacLearningUtil.ts +177 -0
  144. package/Utils/Monitor/MetricValueFormatter.ts +342 -0
  145. package/Utils/Monitor/MonitorTemplateCustomFieldUtil.ts +124 -0
  146. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +7 -2
  147. package/Utils/Monitor/NetworkTopologyUtil.ts +693 -0
  148. package/Utils/NetworkAutomation/AutoImportRunChain.ts +138 -0
  149. package/build/dist/Models/DatabaseModels/GlobalConfig.js +12 -12
  150. package/build/dist/Models/DatabaseModels/GlobalConfig.js.map +1 -1
  151. package/build/dist/Models/DatabaseModels/InventoryItem.js +34 -1
  152. package/build/dist/Models/DatabaseModels/InventoryItem.js.map +1 -1
  153. package/build/dist/Models/DatabaseModels/NetworkDevice.js +128 -0
  154. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  155. package/build/dist/Server/EnvironmentConfig.js +44 -12
  156. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  157. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js +14 -0
  158. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1791700000000-AddMacAddressToNetworkDevice.js.map +1 -0
  159. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  160. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  161. package/build/dist/Server/Infrastructure/Redis.js +8 -8
  162. package/build/dist/Server/Infrastructure/Redis.js.map +1 -1
  163. package/build/dist/Server/Infrastructure/Status.js +10 -4
  164. package/build/dist/Server/Infrastructure/Status.js.map +1 -1
  165. package/build/dist/Server/Services/DatabaseService.js +58 -13
  166. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  167. package/build/dist/Server/Services/InventoryItemService.js +28 -1
  168. package/build/dist/Server/Services/InventoryItemService.js.map +1 -1
  169. package/build/dist/Server/Services/MonitorTemplateService.js +116 -17
  170. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  171. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +270 -37
  172. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -1
  173. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js +65 -0
  174. package/build/dist/Server/Services/NetworkDeviceDiscoveryScanService.js.map +1 -1
  175. package/build/dist/Server/Services/NetworkDeviceService.js +54 -0
  176. package/build/dist/Server/Services/NetworkDeviceService.js.map +1 -1
  177. package/build/dist/Server/Services/UserNotificationRuleService.js +60 -25
  178. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  179. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js +29 -4
  180. package/build/dist/Server/Types/Database/Permissions/ReadPermission.js.map +1 -1
  181. package/build/dist/Server/Types/Database/QueryUtil.js +36 -0
  182. package/build/dist/Server/Types/Database/QueryUtil.js.map +1 -1
  183. package/build/dist/Server/Types/Markdown.js +75 -0
  184. package/build/dist/Server/Types/Markdown.js.map +1 -1
  185. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js +53 -18
  186. package/build/dist/Server/Utils/Dashboard/PublicDashboardResourceListPolicy.js.map +1 -1
  187. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js +10 -1
  188. package/build/dist/Server/Utils/EmailRollup/EmailRollupFlushRunner.js.map +1 -1
  189. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js +2 -2
  190. package/build/dist/Server/Utils/EmailRollup/EmailRollupRenderer.js.map +1 -1
  191. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +58 -35
  192. package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
  193. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js +29 -5
  194. package/build/dist/Server/Utils/Monitor/Criteria/MetricMonitorCriteria.js.map +1 -1
  195. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js +166 -35
  196. package/build/dist/Server/Utils/Monitor/MonitorCriteriaEvaluator.js.map +1 -1
  197. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js +58 -9
  198. package/build/dist/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.js.map +1 -1
  199. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js +9 -3
  200. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageBuilder.js.map +1 -1
  201. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js +26 -12
  202. package/build/dist/Server/Utils/Monitor/MonitorCriteriaMessageFormatter.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js +74 -33
  204. package/build/dist/Server/Utils/Monitor/MonitorCriteriaObservationBuilder.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js +114 -0
  206. package/build/dist/Server/Utils/Monitor/NetworkDeviceMacLearningUtil.js.map +1 -0
  207. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +25 -0
  208. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  209. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js +9 -1
  210. package/build/dist/Server/Utils/Telemetry/EntityRegistry.js.map +1 -1
  211. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js +70 -13
  212. package/build/dist/Server/Utils/Telemetry/TelemetryEntity.js.map +1 -1
  213. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js +54 -0
  214. package/build/dist/Types/BaseDatabase/IncludesAnyOfGroups.js.map +1 -0
  215. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js +1 -0
  216. package/build/dist/Types/Dashboard/DashboardComponents/ComponentArgument.js.map +1 -1
  217. package/build/dist/Types/Dashboard/DashboardVariable.js +1 -0
  218. package/build/dist/Types/Dashboard/DashboardVariable.js.map +1 -1
  219. package/build/dist/Types/JSON.js +1 -0
  220. package/build/dist/Types/JSON.js.map +1 -1
  221. package/build/dist/Types/JSONFunctions.js +13 -0
  222. package/build/dist/Types/JSONFunctions.js.map +1 -1
  223. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +291 -6
  224. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  225. package/build/dist/Types/SerializableObjectDictionary.js +4 -0
  226. package/build/dist/Types/SerializableObjectDictionary.js.map +1 -1
  227. package/build/dist/Types/Telemetry/InventoryLiveness.js +37 -0
  228. package/build/dist/Types/Telemetry/InventoryLiveness.js.map +1 -0
  229. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js +7 -0
  230. package/build/dist/UI/Components/CSVFileUpload/CSVFileUpload.js.map +1 -1
  231. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +9 -2
  232. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  233. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js +137 -0
  234. package/build/dist/UI/Components/Dashboard/DashboardVariableControl.js.map +1 -0
  235. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js +9 -1
  236. package/build/dist/Utils/Dashboard/Components/DashboardMonitorListComponent.js.map +1 -1
  237. package/build/dist/Utils/Dashboard/LabelVariable.js +96 -0
  238. package/build/dist/Utils/Dashboard/LabelVariable.js.map +1 -0
  239. package/build/dist/Utils/Dashboard/VariableUrlState.js +19 -4
  240. package/build/dist/Utils/Dashboard/VariableUrlState.js.map +1 -1
  241. package/build/dist/Utils/MetricUnitUtil.js +26 -0
  242. package/build/dist/Utils/MetricUnitUtil.js.map +1 -1
  243. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js +112 -0
  244. package/build/dist/Utils/Monitor/DeviceMacLearningUtil.js.map +1 -0
  245. package/build/dist/Utils/Monitor/MetricValueFormatter.js +276 -0
  246. package/build/dist/Utils/Monitor/MetricValueFormatter.js.map +1 -0
  247. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js +101 -0
  248. package/build/dist/Utils/Monitor/MonitorTemplateCustomFieldUtil.js.map +1 -0
  249. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +7 -2
  250. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  251. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +484 -0
  252. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  253. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js +59 -0
  254. package/build/dist/Utils/NetworkAutomation/AutoImportRunChain.js.map +1 -0
  255. package/jest.config.json +2 -0
  256. package/package.json +1 -1
  257. package/test-setup.sh +9 -9
  258. package/tsconfig.json +51 -11
  259. package/Tests/App/Dashboard/NotificationEmailPreferences.test.tsx +0 -399
@@ -0,0 +1,975 @@
1
+ import "@testing-library/jest-dom";
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ jest,
8
+ test,
9
+ } from "@jest/globals";
10
+ import {
11
+ cleanup,
12
+ fireEvent,
13
+ render,
14
+ screen,
15
+ waitFor,
16
+ within,
17
+ } from "@testing-library/react";
18
+ import React from "react";
19
+ import getJestMockFunction, { MockFunction } from "../../MockType";
20
+
21
+ /*
22
+ * The panel every calendar-feed card shows before a link exists.
23
+ *
24
+ * The state used to be a paragraph of prose with a button under it, and the
25
+ * paragraph was the only place the reader was told that the URL is the
26
+ * credential. What this file guards:
27
+ *
28
+ * - the panel names the state, says what the button will do, and lists what
29
+ * the link will contain - each point its own line, so none of it depends
30
+ * on somebody reading a four-line sentence to the end;
31
+ * - the privacy caution is on screen BEFORE the link is minted, which is
32
+ * the only moment it can change what the reader does;
33
+ * - the action lives inside the panel whoever may take it - a Generate
34
+ * button, a Publish button, a disabled button naming the missing
35
+ * permission, an "ask an editor" note, or the plan gate;
36
+ * - the server's deployment warnings stay ABOVE the panel, where they are
37
+ * read before the click rather than after it;
38
+ * - and every panel namespaces its test ids, because the schedule page puts
39
+ * two of them on one screen.
40
+ */
41
+
42
+ let translations: Record<string, string> = {};
43
+ let permissionsForTest: Array<unknown> = [];
44
+
45
+ const getMock: MockFunction = getJestMockFunction();
46
+ const postMock: MockFunction = getJestMockFunction();
47
+
48
+ jest.mock("../../../UI/Utils/API/API", () => {
49
+ return {
50
+ __esModule: true,
51
+ default: {
52
+ get: (...args: Array<any>) => {
53
+ return getMock(...args);
54
+ },
55
+ post: (...args: Array<any>) => {
56
+ return postMock(...args);
57
+ },
58
+ getFriendlyMessage: (error: unknown): string => {
59
+ if (
60
+ error &&
61
+ typeof error === "object" &&
62
+ "message" in (error as Record<string, unknown>)
63
+ ) {
64
+ return String((error as Record<string, unknown>)["message"]);
65
+ }
66
+
67
+ return "Something went wrong";
68
+ },
69
+ },
70
+ };
71
+ });
72
+
73
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
74
+ return {
75
+ __esModule: true,
76
+ default: {
77
+ getCommonHeaders: (): Record<string, string> => {
78
+ return {};
79
+ },
80
+ updateById: async (): Promise<void> => {
81
+ return undefined;
82
+ },
83
+ deleteItem: async (): Promise<void> => {
84
+ return undefined;
85
+ },
86
+ getList: async (): Promise<Record<string, unknown>> => {
87
+ return { data: [], count: 0, skip: 0, limit: 10 };
88
+ },
89
+ },
90
+ };
91
+ });
92
+
93
+ jest.mock("../../../UI/Components/ModelDetail/CardModelDetail", () => {
94
+ return {
95
+ __esModule: true,
96
+ default: (): unknown => {
97
+ return React.createElement("div", {
98
+ "data-testid": "card-model-detail-stub",
99
+ });
100
+ },
101
+ };
102
+ });
103
+
104
+ jest.mock("../../../UI/Utils/User", () => {
105
+ return {
106
+ __esModule: true,
107
+ default: {
108
+ isMasterAdmin: (): boolean => {
109
+ return false;
110
+ },
111
+ getUserId: (): null => {
112
+ return null;
113
+ },
114
+ },
115
+ };
116
+ });
117
+
118
+ jest.mock("../../../UI/Utils/Permission", () => {
119
+ return {
120
+ __esModule: true,
121
+ default: {
122
+ getAllPermissions: (): Array<unknown> => {
123
+ return permissionsForTest;
124
+ },
125
+ getProjectPermissions: (): null => {
126
+ return null;
127
+ },
128
+ getGlobalPermissions: (): { globalPermissions: Array<unknown> } => {
129
+ return { globalPermissions: [...permissionsForTest] };
130
+ },
131
+ },
132
+ };
133
+ });
134
+
135
+ /*
136
+ * A real lookup, not the identity stub the other feed tests use: the panel is
137
+ * new copy, and a string that skipped translateString would render in English
138
+ * for every non-English reader without anything failing.
139
+ */
140
+ jest.mock("react-i18next", () => {
141
+ return {
142
+ useTranslation: () => {
143
+ return {
144
+ t: (key: string, options?: { defaultValue?: string }): string => {
145
+ return translations[key] ?? options?.defaultValue ?? key;
146
+ },
147
+ };
148
+ },
149
+ };
150
+ });
151
+
152
+ import ObjectID from "../../../Types/ObjectID";
153
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
154
+ import IconProp from "../../../Types/Icon/IconProp";
155
+ import Permission from "../../../Types/Permission";
156
+ import { JSONObject } from "../../../Types/JSON";
157
+ import FeedEmptyState, {
158
+ FeedEmptyStatePoint,
159
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/FeedEmptyState";
160
+ import {
161
+ PERSONAL_FEED_EMPTY_DESCRIPTION,
162
+ PERSONAL_FEED_EMPTY_POINT_COVERING,
163
+ PERSONAL_FEED_EMPTY_POINT_PRIVATE,
164
+ PERSONAL_FEED_EMPTY_POINT_SHIFTS,
165
+ PERSONAL_FEED_EMPTY_TITLE,
166
+ PERSONAL_FEED_ROTATE_PATH,
167
+ SHARED_FEED_EMPTY_TITLE,
168
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/CalendarFeedUtil";
169
+ import PersonalCalendarFeedCard, {
170
+ PersonalCalendarFeedVariant,
171
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/PersonalCalendarFeedCard";
172
+ import SharedCalendarFeedCard, {
173
+ SharedCalendarFeedKind,
174
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/SharedCalendarFeedCard";
175
+
176
+ const PROJECT_ID: string = "8f2a1b3c-4d5e-4f60-9a7b-1c2d3e4f5a6b";
177
+ const FEED_ID: string = "11111111-1111-4111-8111-111111111111";
178
+ const SCHEDULE_ID: ObjectID = new ObjectID(
179
+ "33333333-3333-4333-8333-333333333333",
180
+ );
181
+ const NOW: Date = new Date("2026-08-31T12:00:00.000Z");
182
+
183
+ const HTTPS_URL: string =
184
+ "https://oneuptime.example.com/api/on-call-calendar/user/abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG/shifts.ics";
185
+
186
+ const EMPTY_STATUS_JSON: JSONObject = {
187
+ exists: false,
188
+ feedId: null,
189
+ isEnabled: false,
190
+ needsRegeneration: false,
191
+ tokenHint: null,
192
+ rotatedAt: null,
193
+ previousTokenExpiresAt: null,
194
+ lastFetchedAt: null,
195
+ lastFetchedClient: null,
196
+ fetchCount: 0,
197
+ lastRenderTruncated: false,
198
+ settings: { pastDays: 2, futureDays: 90 },
199
+ urls: null,
200
+ hostWarning: null,
201
+ protocolWarning: null,
202
+ };
203
+
204
+ const ACTIVE_STATUS_JSON: JSONObject = {
205
+ ...EMPTY_STATUS_JSON,
206
+ exists: true,
207
+ feedId: FEED_ID,
208
+ isEnabled: true,
209
+ tokenHint: "k3Qx",
210
+ rotatedAt: "2026-08-01T10:00:00.000Z",
211
+ urls: {
212
+ https: HTTPS_URL,
213
+ webcal: HTTPS_URL.replace("https:", "webcals:"),
214
+ googleAdd: `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(
215
+ HTTPS_URL,
216
+ )}`,
217
+ },
218
+ };
219
+
220
+ type OkFunction = (json: JSONObject) => HTTPResponse<JSONObject>;
221
+
222
+ const ok: OkFunction = (json: JSONObject): HTTPResponse<JSONObject> => {
223
+ return new HTTPResponse<JSONObject>(200, json, {});
224
+ };
225
+
226
+ function resetMocks(): void {
227
+ translations = {};
228
+ permissionsForTest = [];
229
+ getMock.mockReset();
230
+ postMock.mockReset();
231
+ }
232
+
233
+ function goToProjectPage(): void {
234
+ window.history.pushState({}, "", `/dashboard/${PROJECT_ID}/on-call-duty`);
235
+ }
236
+
237
+ type IsBeforeFunction = (first: HTMLElement, second: HTMLElement) => boolean;
238
+
239
+ /** True when `first` comes before `second` in document order. */
240
+ const isBefore: IsBeforeFunction = (
241
+ first: HTMLElement,
242
+ second: HTMLElement,
243
+ ): boolean => {
244
+ return Boolean(
245
+ first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
246
+ );
247
+ };
248
+
249
+ type ActionFunction = (label: string) => React.ReactElement;
250
+
251
+ const action: ActionFunction = (label: string): React.ReactElement => {
252
+ return <button data-testid="panel-action">{label}</button>;
253
+ };
254
+
255
+ describe("FeedEmptyState", () => {
256
+ beforeEach(() => {
257
+ resetMocks();
258
+ });
259
+
260
+ afterEach(() => {
261
+ cleanup();
262
+ });
263
+
264
+ test("names the state, says what the action does and hosts the action itself", () => {
265
+ render(
266
+ <FeedEmptyState
267
+ idPrefix="feed"
268
+ title="No calendar link yet"
269
+ description="Generate one, then subscribe from your calendar app."
270
+ control={action("Generate")}
271
+ />,
272
+ );
273
+
274
+ expect(screen.getByTestId("feed-empty-state")).toBeInTheDocument();
275
+ expect(screen.getByTestId("feed-empty-title")).toHaveTextContent(
276
+ "No calendar link yet",
277
+ );
278
+ expect(screen.getByTestId("feed-empty-description")).toHaveTextContent(
279
+ "Generate one, then subscribe from your calendar app.",
280
+ );
281
+
282
+ // The action is INSIDE the panel, not loose underneath it.
283
+ expect(
284
+ within(screen.getByTestId("feed-empty-control")).getByTestId(
285
+ "panel-action",
286
+ ),
287
+ ).toBeInTheDocument();
288
+ });
289
+
290
+ test("no bullet list at all when there is nothing to list", () => {
291
+ const { rerender } = render(
292
+ <FeedEmptyState
293
+ idPrefix="feed"
294
+ title="Title"
295
+ description="Description"
296
+ control={action("Go")}
297
+ />,
298
+ );
299
+
300
+ expect(screen.queryByTestId("feed-empty-points")).not.toBeInTheDocument();
301
+
302
+ // An empty array is the same as no array - not an empty <ul>.
303
+ rerender(
304
+ <FeedEmptyState
305
+ idPrefix="feed"
306
+ title="Title"
307
+ description="Description"
308
+ points={[]}
309
+ control={action("Go")}
310
+ />,
311
+ );
312
+
313
+ expect(screen.queryByTestId("feed-empty-points")).not.toBeInTheDocument();
314
+ });
315
+
316
+ test("renders one bullet per point, in the order given", () => {
317
+ const points: Array<FeedEmptyStatePoint> = [
318
+ { icon: IconProp.Check, text: "First point" },
319
+ { icon: IconProp.Check, text: "Second point" },
320
+ { icon: IconProp.Lock, text: "Third point" },
321
+ ];
322
+
323
+ render(
324
+ <FeedEmptyState
325
+ idPrefix="feed"
326
+ title="Title"
327
+ description="Description"
328
+ points={points}
329
+ control={action("Go")}
330
+ />,
331
+ );
332
+
333
+ const list: HTMLElement = screen.getByTestId("feed-empty-points");
334
+ expect(within(list).getAllByRole("listitem")).toHaveLength(3);
335
+
336
+ expect(screen.getByTestId("feed-empty-point-0")).toHaveTextContent(
337
+ "First point",
338
+ );
339
+ expect(screen.getByTestId("feed-empty-point-1")).toHaveTextContent(
340
+ "Second point",
341
+ );
342
+ expect(screen.getByTestId("feed-empty-point-2")).toHaveTextContent(
343
+ "Third point",
344
+ );
345
+ });
346
+
347
+ /*
348
+ * The ticks and the caution have to read differently at a glance: three
349
+ * identical grey glyphs would make "treat it like a password" look like
350
+ * another feature bullet.
351
+ */
352
+ test("a point keeps its own icon colour, and falls back to the muted default", () => {
353
+ render(
354
+ <FeedEmptyState
355
+ idPrefix="feed"
356
+ title="Title"
357
+ description="Description"
358
+ points={[
359
+ {
360
+ icon: IconProp.Check,
361
+ text: "Included",
362
+ iconClassName: "text-indigo-600",
363
+ },
364
+ { icon: IconProp.Lock, text: "Caution" },
365
+ ]}
366
+ control={action("Go")}
367
+ />,
368
+ );
369
+
370
+ expect(screen.getByTestId("feed-empty-point-icon-0")).toHaveClass(
371
+ "text-indigo-600",
372
+ );
373
+ expect(screen.getByTestId("feed-empty-point-icon-0")).not.toHaveClass(
374
+ "text-gray-400",
375
+ );
376
+ expect(screen.getByTestId("feed-empty-point-icon-1")).toHaveClass(
377
+ "text-gray-400",
378
+ );
379
+ });
380
+
381
+ test("the badge is a calendar unless the caller asks for another icon", () => {
382
+ const { unmount } = render(
383
+ <FeedEmptyState
384
+ idPrefix="feed"
385
+ title="Title"
386
+ description="Description"
387
+ control={action("Go")}
388
+ />,
389
+ );
390
+
391
+ const defaultBadge: string =
392
+ screen.getByTestId("feed-empty-icon").innerHTML;
393
+ unmount();
394
+
395
+ render(
396
+ <FeedEmptyState
397
+ idPrefix="feed"
398
+ title="Title"
399
+ description="Description"
400
+ icon={IconProp.Calendar}
401
+ control={action("Go")}
402
+ />,
403
+ );
404
+
405
+ expect(screen.getByTestId("feed-empty-icon").innerHTML).toBe(defaultBadge);
406
+ cleanup();
407
+
408
+ render(
409
+ <FeedEmptyState
410
+ idPrefix="feed"
411
+ title="Title"
412
+ description="Description"
413
+ icon={IconProp.Team}
414
+ control={action("Go")}
415
+ />,
416
+ );
417
+
418
+ expect(screen.getByTestId("feed-empty-icon").innerHTML).not.toBe(
419
+ defaultBadge,
420
+ );
421
+ });
422
+
423
+ test("every visible string goes through the translator, bullets included", () => {
424
+ translations = {
425
+ "No calendar link yet": "Noch kein Kalenderlink",
426
+ "Generate a private link.": "Erzeugen Sie einen privaten Link.",
427
+ "Every shift you hold.": "Jede Schicht, die Sie haben.",
428
+ };
429
+
430
+ render(
431
+ <FeedEmptyState
432
+ idPrefix="feed"
433
+ title="No calendar link yet"
434
+ description="Generate a private link."
435
+ points={[{ icon: IconProp.Check, text: "Every shift you hold." }]}
436
+ control={action("Go")}
437
+ />,
438
+ );
439
+
440
+ expect(screen.getByTestId("feed-empty-title")).toHaveTextContent(
441
+ "Noch kein Kalenderlink",
442
+ );
443
+ expect(screen.getByTestId("feed-empty-description")).toHaveTextContent(
444
+ "Erzeugen Sie einen privaten Link.",
445
+ );
446
+ expect(screen.getByTestId("feed-empty-point-0")).toHaveTextContent(
447
+ "Jede Schicht, die Sie haben.",
448
+ );
449
+ });
450
+
451
+ /*
452
+ * The schedule page renders the personal panel and the shared panel one
453
+ * above the other. Un-namespaced ids would make getByTestId ambiguous and,
454
+ * worse, make a passing assertion about the wrong panel.
455
+ */
456
+ test("two panels on one page keep entirely distinct test ids", () => {
457
+ render(
458
+ <div>
459
+ <FeedEmptyState
460
+ idPrefix="mine"
461
+ title="Mine"
462
+ description="My link"
463
+ points={[{ icon: IconProp.Check, text: "My point" }]}
464
+ control={action("Generate")}
465
+ />
466
+ <FeedEmptyState
467
+ idPrefix="theirs"
468
+ title="Theirs"
469
+ description="Team link"
470
+ points={[{ icon: IconProp.Check, text: "Their point" }]}
471
+ control={action("Publish")}
472
+ />
473
+ </div>,
474
+ );
475
+
476
+ expect(screen.getByTestId("mine-empty-title")).toHaveTextContent("Mine");
477
+ expect(screen.getByTestId("theirs-empty-title")).toHaveTextContent(
478
+ "Theirs",
479
+ );
480
+ expect(screen.getByTestId("mine-empty-point-0")).toHaveTextContent(
481
+ "My point",
482
+ );
483
+ expect(screen.getByTestId("theirs-empty-point-0")).toHaveTextContent(
484
+ "Their point",
485
+ );
486
+ expect(screen.getAllByTestId("panel-action")).toHaveLength(2);
487
+ });
488
+ });
489
+
490
+ describe("PersonalCalendarFeedCard empty state (settings page)", () => {
491
+ beforeEach(() => {
492
+ resetMocks();
493
+ goToProjectPage();
494
+ });
495
+
496
+ afterEach(() => {
497
+ cleanup();
498
+ });
499
+
500
+ test("names the state, says what Generate does and lists what the link will hold", async () => {
501
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
502
+
503
+ render(
504
+ <PersonalCalendarFeedCard
505
+ variant={PersonalCalendarFeedVariant.Full}
506
+ now={NOW}
507
+ />,
508
+ );
509
+
510
+ await waitFor(() => {
511
+ expect(
512
+ screen.getByTestId("personal-calendar-feed-empty-state"),
513
+ ).toBeInTheDocument();
514
+ });
515
+
516
+ expect(
517
+ screen.getByTestId("personal-calendar-feed-empty-title"),
518
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_TITLE);
519
+ expect(
520
+ screen.getByTestId("personal-calendar-feed-empty-description"),
521
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_DESCRIPTION);
522
+
523
+ expect(
524
+ screen.getByTestId("personal-calendar-feed-empty-point-0"),
525
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_POINT_SHIFTS);
526
+ expect(
527
+ screen.getByTestId("personal-calendar-feed-empty-point-1"),
528
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_POINT_COVERING);
529
+ expect(
530
+ screen.getByTestId("personal-calendar-feed-empty-point-2"),
531
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_POINT_PRIVATE);
532
+ });
533
+
534
+ /*
535
+ * The URL is the credential: anyone holding it reads the shifts. Saying so
536
+ * after the link is minted and pasted into a shared calendar is too late,
537
+ * so the caution has to be part of the state that offers to mint it.
538
+ */
539
+ test("the reader is told the link is a secret BEFORE minting one", async () => {
540
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
541
+
542
+ render(
543
+ <PersonalCalendarFeedCard
544
+ variant={PersonalCalendarFeedVariant.Full}
545
+ now={NOW}
546
+ />,
547
+ );
548
+
549
+ await waitFor(() => {
550
+ expect(
551
+ screen.getByTestId("personal-calendar-feed-empty-state"),
552
+ ).toBeInTheDocument();
553
+ });
554
+
555
+ const caution: HTMLElement = screen.getByTestId(
556
+ "personal-calendar-feed-empty-point-2",
557
+ );
558
+ expect(caution).toHaveTextContent(/private to you/i);
559
+ expect(caution).toHaveTextContent(/like a password/i);
560
+ expect(
561
+ isBefore(caution, screen.getByTestId("personal-calendar-feed-generate")),
562
+ ).toBe(true);
563
+ });
564
+
565
+ test("Generate lives inside the panel and still posts to /feed/rotate", async () => {
566
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
567
+ postMock.mockResolvedValue(ok(ACTIVE_STATUS_JSON));
568
+
569
+ render(
570
+ <PersonalCalendarFeedCard
571
+ variant={PersonalCalendarFeedVariant.Full}
572
+ now={NOW}
573
+ />,
574
+ );
575
+
576
+ await waitFor(() => {
577
+ expect(
578
+ screen.getByTestId("personal-calendar-feed-empty-control"),
579
+ ).toBeInTheDocument();
580
+ });
581
+
582
+ const control: HTMLElement = screen.getByTestId(
583
+ "personal-calendar-feed-empty-control",
584
+ );
585
+ fireEvent.click(
586
+ within(control).getByTestId("personal-calendar-feed-generate"),
587
+ );
588
+
589
+ await waitFor(() => {
590
+ expect(
591
+ screen.getByTestId("personal-calendar-feed-active"),
592
+ ).toBeInTheDocument();
593
+ });
594
+
595
+ expect(
596
+ String((postMock.mock.calls[0]![0] as Record<string, unknown>)["url"]),
597
+ ).toContain(PERSONAL_FEED_ROTATE_PATH);
598
+
599
+ // The panel is for the absent link only; it goes when the link arrives.
600
+ expect(
601
+ screen.queryByTestId("personal-calendar-feed-empty-state"),
602
+ ).not.toBeInTheDocument();
603
+ });
604
+
605
+ /*
606
+ * "This link will be unreachable" is only actionable before the click. The
607
+ * warnings are Alerts, and an Alert buried under three bullets inside a
608
+ * grey panel is an Alert nobody reads.
609
+ */
610
+ test("the deployment warnings sit above the panel, and Generate is still offered", async () => {
611
+ getMock.mockResolvedValue(
612
+ ok({
613
+ ...EMPTY_STATUS_JSON,
614
+ hostWarning: "HOST is not set, so the link points at localhost.",
615
+ protocolWarning: "HTTP_PROTOCOL is http, so the link is not encrypted.",
616
+ }),
617
+ );
618
+
619
+ render(
620
+ <PersonalCalendarFeedCard
621
+ variant={PersonalCalendarFeedVariant.Full}
622
+ now={NOW}
623
+ />,
624
+ );
625
+
626
+ await waitFor(() => {
627
+ expect(
628
+ screen.getByTestId("personal-calendar-feed-empty-state"),
629
+ ).toBeInTheDocument();
630
+ });
631
+
632
+ const panel: HTMLElement = screen.getByTestId(
633
+ "personal-calendar-feed-empty-state",
634
+ );
635
+ const host: HTMLElement = screen.getByTestId(
636
+ "personal-calendar-feed-host-warning",
637
+ );
638
+ const protocol: HTMLElement = screen.getByTestId(
639
+ "personal-calendar-feed-protocol-warning",
640
+ );
641
+
642
+ expect(isBefore(host, panel)).toBe(true);
643
+ expect(isBefore(protocol, panel)).toBe(true);
644
+ expect(panel).not.toContainElement(host);
645
+ expect(panel).not.toContainElement(protocol);
646
+
647
+ // The warning informs the click; it does not take the button away.
648
+ expect(
649
+ screen.getByTestId("personal-calendar-feed-generate"),
650
+ ).toBeInTheDocument();
651
+ });
652
+
653
+ test("a failed Generate leaves the panel standing and shows the reason", async () => {
654
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
655
+ postMock.mockRejectedValue(new Error("Feeds are disabled on this server."));
656
+
657
+ render(
658
+ <PersonalCalendarFeedCard
659
+ variant={PersonalCalendarFeedVariant.Full}
660
+ now={NOW}
661
+ />,
662
+ );
663
+
664
+ await waitFor(() => {
665
+ expect(
666
+ screen.getByTestId("personal-calendar-feed-generate"),
667
+ ).toBeInTheDocument();
668
+ });
669
+
670
+ fireEvent.click(screen.getByTestId("personal-calendar-feed-generate"));
671
+
672
+ await waitFor(() => {
673
+ expect(
674
+ screen.getByText("Feeds are disabled on this server."),
675
+ ).toBeInTheDocument();
676
+ });
677
+
678
+ expect(
679
+ screen.getByTestId("personal-calendar-feed-empty-state"),
680
+ ).toBeInTheDocument();
681
+ expect(
682
+ screen.getByTestId("personal-calendar-feed-generate"),
683
+ ).toBeInTheDocument();
684
+ });
685
+ });
686
+
687
+ describe("PersonalCalendarFeedCard empty state (schedule page)", () => {
688
+ beforeEach(() => {
689
+ resetMocks();
690
+ goToProjectPage();
691
+ });
692
+
693
+ afterEach(() => {
694
+ cleanup();
695
+ });
696
+
697
+ /*
698
+ * The schedule page is half a card inside somebody else's page. It gets the
699
+ * same panel so the two halves match, but the three lines of "what's in it"
700
+ * stay on the settings page the reader went to for the feature.
701
+ */
702
+ test("the narrow variant shows the same panel without the bullet list", async () => {
703
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
704
+
705
+ render(
706
+ <PersonalCalendarFeedCard
707
+ variant={PersonalCalendarFeedVariant.Schedule}
708
+ scheduleId={SCHEDULE_ID}
709
+ now={NOW}
710
+ />,
711
+ );
712
+
713
+ await waitFor(() => {
714
+ expect(
715
+ screen.getByTestId("schedule-personal-calendar-feed-empty-state"),
716
+ ).toBeInTheDocument();
717
+ });
718
+
719
+ expect(
720
+ screen.getByTestId("schedule-personal-calendar-feed-empty-title"),
721
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_TITLE);
722
+ expect(
723
+ screen.getByTestId("schedule-personal-calendar-feed-empty-description"),
724
+ ).toHaveTextContent(PERSONAL_FEED_EMPTY_DESCRIPTION);
725
+ expect(
726
+ screen.queryByTestId("schedule-personal-calendar-feed-empty-points"),
727
+ ).not.toBeInTheDocument();
728
+
729
+ expect(
730
+ within(
731
+ screen.getByTestId("schedule-personal-calendar-feed-empty-control"),
732
+ ).getByTestId("schedule-personal-calendar-feed-generate"),
733
+ ).toBeInTheDocument();
734
+ });
735
+
736
+ test("its test ids never collide with the settings page's", async () => {
737
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
738
+
739
+ render(
740
+ <div>
741
+ <PersonalCalendarFeedCard
742
+ variant={PersonalCalendarFeedVariant.Full}
743
+ now={NOW}
744
+ />
745
+ <PersonalCalendarFeedCard
746
+ variant={PersonalCalendarFeedVariant.Schedule}
747
+ scheduleId={SCHEDULE_ID}
748
+ now={NOW}
749
+ />
750
+ </div>,
751
+ );
752
+
753
+ await waitFor(() => {
754
+ expect(
755
+ screen.getByTestId("schedule-personal-calendar-feed-empty-state"),
756
+ ).toBeInTheDocument();
757
+ });
758
+
759
+ // Both panels are on screen, and each id resolves to exactly one of them.
760
+ expect(
761
+ screen.getAllByTestId("personal-calendar-feed-empty-state"),
762
+ ).toHaveLength(1);
763
+ expect(
764
+ screen.getAllByTestId("schedule-personal-calendar-feed-empty-state"),
765
+ ).toHaveLength(1);
766
+ // Only the settings-page panel carries the bullets.
767
+ expect(
768
+ screen.getAllByTestId("personal-calendar-feed-empty-points"),
769
+ ).toHaveLength(1);
770
+ });
771
+ });
772
+
773
+ describe("SharedCalendarFeedCard empty state", () => {
774
+ beforeEach(() => {
775
+ resetMocks();
776
+ goToProjectPage();
777
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
778
+ });
779
+
780
+ afterEach(() => {
781
+ cleanup();
782
+ });
783
+
784
+ test("a schedule with no shared link says who the link is for and offers Publish inside the panel", async () => {
785
+ permissionsForTest = [Permission.ProjectAdmin];
786
+
787
+ render(
788
+ <SharedCalendarFeedCard
789
+ kind={SharedCalendarFeedKind.Schedule}
790
+ scheduleId={SCHEDULE_ID}
791
+ scheduleTimezone="Europe/Stockholm"
792
+ now={NOW}
793
+ />,
794
+ );
795
+
796
+ await waitFor(() => {
797
+ expect(
798
+ screen.getByTestId("schedule-shared-calendar-feed-empty-state"),
799
+ ).toBeInTheDocument();
800
+ });
801
+
802
+ expect(
803
+ screen.getByTestId("schedule-shared-calendar-feed-empty-title"),
804
+ ).toHaveTextContent(SHARED_FEED_EMPTY_TITLE);
805
+ expect(
806
+ screen.getByTestId("schedule-shared-calendar-feed-empty-description"),
807
+ ).toHaveTextContent("anyone with the link sees everyone's shifts on it");
808
+
809
+ expect(
810
+ within(
811
+ screen.getByTestId("schedule-shared-calendar-feed-empty-control"),
812
+ ).getByTestId("schedule-shared-calendar-feed-publish"),
813
+ ).toBeInTheDocument();
814
+ });
815
+
816
+ /*
817
+ * The schedule page stacks the personal panel on the shared one. Same
818
+ * shape, same spacing - so the badge is the only thing carrying "this one
819
+ * is mine, that one is the team's" before the reader starts reading.
820
+ */
821
+ test("the shared panel wears a different badge from the personal one", async () => {
822
+ permissionsForTest = [Permission.ProjectAdmin];
823
+
824
+ render(
825
+ <div>
826
+ <PersonalCalendarFeedCard
827
+ variant={PersonalCalendarFeedVariant.Full}
828
+ now={NOW}
829
+ />
830
+ <SharedCalendarFeedCard
831
+ kind={SharedCalendarFeedKind.Schedule}
832
+ scheduleId={SCHEDULE_ID}
833
+ scheduleTimezone="Europe/Stockholm"
834
+ now={NOW}
835
+ />
836
+ </div>,
837
+ );
838
+
839
+ await waitFor(() => {
840
+ expect(
841
+ screen.getByTestId("schedule-shared-calendar-feed-empty-icon"),
842
+ ).toBeInTheDocument();
843
+ });
844
+
845
+ expect(
846
+ screen.getByTestId("schedule-shared-calendar-feed-empty-icon").innerHTML,
847
+ ).not.toBe(
848
+ screen.getByTestId("personal-calendar-feed-empty-icon").innerHTML,
849
+ );
850
+ });
851
+
852
+ test("the project-wide panel says it covers every schedule in the project", async () => {
853
+ permissionsForTest = [Permission.ProjectAdmin];
854
+
855
+ render(
856
+ <SharedCalendarFeedCard
857
+ kind={SharedCalendarFeedKind.Project}
858
+ now={NOW}
859
+ />,
860
+ );
861
+
862
+ await waitFor(() => {
863
+ expect(
864
+ screen.getByTestId("project-shared-calendar-feed-empty-state"),
865
+ ).toBeInTheDocument();
866
+ });
867
+
868
+ expect(
869
+ screen.getByTestId("project-shared-calendar-feed-empty-title"),
870
+ ).toHaveTextContent(SHARED_FEED_EMPTY_TITLE);
871
+ expect(
872
+ screen.getByTestId("project-shared-calendar-feed-empty-description"),
873
+ ).toHaveTextContent("every shift on every schedule in this project");
874
+ });
875
+
876
+ /*
877
+ * Not every reader of a schedule may publish its link, and what stands in
878
+ * for the button differs by how much the client knows: a real-but-
879
+ * insufficient permission gets a disabled button naming what is missing, a
880
+ * permission snapshot that has not landed gets a plain note rather than an
881
+ * accusation. Both belong in the same slot, so the panel reads the same
882
+ * either way.
883
+ */
884
+ test("a reader with an insufficient permission gets a disabled Publish in the action slot", async () => {
885
+ permissionsForTest = [Permission.ReadProjectOnCallDutyPolicySchedule];
886
+
887
+ render(
888
+ <SharedCalendarFeedCard
889
+ kind={SharedCalendarFeedKind.Schedule}
890
+ scheduleId={SCHEDULE_ID}
891
+ scheduleTimezone="Europe/Stockholm"
892
+ now={NOW}
893
+ />,
894
+ );
895
+
896
+ await waitFor(() => {
897
+ expect(
898
+ screen.getByTestId("schedule-shared-calendar-feed-empty-state"),
899
+ ).toBeInTheDocument();
900
+ });
901
+
902
+ const publish: HTMLElement = within(
903
+ screen.getByTestId("schedule-shared-calendar-feed-empty-control"),
904
+ ).getByTestId("schedule-shared-calendar-feed-publish");
905
+
906
+ expect(publish).toBeDisabled();
907
+ // The panel still explains the feature; only the action is withheld.
908
+ expect(
909
+ screen.getByTestId("schedule-shared-calendar-feed-empty-description"),
910
+ ).toHaveTextContent("anyone with the link sees everyone's shifts on it");
911
+ });
912
+
913
+ test("a reader with no permissions at all gets the 'ask an editor' note there instead", async () => {
914
+ permissionsForTest = [];
915
+
916
+ render(
917
+ <SharedCalendarFeedCard
918
+ kind={SharedCalendarFeedKind.Schedule}
919
+ scheduleId={SCHEDULE_ID}
920
+ scheduleTimezone="Europe/Stockholm"
921
+ now={NOW}
922
+ />,
923
+ );
924
+
925
+ await waitFor(() => {
926
+ expect(
927
+ screen.getByTestId("schedule-shared-calendar-feed-empty-state"),
928
+ ).toBeInTheDocument();
929
+ });
930
+
931
+ const control: HTMLElement = screen.getByTestId(
932
+ "schedule-shared-calendar-feed-empty-control",
933
+ );
934
+
935
+ expect(
936
+ within(control).getByTestId("schedule-shared-calendar-feed-ask-editor"),
937
+ ).toHaveTextContent("Ask an editor of this schedule to publish it.");
938
+ expect(
939
+ within(control).queryByTestId("schedule-shared-calendar-feed-publish"),
940
+ ).not.toBeInTheDocument();
941
+ });
942
+
943
+ test("the deployment warnings sit above the shared panel too", async () => {
944
+ permissionsForTest = [Permission.ProjectAdmin];
945
+ getMock.mockResolvedValue(
946
+ ok({
947
+ ...EMPTY_STATUS_JSON,
948
+ hostWarning: "HOST is not set, so the link points at localhost.",
949
+ }),
950
+ );
951
+
952
+ render(
953
+ <SharedCalendarFeedCard
954
+ kind={SharedCalendarFeedKind.Project}
955
+ now={NOW}
956
+ />,
957
+ );
958
+
959
+ await waitFor(() => {
960
+ expect(
961
+ screen.getByTestId("project-shared-calendar-feed-empty-state"),
962
+ ).toBeInTheDocument();
963
+ });
964
+
965
+ const panel: HTMLElement = screen.getByTestId(
966
+ "project-shared-calendar-feed-empty-state",
967
+ );
968
+ const host: HTMLElement = screen.getByTestId(
969
+ "project-shared-calendar-feed-host-warning",
970
+ );
971
+
972
+ expect(isBefore(host, panel)).toBe(true);
973
+ expect(panel).not.toContainElement(host);
974
+ });
975
+ });