@oneuptime/common 12.0.28 → 12.0.30

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 (367) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +43 -0
  2. package/Models/DatabaseModels/Index.ts +11 -0
  3. package/Models/DatabaseModels/MonitorTemplate.ts +15 -3
  4. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +43 -0
  5. package/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.ts +662 -0
  6. package/Models/DatabaseModels/ProjectOnCallCalendarFeed.ts +584 -0
  7. package/Models/DatabaseModels/StatusPageDomain.ts +43 -0
  8. package/Models/DatabaseModels/UserOnCallCalendarFeed.ts +546 -0
  9. package/Models/DatabaseModels/UserOnCallShiftReminder.ts +231 -0
  10. package/Models/DatabaseModels/UserOnCallShiftReminderLog.ts +331 -0
  11. package/Server/API/DashboardDomainAPI.ts +74 -0
  12. package/Server/API/MicrosoftTeamsAPI.ts +81 -180
  13. package/Server/API/OnCallCalendarAPI.ts +2194 -0
  14. package/Server/API/StatusPageDomainAPI.ts +74 -0
  15. package/Server/EnvironmentConfig.ts +138 -0
  16. package/Server/Infrastructure/OnCallCalendarFeedCache.ts +1050 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.ts +43 -0
  18. package/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.ts +40 -0
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.ts +253 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  21. package/Server/Middleware/OnCallCalendarFeedRateLimit.ts +487 -0
  22. package/Server/Middleware/ProjectAuthorization.ts +6 -1
  23. package/Server/Services/DashboardDomainService.ts +131 -0
  24. package/Server/Services/Index.ts +12 -0
  25. package/Server/Services/LogPipelineProcessorService.ts +88 -0
  26. package/Server/Services/MonitorTemplateService.ts +70 -11
  27. package/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.ts +32 -0
  28. package/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.ts +260 -0
  29. package/Server/Services/OnCallDutyPolicyScheduleLayerService.ts +70 -3
  30. package/Server/Services/OnCallDutyPolicyScheduleLayerUserService.ts +138 -3
  31. package/Server/Services/OnCallDutyPolicyScheduleService.ts +1465 -134
  32. package/Server/Services/OnCallDutyPolicyUserOverrideService.ts +167 -31
  33. package/Server/Services/ProjectOnCallCalendarFeedService.ts +192 -0
  34. package/Server/Services/StatusPageDomainService.ts +163 -3
  35. package/Server/Services/StatusPagePrivateUserSessionService.ts +197 -56
  36. package/Server/Services/TeamMemberService.ts +489 -1
  37. package/Server/Services/TeamService.ts +50 -0
  38. package/Server/Services/UserNotificationSettingService.ts +44 -1
  39. package/Server/Services/UserOnCallCalendarFeedService.ts +159 -0
  40. package/Server/Services/UserOnCallShiftReminderLogService.ts +151 -0
  41. package/Server/Services/UserOnCallShiftReminderService.ts +112 -0
  42. package/Server/Types/Domain.ts +17 -0
  43. package/Server/Utils/APIKey/AccessPermission.ts +24 -0
  44. package/Server/Utils/Greenlock/Greenlock.ts +156 -58
  45. package/Server/Utils/LogPipelineProcessorValidation.ts +112 -0
  46. package/Server/Utils/OnCall/CalendarFeedToken.ts +241 -0
  47. package/Server/Utils/OnCall/OnCallCalendarFeedRenderer.ts +1488 -0
  48. package/Server/Utils/OnCall/OnCallCalendarFeedUrls.ts +230 -0
  49. package/Server/Utils/OnCall/OnCallShiftChangeListeners.ts +227 -0
  50. package/Server/Utils/OnCall/OnCallShiftMaterializer.ts +1127 -0
  51. package/Server/Utils/OnCall/OnCallShiftReminderListener.ts +174 -0
  52. package/Server/Utils/OnCall/OnCallShiftReminderRunner.ts +2584 -0
  53. package/Server/Utils/Response.ts +134 -0
  54. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.ts +1094 -45
  55. package/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.ts +153 -17
  56. package/Server/Utils/StartServer.ts +10 -0
  57. package/Server/Utils/Telemetry/AppMetrics.ts +51 -0
  58. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.ts +23 -11
  59. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.ts +0 -170
  60. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.ts +158 -0
  61. package/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.ts +23 -11
  62. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +35 -13
  63. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +505 -0
  64. package/Tests/App/Dashboard/OnCallCalendarFeedLinks.test.tsx +229 -0
  65. package/Tests/App/Dashboard/OnCallCalendarFeedPlanGate.test.tsx +489 -0
  66. package/Tests/App/Dashboard/OnCallCalendarFeedSideMenus.test.tsx +149 -0
  67. package/Tests/App/Dashboard/OnCallCalendarFeedUtil.test.ts +855 -0
  68. package/Tests/App/Dashboard/OnCallPersonalCalendarFeed.test.tsx +1209 -0
  69. package/Tests/App/Dashboard/OnCallSharedCalendarFeedCard.test.tsx +1019 -0
  70. package/Tests/App/Dashboard/OnCallShiftRemindersAndUpcomingShifts.test.tsx +655 -0
  71. package/Tests/App/Dashboard/UserSettingsSetupChecklistModel.test.ts +73 -0
  72. package/Tests/App/StatusPage/StatusPageLoginCodeBootstrap.test.ts +106 -0
  73. package/Tests/App/StatusPage/StatusPageLoginCodeUtil.test.ts +84 -0
  74. package/Tests/App/StatusPage/StatusPageRedirectSafety.test.ts +119 -0
  75. package/Tests/Models/MonitorTemplateMonitorNameColumn.test.ts +258 -0
  76. package/Tests/Models/OnCallCalendarFeedModels.test.ts +642 -0
  77. package/Tests/Models/OnCallDutyPolicyScheduleCalendarFeed.test.ts +288 -0
  78. package/Tests/Models/OnCallDutyPolicyScheduleShiftConfigVersion.test.ts +88 -0
  79. package/Tests/Models/UserOnCallShiftReminderModels.test.ts +368 -0
  80. package/Tests/Server/API/CustomDomainReissueSslAPI.test.ts +469 -0
  81. package/Tests/Server/API/Helpers.ts +8 -0
  82. package/Tests/Server/API/MicrosoftTeamsBotMessagesEndpoint.test.ts +729 -0
  83. package/Tests/Server/API/MicrosoftTeamsBotTestEndpoint.test.ts +26 -0
  84. package/Tests/Server/API/MicrosoftTeamsLegacyWebhookRemoval.test.ts +402 -0
  85. package/Tests/Server/API/OnCallCalendarAPI.test.ts +4461 -0
  86. package/Tests/Server/EnvironmentConfigOnCallCalendarFeed.test.ts +357 -0
  87. package/Tests/Server/Infrastructure/OnCallCalendarFeedCache.test.ts +1589 -0
  88. package/Tests/Server/Infrastructure/Postgres/AddOnCallCalendarFeedsMigration.test.ts +369 -0
  89. package/Tests/Server/Middleware/OnCallCalendarFeedRateLimit.test.ts +1365 -0
  90. package/Tests/Server/Middleware/ProjectAuthorizationApiKeyMiddleware.test.ts +4 -0
  91. package/Tests/Server/Services/AddCertificateReissueRequestedAtToDomainsMigration.test.ts +287 -0
  92. package/Tests/Server/Services/CustomDomainCertificateReissue.test.ts +477 -0
  93. package/Tests/Server/Services/FeedRetentionConsistency.test.ts +70 -0
  94. package/Tests/Server/Services/LogPipelineProcessorSaveValidation.test.ts +249 -0
  95. package/Tests/Server/Services/MonitorTemplateServiceBulkSync.test.ts +549 -0
  96. package/Tests/Server/Services/MonitorTemplateServiceNetworkDeviceSync.test.ts +16 -3
  97. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +57 -0
  98. package/Tests/Server/Services/OnCallCalendarFeedServices.test.ts +1103 -0
  99. package/Tests/Server/Services/OnCallDutyPolicyScheduleResolver.test.ts +1037 -0
  100. package/Tests/Server/Services/OnCallShiftConfigPropagation.test.ts +1270 -0
  101. package/Tests/Server/Services/SecurityEventLastErrorColumnWidth.test.ts +151 -15
  102. package/Tests/Server/Services/StatusPagePrivateUserSessionService.test.ts +587 -0
  103. package/Tests/Server/Services/TeamDeleteMemberCleanup.test.ts +156 -0
  104. package/Tests/Server/Services/TeamMemberOnCallCleanup.test.ts +755 -0
  105. package/Tests/Server/Services/UserNotificationSettingShiftReminderDefaults.test.ts +267 -0
  106. package/Tests/Server/Services/UserOnCallShiftReminderServices.test.ts +583 -0
  107. package/Tests/Server/Types/Database/Permissions/ApiKeyFileAccess.test.ts +286 -0
  108. package/Tests/Server/Types/Database/Permissions/MonitorTemplateColumnAccess.test.ts +328 -0
  109. package/Tests/Server/Types/Database/Permissions/OnCallCalendarFeedPermissions.test.ts +254 -0
  110. package/Tests/Server/Types/Database/QueryHelperFindWithSameTextAndIsNull.test.ts +83 -0
  111. package/Tests/Server/Utils/AI/SRE/ConfidenceSignal.test.ts +377 -0
  112. package/Tests/Server/Utils/APIKey/AccessPermission.test.ts +16 -1
  113. package/Tests/Server/Utils/Greenlock/CertificateRenewalSchedule.test.ts +250 -0
  114. package/Tests/Server/Utils/LogPipelineProcessorValidation.test.ts +198 -0
  115. package/Tests/Server/Utils/OnCall/CalendarFeedToken.test.ts +467 -0
  116. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedRenderer.test.ts +2142 -0
  117. package/Tests/Server/Utils/OnCall/OnCallCalendarFeedUrls.test.ts +278 -0
  118. package/Tests/Server/Utils/OnCall/OnCallResolverTestHarness.ts +440 -0
  119. package/Tests/Server/Utils/OnCall/OnCallShiftChangeListeners.test.ts +314 -0
  120. package/Tests/Server/Utils/OnCall/OnCallShiftMaterializer.test.ts +1502 -0
  121. package/Tests/Server/Utils/OnCall/OnCallShiftReminderListener.test.ts +491 -0
  122. package/Tests/Server/Utils/OnCall/OnCallShiftReminderRunner.test.ts +3088 -0
  123. package/Tests/Server/Utils/OnCall/OnCallShiftReminderTestHarness.ts +910 -0
  124. package/Tests/Server/Utils/ResponseSendCalendarResponse.test.ts +561 -0
  125. package/Tests/Server/Utils/SecurityEvent/ConnectorErrorMessage.test.ts +48 -9
  126. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsAuth.test.ts +726 -0
  127. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsClient.test.ts +74 -11
  128. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsErrorHandling.test.ts +1107 -0
  129. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPoller.test.ts +20 -3
  130. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerFailureTaxonomy.test.ts +51 -11
  131. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsPollerHardening.test.ts +1246 -0
  132. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsRequestContract.test.ts +961 -0
  133. package/Tests/Server/Utils/SecurityEvent/GoogleSecOpsResponseParsing.test.ts +653 -0
  134. package/Tests/Server/Utils/StartServerEncryptionSecretWarning.test.ts +64 -0
  135. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +29 -0
  136. package/Tests/Server/Utils/Workspace/MicrosoftTeamsActionAuthorization.test.ts +880 -0
  137. package/Tests/Types/API/HTTPResponse.test.ts +175 -0
  138. package/Tests/Types/API/Route.test.ts +36 -4
  139. package/Tests/Types/Calendar/ICalendar.test.ts +488 -0
  140. package/Tests/Types/Database/DatabaseProperty.test.ts +171 -0
  141. package/Tests/Types/DateLocalShortDateTimeString.test.ts +518 -0
  142. package/Tests/Types/DateLocalTimeString.test.ts +259 -0
  143. package/Tests/Types/DateUserPrefers12HourFormat.test.ts +316 -0
  144. package/Tests/Types/NotificationSetting/NotificationSettingEventType.test.ts +53 -0
  145. package/Tests/Types/OnCallDutyPolicy/CalendarFeedTestFixtures.ts +310 -0
  146. package/Tests/Types/OnCallDutyPolicy/CalendarFeedWindow.test.ts +205 -0
  147. package/Tests/Types/OnCallDutyPolicy/LayerUtilLayerMetaAndIterationCap.test.ts +458 -0
  148. package/Tests/Types/OnCallDutyPolicy/MaterializedShift.test.ts +197 -0
  149. package/Tests/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.test.ts +1986 -0
  150. package/Tests/Types/OnCallDutyPolicy/ScheduleShiftUtilGroupKey.test.ts +678 -0
  151. package/Tests/Types/OnCallDutyPolicy/ShiftSeamUtil.test.ts +417 -0
  152. package/Tests/UI/Components/LogsHistogram.test.tsx +41 -2
  153. package/Tests/UI/Components/LogsViewerEmptyStateTimeRange.test.tsx +212 -0
  154. package/Tests/UI/Components/TelemetryChartClockFormat.test.tsx +359 -0
  155. package/Tests/UI/Components/TimeRangePickerClockFormat.test.tsx +422 -0
  156. package/Tests/UI/Components/TimeRangePickerDropdown.test.tsx +146 -8
  157. package/Tests/UI/MicrosoftTeams/MicrosoftTeamsMessagingEndpointGuidance.test.tsx +520 -0
  158. package/Tests/UI/Utils/Breadcrumb/fixtures/RealBreadcrumbTrails.ts +10 -0
  159. package/Tests/UI/Utils/Breadcrumb/fixtures/RealRoutePatterns.ts +2 -0
  160. package/Tests/UI/Utils/Navigation.test.ts +82 -0
  161. package/Tests/Utils/Array.test.ts +100 -0
  162. package/Tests/Utils/CertificateReissue.test.ts +225 -0
  163. package/Tests/Utils/Grok/Grok.test.ts +492 -0
  164. package/Tests/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.test.ts +181 -13
  165. package/Types/API/Route.ts +20 -5
  166. package/Types/Calendar/ICalendar.ts +442 -0
  167. package/Types/Date.ts +143 -13
  168. package/Types/Email/EmailTemplateType.ts +2 -0
  169. package/Types/NotificationSetting/NotificationSettingEventType.ts +4 -0
  170. package/Types/OnCallDutyPolicy/CalendarFeedWindow.ts +140 -0
  171. package/Types/OnCallDutyPolicy/Layer.ts +190 -35
  172. package/Types/OnCallDutyPolicy/MaterializedShift.ts +274 -0
  173. package/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.ts +1415 -0
  174. package/Types/OnCallDutyPolicy/ScheduleShiftUtil.ts +159 -4
  175. package/Types/OnCallDutyPolicy/ShiftSeamUtil.ts +106 -0
  176. package/Types/Rum/SessionReplay.ts +62 -1
  177. package/UI/Components/Date/TimeRangePickerDropdown.tsx +9 -5
  178. package/UI/Components/LogsViewer/LogsViewer.tsx +12 -7
  179. package/UI/Components/LogsViewer/components/HistogramTooltip.tsx +18 -17
  180. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +21 -28
  181. package/UI/Components/LogsViewer/components/LogsHistogram.tsx +2 -4
  182. package/UI/Components/TelemetryViewer/components/TelemetryHistogram.tsx +2 -4
  183. package/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.tsx +17 -16
  184. package/UI/Utils/Navigation.ts +42 -0
  185. package/Utils/Array.ts +37 -0
  186. package/Utils/CertificateReissue.ts +128 -0
  187. package/Utils/Grok/Grok.ts +461 -0
  188. package/Utils/Grok/GrokPatterns.ts +118 -0
  189. package/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.ts +58 -6
  190. package/build/dist/Models/DatabaseModels/DashboardDomain.js +44 -0
  191. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  192. package/build/dist/Models/DatabaseModels/Index.js +11 -0
  193. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  194. package/build/dist/Models/DatabaseModels/MonitorTemplate.js +17 -4
  195. package/build/dist/Models/DatabaseModels/MonitorTemplate.js.map +1 -1
  196. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +44 -0
  197. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  198. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js +699 -0
  199. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleCalendarFeed.js.map +1 -0
  200. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js +620 -0
  201. package/build/dist/Models/DatabaseModels/ProjectOnCallCalendarFeed.js.map +1 -0
  202. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +44 -0
  203. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  204. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js +585 -0
  205. package/build/dist/Models/DatabaseModels/UserOnCallCalendarFeed.js.map +1 -0
  206. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js +247 -0
  207. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminder.js.map +1 -0
  208. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js +355 -0
  209. package/build/dist/Models/DatabaseModels/UserOnCallShiftReminderLog.js.map +1 -0
  210. package/build/dist/Server/API/DashboardDomainAPI.js +43 -1
  211. package/build/dist/Server/API/DashboardDomainAPI.js.map +1 -1
  212. package/build/dist/Server/API/MicrosoftTeamsAPI.js +74 -118
  213. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  214. package/build/dist/Server/API/OnCallCalendarAPI.js +1371 -0
  215. package/build/dist/Server/API/OnCallCalendarAPI.js.map +1 -0
  216. package/build/dist/Server/API/StatusPageDomainAPI.js +43 -1
  217. package/build/dist/Server/API/StatusPageDomainAPI.js.map +1 -1
  218. package/build/dist/Server/EnvironmentConfig.js +102 -0
  219. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  220. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js +663 -0
  221. package/build/dist/Server/Infrastructure/OnCallCalendarFeedCache.js.map +1 -0
  222. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js +34 -0
  223. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790200000000-AllowNullMonitorNameOnMonitorTemplate.js.map +1 -0
  224. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js +28 -0
  225. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790300000000-AddCertificateReissueRequestedAtToDomains.js.map +1 -0
  226. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js +103 -0
  227. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790400000000-AddOnCallCalendarFeeds.js.map +1 -0
  228. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  229. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  230. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js +342 -0
  231. package/build/dist/Server/Middleware/OnCallCalendarFeedRateLimit.js.map +1 -0
  232. package/build/dist/Server/Middleware/ProjectAuthorization.js +6 -1
  233. package/build/dist/Server/Middleware/ProjectAuthorization.js.map +1 -1
  234. package/build/dist/Server/Services/DashboardDomainService.js +104 -0
  235. package/build/dist/Server/Services/DashboardDomainService.js.map +1 -1
  236. package/build/dist/Server/Services/Index.js +12 -0
  237. package/build/dist/Server/Services/Index.js.map +1 -1
  238. package/build/dist/Server/Services/LogPipelineProcessorService.js +65 -0
  239. package/build/dist/Server/Services/LogPipelineProcessorService.js.map +1 -1
  240. package/build/dist/Server/Services/MonitorTemplateService.js +57 -11
  241. package/build/dist/Server/Services/MonitorTemplateService.js.map +1 -1
  242. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js +20 -0
  243. package/build/dist/Server/Services/OnCallDutyPolicyEscalationRuleScheduleService.js.map +1 -1
  244. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js +219 -0
  245. package/build/dist/Server/Services/OnCallDutyPolicyScheduleCalendarFeedService.js.map +1 -0
  246. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js +47 -3
  247. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerService.js.map +1 -1
  248. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js +112 -3
  249. package/build/dist/Server/Services/OnCallDutyPolicyScheduleLayerUserService.js.map +1 -1
  250. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js +955 -9
  251. package/build/dist/Server/Services/OnCallDutyPolicyScheduleService.js.map +1 -1
  252. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js +93 -6
  253. package/build/dist/Server/Services/OnCallDutyPolicyUserOverrideService.js.map +1 -1
  254. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js +157 -0
  255. package/build/dist/Server/Services/ProjectOnCallCalendarFeedService.js.map +1 -0
  256. package/build/dist/Server/Services/StatusPageDomainService.js +132 -3
  257. package/build/dist/Server/Services/StatusPageDomainService.js.map +1 -1
  258. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js +112 -32
  259. package/build/dist/Server/Services/StatusPagePrivateUserSessionService.js.map +1 -1
  260. package/build/dist/Server/Services/TeamMemberService.js +396 -1
  261. package/build/dist/Server/Services/TeamMemberService.js.map +1 -1
  262. package/build/dist/Server/Services/TeamService.js +43 -0
  263. package/build/dist/Server/Services/TeamService.js.map +1 -1
  264. package/build/dist/Server/Services/UserNotificationSettingService.js +26 -1
  265. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  266. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js +140 -0
  267. package/build/dist/Server/Services/UserOnCallCalendarFeedService.js.map +1 -0
  268. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js +116 -0
  269. package/build/dist/Server/Services/UserOnCallShiftReminderLogService.js.map +1 -0
  270. package/build/dist/Server/Services/UserOnCallShiftReminderService.js +92 -0
  271. package/build/dist/Server/Services/UserOnCallShiftReminderService.js.map +1 -0
  272. package/build/dist/Server/Types/Domain.js +16 -0
  273. package/build/dist/Server/Types/Domain.js.map +1 -1
  274. package/build/dist/Server/Utils/APIKey/AccessPermission.js +24 -0
  275. package/build/dist/Server/Utils/APIKey/AccessPermission.js.map +1 -1
  276. package/build/dist/Server/Utils/Greenlock/Greenlock.js +110 -35
  277. package/build/dist/Server/Utils/Greenlock/Greenlock.js.map +1 -1
  278. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js +77 -0
  279. package/build/dist/Server/Utils/LogPipelineProcessorValidation.js.map +1 -0
  280. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js +161 -0
  281. package/build/dist/Server/Utils/OnCall/CalendarFeedToken.js.map +1 -0
  282. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js +983 -0
  283. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedRenderer.js.map +1 -0
  284. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js +155 -0
  285. package/build/dist/Server/Utils/OnCall/OnCallCalendarFeedUrls.js.map +1 -0
  286. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js +145 -0
  287. package/build/dist/Server/Utils/OnCall/OnCallShiftChangeListeners.js.map +1 -0
  288. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js +752 -0
  289. package/build/dist/Server/Utils/OnCall/OnCallShiftMaterializer.js.map +1 -0
  290. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js +92 -0
  291. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderListener.js.map +1 -0
  292. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js +1786 -0
  293. package/build/dist/Server/Utils/OnCall/OnCallShiftReminderRunner.js.map +1 -0
  294. package/build/dist/Server/Utils/Response.js +97 -0
  295. package/build/dist/Server/Utils/Response.js.map +1 -1
  296. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js +777 -36
  297. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsClient.js.map +1 -1
  298. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js +108 -17
  299. package/build/dist/Server/Utils/SecurityEvent/GoogleSecOps/GoogleSecOpsPoller.js.map +1 -1
  300. package/build/dist/Server/Utils/StartServer.js +9 -1
  301. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  302. package/build/dist/Server/Utils/Telemetry/AppMetrics.js +41 -0
  303. package/build/dist/Server/Utils/Telemetry/AppMetrics.js.map +1 -1
  304. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js +17 -6
  305. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Alert.js.map +1 -1
  306. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js +0 -159
  307. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Auth.js.map +1 -1
  308. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js +128 -0
  309. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Authorization.js.map +1 -0
  310. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js +17 -6
  311. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/Actions/Incident.js.map +1 -1
  312. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +25 -13
  313. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  314. package/build/dist/Types/API/Route.js +16 -5
  315. package/build/dist/Types/API/Route.js.map +1 -1
  316. package/build/dist/Types/Calendar/ICalendar.js +290 -0
  317. package/build/dist/Types/Calendar/ICalendar.js.map +1 -0
  318. package/build/dist/Types/Date.js +115 -13
  319. package/build/dist/Types/Date.js.map +1 -1
  320. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  321. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  322. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js +3 -0
  323. package/build/dist/Types/NotificationSetting/NotificationSettingEventType.js.map +1 -1
  324. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js +97 -0
  325. package/build/dist/Types/OnCallDutyPolicy/CalendarFeedWindow.js.map +1 -0
  326. package/build/dist/Types/OnCallDutyPolicy/Layer.js +113 -19
  327. package/build/dist/Types/OnCallDutyPolicy/Layer.js.map +1 -1
  328. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js +134 -0
  329. package/build/dist/Types/OnCallDutyPolicy/MaterializedShift.js.map +1 -0
  330. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js +800 -0
  331. package/build/dist/Types/OnCallDutyPolicy/OnCallCalendarFeedUtil.js.map +1 -0
  332. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js +97 -3
  333. package/build/dist/Types/OnCallDutyPolicy/ScheduleShiftUtil.js.map +1 -1
  334. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js +77 -0
  335. package/build/dist/Types/OnCallDutyPolicy/ShiftSeamUtil.js.map +1 -0
  336. package/build/dist/Types/Rum/SessionReplay.js +38 -0
  337. package/build/dist/Types/Rum/SessionReplay.js.map +1 -1
  338. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js +9 -5
  339. package/build/dist/UI/Components/Date/TimeRangePickerDropdown.js.map +1 -1
  340. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +12 -7
  341. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  342. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js +15 -14
  343. package/build/dist/UI/Components/LogsViewer/components/HistogramTooltip.js.map +1 -1
  344. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +17 -22
  345. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -1
  346. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js +2 -4
  347. package/build/dist/UI/Components/LogsViewer/components/LogsHistogram.js.map +1 -1
  348. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js +2 -4
  349. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogram.js.map +1 -1
  350. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js +15 -14
  351. package/build/dist/UI/Components/TelemetryViewer/components/TelemetryHistogramTooltip.js.map +1 -1
  352. package/build/dist/UI/Utils/Navigation.js +30 -0
  353. package/build/dist/UI/Utils/Navigation.js.map +1 -1
  354. package/build/dist/Utils/Array.js +25 -0
  355. package/build/dist/Utils/Array.js.map +1 -1
  356. package/build/dist/Utils/CertificateReissue.js +97 -0
  357. package/build/dist/Utils/CertificateReissue.js.map +1 -0
  358. package/build/dist/Utils/Grok/Grok.js +324 -0
  359. package/build/dist/Utils/Grok/Grok.js.map +1 -0
  360. package/build/dist/Utils/Grok/GrokPatterns.js +112 -0
  361. package/build/dist/Utils/Grok/GrokPatterns.js.map +1 -0
  362. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js +63 -3
  363. package/build/dist/Utils/Monitor/NetworkDeviceMonitorTemplateUtil.js.map +1 -1
  364. package/package.json +1 -1
  365. package/UI/Utils/JsonWebToken.ts +0 -14
  366. package/build/dist/UI/Utils/JsonWebToken.js +0 -10
  367. package/build/dist/UI/Utils/JsonWebToken.js.map +0 -1
@@ -0,0 +1,1209 @@
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 reader's own calendar link, rendered for real against a stubbed API.
23
+ *
24
+ * What this file guards: the link is only ever minted through POST /rotate
25
+ * (there is no client-side token anywhere), the four states a feed can be in
26
+ * (absent / active / disabled / unreadable) each look different and offer the
27
+ * right next step, every write goes to the right route or model with the
28
+ * right payload, an older API (404) hides the feature rather than erroring,
29
+ * and the schedule variant narrows the same link with ?schedule= while
30
+ * pointing back to the full page for everything else.
31
+ */
32
+
33
+ const getMock: MockFunction = getJestMockFunction();
34
+ const postMock: MockFunction = getJestMockFunction();
35
+ const updateByIdMock: MockFunction = getJestMockFunction();
36
+ const deleteItemMock: MockFunction = getJestMockFunction();
37
+ const getListMock: MockFunction = getJestMockFunction();
38
+
39
+ jest.mock("../../../UI/Utils/API/API", () => {
40
+ return {
41
+ __esModule: true,
42
+ default: {
43
+ get: (...args: Array<any>) => {
44
+ return getMock(...args);
45
+ },
46
+ post: (...args: Array<any>) => {
47
+ return postMock(...args);
48
+ },
49
+ getFriendlyMessage: (error: unknown): string => {
50
+ if (error instanceof Error) {
51
+ return error.message;
52
+ }
53
+
54
+ if (
55
+ error &&
56
+ typeof error === "object" &&
57
+ "message" in (error as Record<string, unknown>)
58
+ ) {
59
+ return String((error as Record<string, unknown>)["message"]);
60
+ }
61
+
62
+ return "Something went wrong";
63
+ },
64
+ },
65
+ };
66
+ });
67
+
68
+ jest.mock("../../../UI/Utils/ModelAPI/ModelAPI", () => {
69
+ return {
70
+ __esModule: true,
71
+ default: {
72
+ getCommonHeaders: (): Record<string, string> => {
73
+ return {};
74
+ },
75
+ updateById: (...args: Array<any>) => {
76
+ return updateByIdMock(...args);
77
+ },
78
+ deleteItem: (...args: Array<any>) => {
79
+ return deleteItemMock(...args);
80
+ },
81
+ getList: (...args: Array<any>) => {
82
+ return getListMock(...args);
83
+ },
84
+ },
85
+ };
86
+ });
87
+
88
+ /*
89
+ * The settings card is ModelDetail machinery (its own fetch, its own form).
90
+ * It is replaced with a stub that prints what it was asked to show, so the
91
+ * assertions here are about WHICH model, id and fields the card is wired to,
92
+ * not about ModelDetail itself.
93
+ */
94
+ jest.mock("../../../UI/Components/ModelDetail/CardModelDetail", () => {
95
+ return {
96
+ __esModule: true,
97
+ default: (props: Record<string, any>) => {
98
+ const fieldTitles: Array<string> = (props["formFields"] || []).map(
99
+ (field: Record<string, unknown>): string => {
100
+ return String(field["title"]);
101
+ },
102
+ );
103
+
104
+ return React.createElement(
105
+ "div",
106
+ {
107
+ "data-testid": "card-model-detail-stub",
108
+ "data-model-id": String(props["modelDetailProps"]?.modelId),
109
+ "data-model-name": String(
110
+ new props["modelDetailProps"].modelType().tableName,
111
+ ),
112
+ "data-editable": String(props["isEditable"]),
113
+ },
114
+ `${props["cardProps"]?.title}|${fieldTitles.join(",")}`,
115
+ );
116
+ },
117
+ };
118
+ });
119
+
120
+ jest.mock("../../../UI/Utils/User", () => {
121
+ return {
122
+ __esModule: true,
123
+ default: {
124
+ getUserId: (): ObjectID => {
125
+ return new ObjectID("44444444-4444-4444-8444-444444444444");
126
+ },
127
+ isMasterAdmin: (): boolean => {
128
+ return false;
129
+ },
130
+ },
131
+ };
132
+ });
133
+
134
+ jest.mock("react-i18next", () => {
135
+ return {
136
+ useTranslation: () => {
137
+ return {
138
+ t: (key: string, options?: { defaultValue?: string }): string => {
139
+ return options?.defaultValue ?? key;
140
+ },
141
+ };
142
+ },
143
+ };
144
+ });
145
+
146
+ import ObjectID from "../../../Types/ObjectID";
147
+ import OneUptimeDate from "../../../Types/Date";
148
+ import HTTPErrorResponse from "../../../Types/API/HTTPErrorResponse";
149
+ import HTTPResponse from "../../../Types/API/HTTPResponse";
150
+ import { JSONObject } from "../../../Types/JSON";
151
+ import UserOnCallCalendarFeed from "../../../Models/DatabaseModels/UserOnCallCalendarFeed";
152
+ import FeedStatusLine from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/FeedStatusLine";
153
+ import { FeedStatus } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/CalendarFeedTypes";
154
+ import {
155
+ PERSONAL_FEED_CURRENT_PATH,
156
+ PERSONAL_FEED_ROTATE_PATH,
157
+ PLANNING_NOT_AUDIT_COPY,
158
+ buildGoogleAddUrl,
159
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/CalendarFeedUtil";
160
+ import PersonalCalendarFeedCard, {
161
+ PersonalCalendarFeedVariant,
162
+ } from "../../../../App/FeatureSet/Dashboard/src/Components/OnCallPolicy/CalendarFeed/PersonalCalendarFeedCard";
163
+ import UserSettingsOnCallCalendarFeed from "../../../../App/FeatureSet/Dashboard/src/Pages/UserSettings/OnCallCalendarFeed";
164
+ import PageMap from "../../../../App/FeatureSet/Dashboard/src/Utils/PageMap";
165
+ import RouteMap, {
166
+ RouteUtil,
167
+ } from "../../../../App/FeatureSet/Dashboard/src/Utils/RouteMap";
168
+ import Route from "../../../Types/API/Route";
169
+
170
+ const PROJECT_ID: string = "8f2a1b3c-4d5e-4f60-9a7b-1c2d3e4f5a6b";
171
+ const FEED_ID: string = "11111111-1111-4111-8111-111111111111";
172
+ const SCHEDULE_ID: ObjectID = new ObjectID(
173
+ "33333333-3333-4333-8333-333333333333",
174
+ );
175
+ const NOW: Date = new Date("2026-08-31T12:00:00.000Z");
176
+
177
+ const HTTPS_URL: string = `https://oneuptime.example.com/api/on-call-calendar/user/abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG/shifts.ics`;
178
+ const WEBCAL_URL: string = HTTPS_URL.replace("https:", "webcals:");
179
+
180
+ type StatusJsonFunction = (overrides?: JSONObject) => JSONObject;
181
+
182
+ const activeStatusJson: StatusJsonFunction = (
183
+ overrides?: JSONObject,
184
+ ): JSONObject => {
185
+ return {
186
+ exists: true,
187
+ feedId: FEED_ID,
188
+ isEnabled: true,
189
+ needsRegeneration: false,
190
+ tokenHint: "k3Qx",
191
+ rotatedAt: "2026-08-01T10:00:00.000Z",
192
+ previousTokenExpiresAt: null,
193
+ lastFetchedAt: "2026-08-31T10:00:00.000Z",
194
+ lastFetchedClient: "Google Calendar",
195
+ fetchCount: 143,
196
+ lastRenderTruncated: false,
197
+ settings: {
198
+ includeCoveringShifts: true,
199
+ pastDays: 2,
200
+ futureDays: 90,
201
+ },
202
+ urls: {
203
+ https: HTTPS_URL,
204
+ webcal: WEBCAL_URL,
205
+ googleAdd: buildGoogleAddUrl(HTTPS_URL),
206
+ },
207
+ hostWarning: null,
208
+ protocolWarning: null,
209
+ ...overrides,
210
+ };
211
+ };
212
+
213
+ const EMPTY_STATUS_JSON: JSONObject = {
214
+ exists: false,
215
+ feedId: null,
216
+ isEnabled: false,
217
+ needsRegeneration: false,
218
+ tokenHint: null,
219
+ rotatedAt: null,
220
+ previousTokenExpiresAt: null,
221
+ lastFetchedAt: null,
222
+ lastFetchedClient: null,
223
+ fetchCount: 0,
224
+ lastRenderTruncated: false,
225
+ settings: { pastDays: 2, futureDays: 90 },
226
+ urls: null,
227
+ hostWarning: null,
228
+ protocolWarning: null,
229
+ };
230
+
231
+ type OkFunction = (json: JSONObject) => HTTPResponse<JSONObject>;
232
+
233
+ const ok: OkFunction = (json: JSONObject): HTTPResponse<JSONObject> => {
234
+ return new HTTPResponse<JSONObject>(200, json, {});
235
+ };
236
+
237
+ type FailFunction = (status: number, message: string) => HTTPErrorResponse;
238
+
239
+ const fail: FailFunction = (
240
+ status: number,
241
+ message: string,
242
+ ): HTTPErrorResponse => {
243
+ return new HTTPErrorResponse(status, { message: message }, {});
244
+ };
245
+
246
+ type UrlOfFunction = (call: Array<unknown>) => string;
247
+
248
+ const urlOf: UrlOfFunction = (call: Array<unknown>): string => {
249
+ return String((call[0] as Record<string, unknown>)["url"]);
250
+ };
251
+
252
+ type StatusFunction = (overrides?: Partial<FeedStatus>) => FeedStatus;
253
+
254
+ const status: StatusFunction = (
255
+ overrides?: Partial<FeedStatus>,
256
+ ): FeedStatus => {
257
+ return {
258
+ exists: true,
259
+ feedId: FEED_ID,
260
+ isEnabled: true,
261
+ needsRegeneration: false,
262
+ tokenHint: "k3Qx",
263
+ rotatedAt: "2026-08-01T10:00:00.000Z",
264
+ previousTokenExpiresAt: null,
265
+ lastFetchedAt: null,
266
+ lastFetchedClient: null,
267
+ fetchCount: 0,
268
+ lastRenderTruncated: false,
269
+ settings: { pastDays: 2, futureDays: 90 },
270
+ urls: null,
271
+ hostWarning: null,
272
+ protocolWarning: null,
273
+ ...overrides,
274
+ };
275
+ };
276
+
277
+ function goToCalendarFeedPage(): void {
278
+ window.history.pushState(
279
+ {},
280
+ "",
281
+ `/dashboard/${PROJECT_ID}/user-settings/calendar-feed`,
282
+ );
283
+ }
284
+
285
+ function resetMocks(): void {
286
+ getMock.mockReset();
287
+ postMock.mockReset();
288
+ updateByIdMock.mockReset();
289
+ deleteItemMock.mockReset();
290
+ getListMock.mockReset();
291
+ updateByIdMock.mockResolvedValue(undefined);
292
+ deleteItemMock.mockResolvedValue(undefined);
293
+ getListMock.mockResolvedValue({ data: [], count: 0, skip: 0, limit: 10 });
294
+ }
295
+
296
+ describe("FeedStatusLine", () => {
297
+ afterEach(() => {
298
+ cleanup();
299
+ });
300
+
301
+ test("says 'Not fetched yet' and the link hint when nothing has fetched the link", () => {
302
+ render(<FeedStatusLine status={status()} now={NOW} />);
303
+
304
+ const line: HTMLElement = screen.getByTestId("calendar-feed-status-line");
305
+
306
+ expect(line).toHaveTextContent("Not fetched yet");
307
+ expect(line).toHaveTextContent("link ending in …k3Qx");
308
+ expect(line).not.toHaveTextContent("Last rotated");
309
+ });
310
+
311
+ test("composes last fetched / client / approximate count / hint", () => {
312
+ render(
313
+ <FeedStatusLine
314
+ status={status({
315
+ lastFetchedAt: "2026-08-31T10:00:00.000Z",
316
+ lastFetchedClient: "Google Calendar",
317
+ fetchCount: 143,
318
+ })}
319
+ now={NOW}
320
+ />,
321
+ );
322
+
323
+ const line: HTMLElement = screen.getByTestId("calendar-feed-status-line");
324
+
325
+ expect(line).toHaveTextContent("Last fetched");
326
+ expect(line).toHaveTextContent("by Google Calendar");
327
+ expect(line).toHaveTextContent("~143 fetches");
328
+ expect(line).toHaveTextContent("link ending in …k3Qx");
329
+ });
330
+
331
+ test("uses the singular for exactly one fetch and omits the count at zero", () => {
332
+ const { rerender } = render(
333
+ <FeedStatusLine
334
+ status={status({
335
+ lastFetchedAt: "2026-08-31T10:00:00.000Z",
336
+ fetchCount: 1,
337
+ })}
338
+ now={NOW}
339
+ />,
340
+ );
341
+
342
+ expect(screen.getByTestId("calendar-feed-status-line")).toHaveTextContent(
343
+ "~1 fetch",
344
+ );
345
+
346
+ rerender(
347
+ <FeedStatusLine
348
+ status={status({
349
+ lastFetchedAt: "2026-08-31T10:00:00.000Z",
350
+ fetchCount: 0,
351
+ })}
352
+ now={NOW}
353
+ />,
354
+ );
355
+
356
+ expect(
357
+ screen.getByTestId("calendar-feed-status-line"),
358
+ ).not.toHaveTextContent("~");
359
+ });
360
+
361
+ test("shows the rotation age only when asked, with today / 1 day / N days phrasing", () => {
362
+ const { rerender } = render(
363
+ <FeedStatusLine
364
+ status={status({ rotatedAt: "2026-08-01T10:00:00.000Z" })}
365
+ now={NOW}
366
+ showRotatedAgo={true}
367
+ />,
368
+ );
369
+
370
+ expect(screen.getByTestId("calendar-feed-status-line")).toHaveTextContent(
371
+ "Last rotated 30 days ago",
372
+ );
373
+
374
+ rerender(
375
+ <FeedStatusLine
376
+ status={status({ rotatedAt: "2026-08-30T08:00:00.000Z" })}
377
+ now={NOW}
378
+ showRotatedAgo={true}
379
+ />,
380
+ );
381
+
382
+ expect(screen.getByTestId("calendar-feed-status-line")).toHaveTextContent(
383
+ "Last rotated 1 day ago",
384
+ );
385
+
386
+ rerender(
387
+ <FeedStatusLine
388
+ status={status({ rotatedAt: "2026-08-31T08:00:00.000Z" })}
389
+ now={NOW}
390
+ showRotatedAgo={true}
391
+ />,
392
+ );
393
+
394
+ expect(screen.getByTestId("calendar-feed-status-line")).toHaveTextContent(
395
+ "Last rotated today",
396
+ );
397
+ });
398
+
399
+ test("the reachability hint appears only after 48 hours without a fetch", () => {
400
+ const { rerender } = render(
401
+ <FeedStatusLine
402
+ status={status({ rotatedAt: "2026-08-01T10:00:00.000Z" })}
403
+ now={NOW}
404
+ />,
405
+ );
406
+
407
+ expect(
408
+ screen.getByTestId("calendar-feed-nothing-fetched-hint"),
409
+ ).toHaveTextContent("Nothing has fetched this link yet");
410
+
411
+ rerender(
412
+ <FeedStatusLine
413
+ status={status({ rotatedAt: "2026-08-31T00:00:00.000Z" })}
414
+ now={NOW}
415
+ />,
416
+ );
417
+
418
+ expect(
419
+ screen.queryByTestId("calendar-feed-nothing-fetched-hint"),
420
+ ).not.toBeInTheDocument();
421
+ });
422
+
423
+ test("idPrefix namespaces the test ids", () => {
424
+ render(<FeedStatusLine status={status()} now={NOW} idPrefix="shared" />);
425
+
426
+ expect(screen.getByTestId("shared-status-line")).toBeInTheDocument();
427
+ });
428
+
429
+ /*
430
+ * Every segment is one whole translated sentence with {{placeholders}}
431
+ * filled in by the component, not a translated word glued to a value. If a
432
+ * placeholder ever reached the DOM it would mean the sentence was looked up
433
+ * but never interpolated - a visible break in all 16 locales.
434
+ */
435
+ test("no interpolation placeholder ever reaches the rendered line", () => {
436
+ render(
437
+ <FeedStatusLine
438
+ status={status({
439
+ lastFetchedAt: "2026-08-31T10:00:00.000Z",
440
+ lastFetchedClient: "Google Calendar",
441
+ fetchCount: 143,
442
+ rotatedAt: "2026-08-01T10:00:00.000Z",
443
+ })}
444
+ now={NOW}
445
+ showRotatedAgo={true}
446
+ />,
447
+ );
448
+
449
+ const line: HTMLElement = screen.getByTestId("calendar-feed-status-line");
450
+
451
+ expect(line.textContent).not.toContain("{{");
452
+ expect(line.textContent).not.toContain("}}");
453
+ // Every value still lands in the sentence it belongs to.
454
+ expect(line).toHaveTextContent("by Google Calendar");
455
+ expect(line).toHaveTextContent("~143 fetches");
456
+ expect(line).toHaveTextContent("link ending in …k3Qx");
457
+ expect(line).toHaveTextContent("Last rotated 30 days ago");
458
+ });
459
+
460
+ test("the client is left out of the sentence rather than trailing an empty 'by'", () => {
461
+ render(
462
+ <FeedStatusLine
463
+ status={status({
464
+ lastFetchedAt: "2026-08-31T10:00:00.000Z",
465
+ lastFetchedClient: null,
466
+ fetchCount: 4,
467
+ })}
468
+ now={NOW}
469
+ />,
470
+ );
471
+
472
+ const line: HTMLElement = screen.getByTestId("calendar-feed-status-line");
473
+
474
+ expect(line).toHaveTextContent("Last fetched");
475
+ expect(line.textContent).not.toContain(" by ");
476
+ expect(line.textContent).not.toContain("{{");
477
+ });
478
+ });
479
+
480
+ describe("PersonalCalendarFeedCard (full variant)", () => {
481
+ beforeEach(() => {
482
+ resetMocks();
483
+ goToCalendarFeedPage();
484
+ });
485
+
486
+ afterEach(() => {
487
+ cleanup();
488
+ });
489
+
490
+ test("reads /feed/current on mount and shows the empty state with a Generate button", async () => {
491
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
492
+
493
+ render(
494
+ <PersonalCalendarFeedCard
495
+ variant={PersonalCalendarFeedVariant.Full}
496
+ now={NOW}
497
+ />,
498
+ );
499
+
500
+ await waitFor(() => {
501
+ expect(
502
+ screen.getByTestId("personal-calendar-feed-empty"),
503
+ ).toBeInTheDocument();
504
+ });
505
+
506
+ expect(getMock).toHaveBeenCalledTimes(1);
507
+ expect(urlOf(getMock.mock.calls[0] as Array<unknown>)).toContain(
508
+ PERSONAL_FEED_CURRENT_PATH,
509
+ );
510
+ expect(
511
+ screen.getByTestId("personal-calendar-feed-generate"),
512
+ ).toBeInTheDocument();
513
+ // No card buttons (regenerate / disable / delete) before a link exists.
514
+ expect(screen.queryByTestId("card-button")).not.toBeInTheDocument();
515
+ // No settings card before a link exists.
516
+ expect(
517
+ screen.queryByTestId("card-model-detail-stub"),
518
+ ).not.toBeInTheDocument();
519
+ });
520
+
521
+ /*
522
+ * The server sends hostWarning / protocolWarning even when no feed exists.
523
+ * Saying "this link will be unreachable" only AFTER the reader has minted
524
+ * one and pasted it into Google Calendar wastes the one moment the warning
525
+ * is actionable.
526
+ */
527
+ test("the empty state shows the deployment warnings the server attached to it", async () => {
528
+ getMock.mockResolvedValue(
529
+ ok({
530
+ ...EMPTY_STATUS_JSON,
531
+ hostWarning: "HOST is not set, so the link points at localhost.",
532
+ protocolWarning: "HTTP_PROTOCOL is http, so the link is not encrypted.",
533
+ }),
534
+ );
535
+
536
+ render(
537
+ <PersonalCalendarFeedCard
538
+ variant={PersonalCalendarFeedVariant.Full}
539
+ now={NOW}
540
+ />,
541
+ );
542
+
543
+ await waitFor(() => {
544
+ expect(
545
+ screen.getByTestId("personal-calendar-feed-empty"),
546
+ ).toBeInTheDocument();
547
+ });
548
+
549
+ expect(
550
+ screen.getByTestId("personal-calendar-feed-host-warning"),
551
+ ).toHaveTextContent("HOST is not set");
552
+ expect(
553
+ screen.getByTestId("personal-calendar-feed-protocol-warning"),
554
+ ).toHaveTextContent("HTTP_PROTOCOL is http");
555
+ // Still offered - the warning informs the click, it does not block it.
556
+ expect(
557
+ screen.getByTestId("personal-calendar-feed-generate"),
558
+ ).toBeInTheDocument();
559
+ });
560
+
561
+ test("Generate posts to /feed/rotate with an empty JSON body and renders the returned link", async () => {
562
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
563
+ postMock.mockResolvedValue(ok(activeStatusJson()));
564
+
565
+ render(
566
+ <PersonalCalendarFeedCard
567
+ variant={PersonalCalendarFeedVariant.Full}
568
+ now={NOW}
569
+ />,
570
+ );
571
+
572
+ await waitFor(() => {
573
+ expect(
574
+ screen.getByTestId("personal-calendar-feed-generate"),
575
+ ).toBeInTheDocument();
576
+ });
577
+
578
+ fireEvent.click(screen.getByTestId("personal-calendar-feed-generate"));
579
+
580
+ await waitFor(() => {
581
+ expect(
582
+ screen.getByTestId("personal-calendar-feed-active"),
583
+ ).toBeInTheDocument();
584
+ });
585
+
586
+ expect(postMock).toHaveBeenCalledTimes(1);
587
+ expect(urlOf(postMock.mock.calls[0] as Array<unknown>)).toContain(
588
+ PERSONAL_FEED_ROTATE_PATH,
589
+ );
590
+ expect(
591
+ (postMock.mock.calls[0]![0] as Record<string, unknown>)["data"],
592
+ ).toEqual({});
593
+
594
+ // The link block, hidden until revealed, and the webcal anchor.
595
+ expect(
596
+ screen.getByTestId("personal-calendar-feed-links"),
597
+ ).toBeInTheDocument();
598
+ expect(screen.getByTestId("personal-calendar-feed-webcal")).toHaveAttribute(
599
+ "href",
600
+ WEBCAL_URL,
601
+ );
602
+ expect(screen.queryByText(HTTPS_URL)).not.toBeInTheDocument();
603
+
604
+ // The settings card is wired to the personal feed model and this feed id.
605
+ const settings: HTMLElement = screen.getByTestId("card-model-detail-stub");
606
+ expect(settings).toHaveAttribute("data-model-id", FEED_ID);
607
+ expect(settings).toHaveAttribute(
608
+ "data-model-name",
609
+ new UserOnCallCalendarFeed().tableName,
610
+ );
611
+ expect(settings).toHaveTextContent(
612
+ "Calendar feed settings|Include shifts I cover for others,Days of past shifts,Days ahead",
613
+ );
614
+ });
615
+
616
+ test("an active feed shows the bookkeeping line, the Time Log pointer and the refresh alert", async () => {
617
+ getMock.mockResolvedValue(ok(activeStatusJson()));
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-active"),
629
+ ).toBeInTheDocument();
630
+ });
631
+
632
+ expect(
633
+ screen.getByTestId("personal-calendar-feed-status-line"),
634
+ ).toHaveTextContent("by Google Calendar");
635
+ expect(
636
+ screen.getByTestId("personal-calendar-feed-status-line"),
637
+ ).toHaveTextContent("~143 fetches");
638
+
639
+ const timeLog: HTMLElement = screen.getByTestId(
640
+ "personal-calendar-feed-time-log",
641
+ );
642
+ expect(timeLog).toHaveTextContent(PLANNING_NOT_AUDIT_COPY);
643
+ const expectedTimeLogRoute: Route = RouteUtil.populateRouteParams(
644
+ RouteMap[PageMap.ON_CALLDUTY_USER_TIME_LOGS] as Route,
645
+ );
646
+ expect(
647
+ within(timeLog).getByText("Open the On-Call Time Log").closest("a"),
648
+ ).toHaveAttribute("href", expectedTimeLogRoute.toString());
649
+
650
+ expect(
651
+ screen.getByTestId("personal-calendar-feed-refresh-alert"),
652
+ ).toBeInTheDocument();
653
+
654
+ // Regenerate / Disable / Delete are offered once a link exists.
655
+ expect(
656
+ screen.getByRole("button", { name: "Regenerate link" }),
657
+ ).toBeInTheDocument();
658
+ expect(screen.getByRole("button", { name: "Disable" })).toBeInTheDocument();
659
+ expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument();
660
+ });
661
+
662
+ /*
663
+ * What the server does with the rotated-out token inside its grace window is
664
+ * answer with an EMPTY calendar (spec 2.1), not with the old shifts. The
665
+ * line has to say that: "keeps working until <date>" would tell somebody who
666
+ * rotated because a colleague left that the leaver still sees the roster for
667
+ * a month.
668
+ */
669
+ test("a rotated-out previous link is announced as empty until its expiry, not as still working", async () => {
670
+ getMock.mockResolvedValue(
671
+ ok(
672
+ activeStatusJson({
673
+ previousTokenExpiresAt: "2026-09-30T10:00:00.000Z",
674
+ }),
675
+ ),
676
+ );
677
+
678
+ render(
679
+ <PersonalCalendarFeedCard
680
+ variant={PersonalCalendarFeedVariant.Full}
681
+ now={NOW}
682
+ />,
683
+ );
684
+
685
+ let line: HTMLElement | null = null;
686
+
687
+ await waitFor(() => {
688
+ line = screen.getByTestId("personal-calendar-feed-previous-link");
689
+ expect(line).toHaveTextContent(
690
+ "Your previous link returns an empty calendar until",
691
+ );
692
+ });
693
+
694
+ expect(line!).toHaveTextContent("then stops working");
695
+ expect(line!).not.toHaveTextContent("keeps working");
696
+ // The {{date}} placeholder is filled, not printed.
697
+ expect(line!.textContent).not.toContain("{{");
698
+ expect(line!.textContent).toContain(
699
+ OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
700
+ OneUptimeDate.fromString("2026-09-30T10:00:00.000Z"),
701
+ ),
702
+ );
703
+ });
704
+
705
+ test("Regenerate asks for confirmation, then posts to /feed/rotate", async () => {
706
+ getMock.mockResolvedValue(ok(activeStatusJson()));
707
+ postMock.mockResolvedValue(ok(activeStatusJson({ tokenHint: "n3wX" })));
708
+
709
+ render(
710
+ <PersonalCalendarFeedCard
711
+ variant={PersonalCalendarFeedVariant.Full}
712
+ now={NOW}
713
+ />,
714
+ );
715
+
716
+ await waitFor(() => {
717
+ expect(
718
+ screen.getByRole("button", { name: "Regenerate link" }),
719
+ ).toBeInTheDocument();
720
+ });
721
+
722
+ fireEvent.click(screen.getByRole("button", { name: "Regenerate link" }));
723
+
724
+ const modal: HTMLElement = await screen.findByTestId("modal");
725
+ /*
726
+ * The modal must not promise the old link keeps serving shifts: the server
727
+ * answers it with an empty calendar for the 30-day grace, then 404s.
728
+ */
729
+ const description: HTMLElement = within(modal).getByTestId(
730
+ "confirm-modal-description",
731
+ );
732
+ expect(description).toHaveTextContent(
733
+ "For 30 days the old link keeps answering with that empty calendar instead of an error, then it stops working",
734
+ );
735
+ expect(description).not.toHaveTextContent("keeps working for 30 days");
736
+ // Nothing is posted until the reader confirms.
737
+ expect(postMock).not.toHaveBeenCalled();
738
+
739
+ fireEvent.click(
740
+ within(modal).getByRole("button", { name: "Regenerate link" }),
741
+ );
742
+
743
+ await waitFor(() => {
744
+ expect(postMock).toHaveBeenCalledTimes(1);
745
+ });
746
+ expect(urlOf(postMock.mock.calls[0] as Array<unknown>)).toContain(
747
+ PERSONAL_FEED_ROTATE_PATH,
748
+ );
749
+
750
+ await waitFor(() => {
751
+ expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
752
+ });
753
+ expect(
754
+ screen.getByTestId("personal-calendar-feed-status-line"),
755
+ ).toHaveTextContent("…n3wX");
756
+ });
757
+
758
+ test("Disable updates isEnabled on the personal feed row and re-reads the status", async () => {
759
+ getMock
760
+ .mockResolvedValueOnce(ok(activeStatusJson()))
761
+ .mockResolvedValueOnce(ok(activeStatusJson({ isEnabled: false })));
762
+
763
+ render(
764
+ <PersonalCalendarFeedCard
765
+ variant={PersonalCalendarFeedVariant.Full}
766
+ now={NOW}
767
+ />,
768
+ );
769
+
770
+ await waitFor(() => {
771
+ expect(
772
+ screen.getByRole("button", { name: "Disable" }),
773
+ ).toBeInTheDocument();
774
+ });
775
+
776
+ fireEvent.click(screen.getByRole("button", { name: "Disable" }));
777
+
778
+ await waitFor(() => {
779
+ expect(updateByIdMock).toHaveBeenCalledTimes(1);
780
+ });
781
+
782
+ const call: Record<string, unknown> = updateByIdMock.mock
783
+ .calls[0]![0] as Record<string, unknown>;
784
+ expect(call["modelType"]).toBe(UserOnCallCalendarFeed);
785
+ expect(String(call["id"])).toBe(FEED_ID);
786
+ expect(call["data"]).toEqual({ isEnabled: false });
787
+
788
+ await waitFor(() => {
789
+ expect(
790
+ screen.getByTestId("personal-calendar-feed-disabled"),
791
+ ).toBeInTheDocument();
792
+ });
793
+ expect(getMock).toHaveBeenCalledTimes(2);
794
+ /*
795
+ * A disabled feed offers Enable; the link stays visible under the
796
+ * warning so the reader can still copy it for later.
797
+ */
798
+ expect(screen.getByRole("button", { name: "Enable" })).toBeInTheDocument();
799
+ expect(
800
+ screen.getByTestId("personal-calendar-feed-links"),
801
+ ).toBeInTheDocument();
802
+ });
803
+
804
+ test("Delete asks for confirmation, then deletes the row and re-reads", async () => {
805
+ getMock
806
+ .mockResolvedValueOnce(ok(activeStatusJson()))
807
+ .mockResolvedValueOnce(ok(EMPTY_STATUS_JSON));
808
+
809
+ render(
810
+ <PersonalCalendarFeedCard
811
+ variant={PersonalCalendarFeedVariant.Full}
812
+ now={NOW}
813
+ />,
814
+ );
815
+
816
+ await waitFor(() => {
817
+ expect(
818
+ screen.getByRole("button", { name: "Delete" }),
819
+ ).toBeInTheDocument();
820
+ });
821
+
822
+ fireEvent.click(screen.getByRole("button", { name: "Delete" }));
823
+
824
+ const modal: HTMLElement = await screen.findByTestId("modal");
825
+ expect(deleteItemMock).not.toHaveBeenCalled();
826
+
827
+ fireEvent.click(within(modal).getByRole("button", { name: "Delete" }));
828
+
829
+ await waitFor(() => {
830
+ expect(deleteItemMock).toHaveBeenCalledTimes(1);
831
+ });
832
+
833
+ const call: Record<string, unknown> = deleteItemMock.mock
834
+ .calls[0]![0] as Record<string, unknown>;
835
+ expect(call["modelType"]).toBe(UserOnCallCalendarFeed);
836
+ expect(String(call["id"])).toBe(FEED_ID);
837
+
838
+ await waitFor(() => {
839
+ expect(
840
+ screen.getByTestId("personal-calendar-feed-empty"),
841
+ ).toBeInTheDocument();
842
+ });
843
+ });
844
+
845
+ test("an unreadable stored link shows the regenerate warning and no link", async () => {
846
+ getMock.mockResolvedValue(
847
+ ok(activeStatusJson({ needsRegeneration: true, urls: null })),
848
+ );
849
+
850
+ render(
851
+ <PersonalCalendarFeedCard
852
+ variant={PersonalCalendarFeedVariant.Full}
853
+ now={NOW}
854
+ />,
855
+ );
856
+
857
+ await waitFor(() => {
858
+ expect(
859
+ screen.getByTestId("personal-calendar-feed-needs-regeneration"),
860
+ ).toHaveTextContent("encryption secret changed");
861
+ });
862
+
863
+ expect(
864
+ screen.queryByTestId("personal-calendar-feed-links"),
865
+ ).not.toBeInTheDocument();
866
+ expect(
867
+ screen.getByRole("button", { name: "Regenerate link" }),
868
+ ).toBeInTheDocument();
869
+ });
870
+
871
+ test("server warnings attached to the link are shown next to it", async () => {
872
+ getMock.mockResolvedValue(
873
+ ok(
874
+ activeStatusJson({
875
+ hostWarning: "Set HOST to your public hostname",
876
+ protocolWarning: "This link will travel unencrypted",
877
+ lastRenderTruncated: true,
878
+ }),
879
+ ),
880
+ );
881
+
882
+ render(
883
+ <PersonalCalendarFeedCard
884
+ variant={PersonalCalendarFeedVariant.Full}
885
+ now={NOW}
886
+ />,
887
+ );
888
+
889
+ await waitFor(() => {
890
+ expect(
891
+ screen.getByTestId("personal-calendar-feed-host-warning"),
892
+ ).toHaveTextContent("Set HOST to your public hostname");
893
+ });
894
+ expect(
895
+ screen.getByTestId("personal-calendar-feed-protocol-warning"),
896
+ ).toHaveTextContent("This link will travel unencrypted");
897
+ expect(
898
+ screen.getByTestId("personal-calendar-feed-truncated-warning"),
899
+ ).toBeInTheDocument();
900
+ });
901
+
902
+ test("a 404 from an older API hides the feature instead of erroring", async () => {
903
+ getMock.mockResolvedValue(fail(404, "Not found"));
904
+
905
+ render(
906
+ <PersonalCalendarFeedCard
907
+ variant={PersonalCalendarFeedVariant.Full}
908
+ now={NOW}
909
+ />,
910
+ );
911
+
912
+ await waitFor(() => {
913
+ expect(
914
+ screen.getByTestId("personal-calendar-feed-unsupported"),
915
+ ).toHaveTextContent("does not offer calendar feeds yet");
916
+ });
917
+
918
+ expect(
919
+ screen.queryByTestId("personal-calendar-feed-generate"),
920
+ ).not.toBeInTheDocument();
921
+ expect(screen.queryByText("Not found")).not.toBeInTheDocument();
922
+ });
923
+
924
+ test("any other failure surfaces the server's own message", async () => {
925
+ getMock.mockResolvedValue(fail(500, "Redis is on fire"));
926
+
927
+ render(
928
+ <PersonalCalendarFeedCard
929
+ variant={PersonalCalendarFeedVariant.Full}
930
+ now={NOW}
931
+ />,
932
+ );
933
+
934
+ await waitFor(() => {
935
+ expect(screen.getByText("Redis is on fire")).toBeInTheDocument();
936
+ });
937
+ });
938
+
939
+ test("a failed Generate keeps the empty state and shows the reason", async () => {
940
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
941
+ postMock.mockResolvedValue(
942
+ fail(402, "Upgrade to Growth to use calendar feeds"),
943
+ );
944
+
945
+ render(
946
+ <PersonalCalendarFeedCard
947
+ variant={PersonalCalendarFeedVariant.Full}
948
+ now={NOW}
949
+ />,
950
+ );
951
+
952
+ await waitFor(() => {
953
+ expect(
954
+ screen.getByTestId("personal-calendar-feed-generate"),
955
+ ).toBeInTheDocument();
956
+ });
957
+
958
+ fireEvent.click(screen.getByTestId("personal-calendar-feed-generate"));
959
+
960
+ await waitFor(() => {
961
+ expect(
962
+ screen.getByText("Upgrade to Growth to use calendar feeds"),
963
+ ).toBeInTheDocument();
964
+ });
965
+ expect(
966
+ screen.getByTestId("personal-calendar-feed-empty"),
967
+ ).toBeInTheDocument();
968
+ });
969
+ });
970
+
971
+ describe("PersonalCalendarFeedCard (schedule variant)", () => {
972
+ beforeEach(() => {
973
+ resetMocks();
974
+ goToCalendarFeedPage();
975
+ });
976
+
977
+ afterEach(() => {
978
+ cleanup();
979
+ });
980
+
981
+ test("narrows the personal link to the schedule and points at the full page", async () => {
982
+ getMock.mockResolvedValue(ok(activeStatusJson()));
983
+
984
+ render(
985
+ <PersonalCalendarFeedCard
986
+ variant={PersonalCalendarFeedVariant.Schedule}
987
+ scheduleId={SCHEDULE_ID}
988
+ now={NOW}
989
+ />,
990
+ );
991
+
992
+ await waitFor(() => {
993
+ expect(
994
+ screen.getByTestId("schedule-personal-calendar-feed-links"),
995
+ ).toBeInTheDocument();
996
+ });
997
+
998
+ expect(
999
+ screen.getByTestId("schedule-personal-calendar-feed-webcal"),
1000
+ ).toHaveAttribute(
1001
+ "href",
1002
+ `${WEBCAL_URL}?schedule=${SCHEDULE_ID.toString()}`,
1003
+ );
1004
+ // The page renders the refresh alert once, so the schedule half does not.
1005
+ expect(
1006
+ screen.queryByTestId("schedule-personal-calendar-feed-refresh-alert"),
1007
+ ).not.toBeInTheDocument();
1008
+
1009
+ const expectedRoute: Route = RouteUtil.populateRouteParams(
1010
+ RouteMap[PageMap.USER_SETTINGS_ON_CALL_CALENDAR_FEED] as Route,
1011
+ );
1012
+ expect(
1013
+ screen.getByText("Manage your calendar link and reminders").closest("a"),
1014
+ ).toHaveAttribute("href", expectedRoute.toString());
1015
+
1016
+ // No regenerate / disable / delete controls in the narrow variant.
1017
+ expect(screen.queryByTestId("card-button")).not.toBeInTheDocument();
1018
+ expect(
1019
+ screen.queryByTestId("card-model-detail-stub"),
1020
+ ).not.toBeInTheDocument();
1021
+ });
1022
+
1023
+ /*
1024
+ * A truncated render is a property of the feed, not of the page it is shown
1025
+ * on: the same link is short on the schedule page and on the settings page.
1026
+ * The narrow variant used to drop the flag, so a reader who only ever opens
1027
+ * the schedule page never learned their calendar was incomplete.
1028
+ */
1029
+ test("the narrow variant carries the truncation warning, like the full one", async () => {
1030
+ getMock.mockResolvedValue(
1031
+ ok(activeStatusJson({ lastRenderTruncated: true })),
1032
+ );
1033
+
1034
+ render(
1035
+ <PersonalCalendarFeedCard
1036
+ variant={PersonalCalendarFeedVariant.Schedule}
1037
+ scheduleId={SCHEDULE_ID}
1038
+ now={NOW}
1039
+ />,
1040
+ );
1041
+
1042
+ await waitFor(() => {
1043
+ expect(
1044
+ screen.getByTestId("schedule-personal-calendar-feed-links"),
1045
+ ).toBeInTheDocument();
1046
+ });
1047
+
1048
+ expect(
1049
+ screen.getByTestId("schedule-personal-calendar-feed-truncated-warning"),
1050
+ ).toHaveTextContent("shortened because it would have exceeded the event");
1051
+ });
1052
+
1053
+ test("offers Generate when no link exists and posts to /feed/rotate", async () => {
1054
+ getMock.mockResolvedValue(ok(EMPTY_STATUS_JSON));
1055
+ postMock.mockResolvedValue(ok(activeStatusJson()));
1056
+
1057
+ render(
1058
+ <PersonalCalendarFeedCard
1059
+ variant={PersonalCalendarFeedVariant.Schedule}
1060
+ scheduleId={SCHEDULE_ID}
1061
+ now={NOW}
1062
+ />,
1063
+ );
1064
+
1065
+ await waitFor(() => {
1066
+ expect(
1067
+ screen.getByTestId("schedule-personal-calendar-feed-generate"),
1068
+ ).toBeInTheDocument();
1069
+ });
1070
+
1071
+ fireEvent.click(
1072
+ screen.getByTestId("schedule-personal-calendar-feed-generate"),
1073
+ );
1074
+
1075
+ await waitFor(() => {
1076
+ expect(
1077
+ screen.getByTestId("schedule-personal-calendar-feed-links"),
1078
+ ).toBeInTheDocument();
1079
+ });
1080
+ expect(urlOf(postMock.mock.calls[0] as Array<unknown>)).toContain(
1081
+ PERSONAL_FEED_ROTATE_PATH,
1082
+ );
1083
+ });
1084
+
1085
+ test("a disabled link explains itself rather than showing a dead URL", async () => {
1086
+ getMock.mockResolvedValue(ok(activeStatusJson({ isEnabled: false })));
1087
+
1088
+ render(
1089
+ <PersonalCalendarFeedCard
1090
+ variant={PersonalCalendarFeedVariant.Schedule}
1091
+ scheduleId={SCHEDULE_ID}
1092
+ now={NOW}
1093
+ />,
1094
+ );
1095
+
1096
+ await waitFor(() => {
1097
+ expect(screen.getByText(/This link is disabled/)).toBeInTheDocument();
1098
+ });
1099
+ expect(
1100
+ screen.queryByTestId("schedule-personal-calendar-feed-links"),
1101
+ ).not.toBeInTheDocument();
1102
+ });
1103
+
1104
+ test("an unreadable link says it needs regenerating", async () => {
1105
+ getMock.mockResolvedValue(
1106
+ ok(activeStatusJson({ needsRegeneration: true, urls: null })),
1107
+ );
1108
+
1109
+ render(
1110
+ <PersonalCalendarFeedCard
1111
+ variant={PersonalCalendarFeedVariant.Schedule}
1112
+ scheduleId={SCHEDULE_ID}
1113
+ now={NOW}
1114
+ />,
1115
+ );
1116
+
1117
+ await waitFor(() => {
1118
+ expect(
1119
+ screen.getByText(/needs to be regenerated before it can be shown here/),
1120
+ ).toBeInTheDocument();
1121
+ });
1122
+ });
1123
+
1124
+ test("a 404 from an older API renders the unsupported note", async () => {
1125
+ getMock.mockResolvedValue(fail(404, "Not found"));
1126
+
1127
+ render(
1128
+ <PersonalCalendarFeedCard
1129
+ variant={PersonalCalendarFeedVariant.Schedule}
1130
+ scheduleId={SCHEDULE_ID}
1131
+ now={NOW}
1132
+ />,
1133
+ );
1134
+
1135
+ await waitFor(() => {
1136
+ expect(
1137
+ screen.getByTestId("schedule-personal-calendar-feed-unsupported"),
1138
+ ).toBeInTheDocument();
1139
+ });
1140
+ });
1141
+ });
1142
+
1143
+ describe("User Settings > Calendar Feed page", () => {
1144
+ beforeEach(() => {
1145
+ resetMocks();
1146
+ goToCalendarFeedPage();
1147
+ });
1148
+
1149
+ afterEach(() => {
1150
+ cleanup();
1151
+ });
1152
+
1153
+ test("renders the link card, the upcoming shifts card and the reminders card", async () => {
1154
+ getMock.mockImplementation((args: Record<string, unknown>) => {
1155
+ const url: string = String(args["url"]);
1156
+
1157
+ if (url.includes("/my-shifts")) {
1158
+ return Promise.resolve(
1159
+ ok({ shifts: [], truncated: false, generatedAt: NOW.toISOString() }),
1160
+ );
1161
+ }
1162
+
1163
+ return Promise.resolve(ok(activeStatusJson()));
1164
+ });
1165
+
1166
+ render(
1167
+ <UserSettingsOnCallCalendarFeed
1168
+ pageRoute={new Route("/")}
1169
+ currentProject={null}
1170
+ hasPaymentMethod={false}
1171
+ />,
1172
+ );
1173
+
1174
+ await waitFor(() => {
1175
+ expect(
1176
+ screen.getByTestId("personal-calendar-feed-active"),
1177
+ ).toBeInTheDocument();
1178
+ });
1179
+ await waitFor(() => {
1180
+ expect(screen.getByTestId("upcoming-shifts-empty")).toBeInTheDocument();
1181
+ });
1182
+ await waitFor(() => {
1183
+ expect(
1184
+ screen.getByTestId("shift-reminder-chip-custom"),
1185
+ ).toBeInTheDocument();
1186
+ });
1187
+
1188
+ expect(
1189
+ screen.getByText("Subscribe to your on-call shifts"),
1190
+ ).toBeInTheDocument();
1191
+ expect(
1192
+ screen.getByText("Upcoming shifts (next 30 days)"),
1193
+ ).toBeInTheDocument();
1194
+ expect(screen.getByText("Remind me before shifts")).toBeInTheDocument();
1195
+
1196
+ // The reminders card asked for the signed-in user's rows in this project.
1197
+ expect(getListMock).toHaveBeenCalledTimes(1);
1198
+ const listCall: Record<string, unknown> = getListMock.mock
1199
+ .calls[0]![0] as Record<string, unknown>;
1200
+ const query: Record<string, unknown> = listCall["query"] as Record<
1201
+ string,
1202
+ unknown
1203
+ >;
1204
+ expect(String(query["projectId"])).toBe(PROJECT_ID);
1205
+ expect(String(query["userId"])).toBe(
1206
+ "44444444-4444-4444-8444-444444444444",
1207
+ );
1208
+ });
1209
+ });