@oneuptime/common 12.0.31 → 12.0.32

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 (188) hide show
  1. package/Models/DatabaseModels/AlertState.ts +0 -2
  2. package/Models/DatabaseModels/Domain.ts +1 -1
  3. package/Models/DatabaseModels/Incident.ts +1 -1
  4. package/Models/DatabaseModels/IncidentTemplate.ts +1 -1
  5. package/Models/DatabaseModels/Index.ts +2 -0
  6. package/Models/DatabaseModels/MetricType.ts +0 -2
  7. package/Models/DatabaseModels/MonitorStatusTimeline.ts +0 -2
  8. package/Models/DatabaseModels/MonitorTest.ts +0 -2
  9. package/Models/DatabaseModels/NetworkDevice.ts +121 -2
  10. package/Models/DatabaseModels/NetworkDeviceOidTemplate.ts +3 -1
  11. package/Models/DatabaseModels/NetworkDeviceRole.ts +648 -0
  12. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -1
  13. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -1
  14. package/Models/DatabaseModels/StatusPageAnnouncement.ts +0 -2
  15. package/Models/DatabaseModels/StatusPageAnnouncementTemplate.ts +0 -2
  16. package/Models/DatabaseModels/StatusPagePrivateUser.ts +0 -2
  17. package/Models/DatabaseModels/StatusPageResource.ts +0 -2
  18. package/Models/DatabaseModels/StatusPageSubscriber.ts +0 -2
  19. package/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.ts +72 -0
  20. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  21. package/Server/Services/DatabaseService.ts +39 -9
  22. package/Server/Services/Index.ts +2 -0
  23. package/Server/Services/NetworkDeviceRoleService.ts +90 -0
  24. package/Server/Services/ProjectService.ts +85 -0
  25. package/Server/Services/UserNotificationSettingService.ts +0 -6
  26. package/Tests/App/Dashboard/AddNeighborToMonitoringModal.test.tsx +31 -1
  27. package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +35 -8
  28. package/Tests/App/Dashboard/DashboardCommandPalette.test.tsx +107 -3
  29. package/Tests/App/Dashboard/DashboardWideChartZoom.test.tsx +401 -0
  30. package/Tests/App/Dashboard/MonitorTemplateColumnAndFacet.test.tsx +318 -0
  31. package/Tests/App/Dashboard/NetworkDeviceBulkDelete.test.tsx +357 -0
  32. package/Tests/App/Dashboard/NetworkSideMenu.test.tsx +590 -0
  33. package/Tests/App/Dashboard/SideMenuHarness.tsx +11 -3
  34. package/Tests/Models/DatabaseModels/ModelRegistryInvariants.test.ts +115 -41
  35. package/Tests/Server/Infrastructure/Postgres/NetworkDeviceRoleTableMigration.test.ts +476 -0
  36. package/Tests/Server/Services/DatabaseServiceGenerateSlug.test.ts +215 -0
  37. package/Tests/Server/Services/NetworkDeviceRoleModel.test.ts +672 -0
  38. package/Tests/Server/Services/NetworkDeviceRuleEngines.test.ts +1 -1
  39. package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +67 -4
  40. package/Tests/Server/Services/ScheduledMaintenanceSubscriberEndTime.test.ts +369 -0
  41. package/Tests/Server/Services/UserNotificationSettingMonitorDefaults.test.ts +248 -0
  42. package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +43 -2
  43. package/Tests/Server/Utils/BasicCron.test.ts +283 -0
  44. package/Tests/Server/Utils/IncomingCallPhoneNumber.test.ts +174 -0
  45. package/Tests/Types/NetworkDevice/DefaultNetworkDeviceRole.test.ts +417 -0
  46. package/Tests/UI/Components/Charts/ChartClickDoubleClickDisambiguation.test.tsx +221 -0
  47. package/Tests/UI/Components/Charts/ChartDoubleClickReset.test.tsx +220 -0
  48. package/Tests/UI/Components/ModelTable/BaseModelTableBulkDelete.test.tsx +751 -0
  49. package/Tests/UI/Components/ModelTable/BaseModelTableColumnCustomization.test.tsx +475 -43
  50. package/Tests/UI/Components/ModelTable/ColumnPreference.test.ts +89 -0
  51. package/Tests/UI/Components/NavBarCommandKGating.test.tsx +103 -8
  52. package/Tests/UI/Components/SideMenuItem.test.tsx +30 -0
  53. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +30 -0
  54. package/Tests/UI/Utils/UseDashboardTimeRangeZoom.test.tsx +187 -0
  55. package/Tests/Utils/Dashboard/DashboardResourceListSharedArgs.test.ts +302 -0
  56. package/Tests/Utils/Dashboard/DashboardTimeRangeZoom.test.ts +242 -0
  57. package/Tests/Utils/Monitor/NetworkDeviceRoleCatalog.test.ts +981 -0
  58. package/Tests/Utils/Monitor/NetworkTopologyRoleCatalog.test.ts +865 -0
  59. package/Tests/Utils/NetworkDevice/DeviceRoleKeyUtil.test.ts +538 -0
  60. package/Tests/Utils/Slo/SloEvaluation.test.ts +171 -0
  61. package/Tests/Utils/Slug.test.ts +61 -1
  62. package/Tests/jest.setup.ts +19 -0
  63. package/Types/Monitor/SnmpMonitor/NetworkTopology.ts +61 -0
  64. package/Types/NetworkDevice/DefaultNetworkDeviceRole.ts +145 -0
  65. package/Types/Permission.ts +45 -0
  66. package/UI/Components/Charts/Area/AreaChart.tsx +7 -0
  67. package/UI/Components/Charts/Bar/BarChart.tsx +6 -0
  68. package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +9 -1
  69. package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +97 -35
  70. package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +14 -0
  71. package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +97 -35
  72. package/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.ts +13 -0
  73. package/UI/Components/Charts/Line/LineChart.tsx +7 -0
  74. package/UI/Components/ModelTable/BaseModelTable.tsx +50 -15
  75. package/UI/Components/Navbar/NavBar.tsx +62 -13
  76. package/UI/Components/Navbar/NavBarMenuModal.tsx +7 -2
  77. package/UI/Components/SideMenu/CountModelSideMenuItem.tsx +2 -0
  78. package/UI/Components/SideMenu/SideMenu.tsx +12 -2
  79. package/UI/Components/SideMenu/SideMenuItem.tsx +4 -1
  80. package/UI/Components/SideMenu/SideMenuSection.tsx +14 -2
  81. package/UI/Utils/UseDashboardTimeRangeZoom.ts +77 -0
  82. package/Utils/Dashboard/DashboardTimeRangeZoom.ts +118 -0
  83. package/Utils/Monitor/NetworkDeviceRoleCatalog.ts +232 -0
  84. package/Utils/Monitor/NetworkDeviceRoleUtil.ts +2 -1
  85. package/Utils/Monitor/NetworkTopologyUtil.ts +116 -33
  86. package/Utils/NetworkDevice/DeviceRoleKeyUtil.ts +137 -0
  87. package/Utils/Rules/RulePatternMatchUtil.ts +1 -1
  88. package/Utils/Slug.ts +34 -1
  89. package/build/dist/Models/DatabaseModels/AlertState.js +0 -2
  90. package/build/dist/Models/DatabaseModels/AlertState.js.map +1 -1
  91. package/build/dist/Models/DatabaseModels/Domain.js +1 -1
  92. package/build/dist/Models/DatabaseModels/Domain.js.map +1 -1
  93. package/build/dist/Models/DatabaseModels/Incident.js +1 -1
  94. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  95. package/build/dist/Models/DatabaseModels/IncidentTemplate.js +1 -1
  96. package/build/dist/Models/DatabaseModels/IncidentTemplate.js.map +1 -1
  97. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  98. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  99. package/build/dist/Models/DatabaseModels/MetricType.js +0 -2
  100. package/build/dist/Models/DatabaseModels/MetricType.js.map +1 -1
  101. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +0 -2
  102. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  103. package/build/dist/Models/DatabaseModels/MonitorTest.js +0 -2
  104. package/build/dist/Models/DatabaseModels/MonitorTest.js.map +1 -1
  105. package/build/dist/Models/DatabaseModels/NetworkDevice.js +124 -3
  106. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  107. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js +3 -1
  108. package/build/dist/Models/DatabaseModels/NetworkDeviceOidTemplate.js.map +1 -1
  109. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js +677 -0
  110. package/build/dist/Models/DatabaseModels/NetworkDeviceRole.js.map +1 -0
  111. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -1
  112. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  113. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -1
  114. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  115. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +0 -2
  116. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  117. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js +0 -2
  118. package/build/dist/Models/DatabaseModels/StatusPageAnnouncementTemplate.js.map +1 -1
  119. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +0 -2
  120. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  121. package/build/dist/Models/DatabaseModels/StatusPageResource.js +0 -2
  122. package/build/dist/Models/DatabaseModels/StatusPageResource.js.map +1 -1
  123. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js +0 -2
  124. package/build/dist/Models/DatabaseModels/StatusPageSubscriber.js.map +1 -1
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js +39 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1790800000000-AddNetworkDeviceRoleTable.js.map +1 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  129. package/build/dist/Server/Services/DatabaseService.js +31 -5
  130. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  131. package/build/dist/Server/Services/Index.js +2 -0
  132. package/build/dist/Server/Services/Index.js.map +1 -1
  133. package/build/dist/Server/Services/NetworkDeviceRoleService.js +70 -0
  134. package/build/dist/Server/Services/NetworkDeviceRoleService.js.map +1 -0
  135. package/build/dist/Server/Services/ProjectService.js +65 -0
  136. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  137. package/build/dist/Server/Services/UserNotificationSettingService.js +0 -1
  138. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  139. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js +105 -0
  140. package/build/dist/Types/NetworkDevice/DefaultNetworkDeviceRole.js.map +1 -0
  141. package/build/dist/Types/Permission.js +40 -0
  142. package/build/dist/Types/Permission.js.map +1 -1
  143. package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
  144. package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
  145. package/build/dist/UI/Components/Charts/Bar/BarChart.js +1 -1
  146. package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
  147. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +6 -1
  148. package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
  149. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +79 -31
  150. package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
  151. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +7 -1
  152. package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
  153. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +79 -31
  154. package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
  155. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js +14 -0
  156. package/build/dist/UI/Components/Charts/ChartLibrary/Utils/DoubleClick.js.map +1 -0
  157. package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
  158. package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
  159. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +39 -13
  160. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  161. package/build/dist/UI/Components/Navbar/NavBar.js +44 -9
  162. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  163. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +2 -2
  164. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  165. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js +1 -1
  166. package/build/dist/UI/Components/SideMenu/CountModelSideMenuItem.js.map +1 -1
  167. package/build/dist/UI/Components/SideMenu/SideMenu.js +9 -6
  168. package/build/dist/UI/Components/SideMenu/SideMenu.js.map +1 -1
  169. package/build/dist/UI/Components/SideMenu/SideMenuItem.js +1 -1
  170. package/build/dist/UI/Components/SideMenu/SideMenuItem.js.map +1 -1
  171. package/build/dist/UI/Components/SideMenu/SideMenuSection.js +7 -2
  172. package/build/dist/UI/Components/SideMenu/SideMenuSection.js.map +1 -1
  173. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js +42 -0
  174. package/build/dist/UI/Utils/UseDashboardTimeRangeZoom.js.map +1 -0
  175. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js +74 -0
  176. package/build/dist/Utils/Dashboard/DashboardTimeRangeZoom.js.map +1 -0
  177. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js +126 -0
  178. package/build/dist/Utils/Monitor/NetworkDeviceRoleCatalog.js.map +1 -0
  179. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js +2 -1
  180. package/build/dist/Utils/Monitor/NetworkDeviceRoleUtil.js.map +1 -1
  181. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js +67 -32
  182. package/build/dist/Utils/Monitor/NetworkTopologyUtil.js.map +1 -1
  183. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js +114 -0
  184. package/build/dist/Utils/NetworkDevice/DeviceRoleKeyUtil.js.map +1 -0
  185. package/build/dist/Utils/Rules/RulePatternMatchUtil.js +1 -1
  186. package/build/dist/Utils/Slug.js +31 -1
  187. package/build/dist/Utils/Slug.js.map +1 -1
  188. package/package.json +1 -1
@@ -0,0 +1,248 @@
1
+ import UserNotificationSetting from "../../../Models/DatabaseModels/UserNotificationSetting";
2
+ import UserNotificationSettingService from "../../../Server/Services/UserNotificationSettingService";
3
+ import NotificationSettingEventType from "../../../Types/NotificationSetting/NotificationSettingEventType";
4
+ import ObjectID from "../../../Types/ObjectID";
5
+ import PositiveNumber from "../../../Types/PositiveNumber";
6
+ import {
7
+ afterEach,
8
+ beforeEach,
9
+ describe,
10
+ expect,
11
+ jest,
12
+ test,
13
+ } from "@jest/globals";
14
+
15
+ const PROJECT_ID: ObjectID = new ObjectID(
16
+ "11111111-1111-4111-8111-111111111111",
17
+ );
18
+ const OTHER_PROJECT_ID: ObjectID = new ObjectID(
19
+ "33333333-3333-4333-8333-333333333333",
20
+ );
21
+ const USER_ID: ObjectID = new ObjectID("22222222-2222-4222-8222-222222222222");
22
+ const OTHER_USER_ID: ObjectID = new ObjectID(
23
+ "44444444-4444-4444-8444-444444444444",
24
+ );
25
+
26
+ const STATUS_CHANGE_EVENT: NotificationSettingEventType =
27
+ NotificationSettingEventType.SEND_MONITOR_STATUS_CHANGED_OWNER_NOTIFICATION;
28
+
29
+ const ENABLED_MONITOR_EVENTS: Array<NotificationSettingEventType> = [
30
+ NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_NO_PROBES_ARE_MONITORING_THE_MONITOR,
31
+ NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_PORBE_STATUS_CHANGES,
32
+ ];
33
+
34
+ interface StoredSetting {
35
+ userId: string;
36
+ projectId: string;
37
+ eventType: NotificationSettingEventType;
38
+ alertByEmail: boolean;
39
+ alertBySMS: boolean;
40
+ alertByCall: boolean;
41
+ alertByPush: boolean;
42
+ alertByWhatsApp: boolean;
43
+ alertByTelegram: boolean;
44
+ alertBySlack: boolean;
45
+ alertByMicrosoftTeams: boolean;
46
+ alertByWebhook: boolean;
47
+ isRoot: boolean;
48
+ }
49
+
50
+ interface SettingIdentity {
51
+ userId: unknown;
52
+ projectId: unknown;
53
+ eventType: unknown;
54
+ }
55
+
56
+ function key(data: SettingIdentity): string {
57
+ return `${String(data.userId)}|${String(data.projectId)}|${String(
58
+ data.eventType,
59
+ )}`;
60
+ }
61
+
62
+ describe("UserNotificationSettingService monitor defaults", () => {
63
+ let store: Map<string, StoredSetting>;
64
+ let createdSettings: Array<StoredSetting>;
65
+ let queriedSettings: Array<SettingIdentity>;
66
+
67
+ beforeEach(() => {
68
+ store = new Map<string, StoredSetting>();
69
+ createdSettings = [];
70
+ queriedSettings = [];
71
+
72
+ jest
73
+ .spyOn(UserNotificationSettingService, "countBy")
74
+ .mockImplementation(((args: {
75
+ query: {
76
+ userId: ObjectID;
77
+ projectId: ObjectID;
78
+ eventType: NotificationSettingEventType;
79
+ };
80
+ }): Promise<PositiveNumber> => {
81
+ queriedSettings.push(args.query);
82
+
83
+ return Promise.resolve(
84
+ new PositiveNumber(store.has(key(args.query)) ? 1 : 0),
85
+ );
86
+ }) as never);
87
+
88
+ jest
89
+ .spyOn(UserNotificationSettingService, "create")
90
+ .mockImplementation(((args: {
91
+ data: UserNotificationSetting;
92
+ props: { isRoot?: boolean };
93
+ }): Promise<UserNotificationSetting> => {
94
+ const item: UserNotificationSetting = args.data;
95
+ const storedSetting: StoredSetting = {
96
+ userId: String(item.userId),
97
+ projectId: String(item.projectId),
98
+ eventType: item.eventType as NotificationSettingEventType,
99
+ alertByEmail: Boolean(item.alertByEmail),
100
+ alertBySMS: Boolean(item.alertBySMS),
101
+ alertByCall: Boolean(item.alertByCall),
102
+ alertByPush: Boolean(item.alertByPush),
103
+ alertByWhatsApp: Boolean(item.alertByWhatsApp),
104
+ alertByTelegram: Boolean(item.alertByTelegram),
105
+ alertBySlack: Boolean(item.alertBySlack),
106
+ alertByMicrosoftTeams: Boolean(item.alertByMicrosoftTeams),
107
+ alertByWebhook: Boolean(item.alertByWebhook),
108
+ isRoot: Boolean(args.props.isRoot),
109
+ };
110
+
111
+ createdSettings.push(storedSetting);
112
+ store.set(key(storedSetting), storedSetting);
113
+
114
+ return Promise.resolve(item);
115
+ }) as never);
116
+ });
117
+
118
+ afterEach(() => {
119
+ jest.restoreAllMocks();
120
+ });
121
+
122
+ function stored(
123
+ eventType: NotificationSettingEventType,
124
+ userId: ObjectID = USER_ID,
125
+ projectId: ObjectID = PROJECT_ID,
126
+ ): StoredSetting | undefined {
127
+ return store.get(key({ userId, projectId, eventType }));
128
+ }
129
+
130
+ function seed(setting: StoredSetting): void {
131
+ store.set(key(setting), setting);
132
+ }
133
+
134
+ test("does not seed monitor status-change notifications for a new membership", async () => {
135
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
136
+ USER_ID,
137
+ PROJECT_ID,
138
+ );
139
+
140
+ expect(stored(STATUS_CHANGE_EVENT)).toBeUndefined();
141
+ expect(
142
+ createdSettings.some((setting: StoredSetting) => {
143
+ return setting.eventType === STATUS_CHANGE_EVENT;
144
+ }),
145
+ ).toBe(false);
146
+ expect(
147
+ queriedSettings.some((setting: SettingIdentity) => {
148
+ return setting.eventType === STATUS_CHANGE_EVENT;
149
+ }),
150
+ ).toBe(false);
151
+ });
152
+
153
+ test("keeps the other monitor defaults enabled by email only", async () => {
154
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
155
+ USER_ID,
156
+ PROJECT_ID,
157
+ );
158
+
159
+ for (const eventType of ENABLED_MONITOR_EVENTS) {
160
+ expect(stored(eventType)).toEqual({
161
+ userId: USER_ID.toString(),
162
+ projectId: PROJECT_ID.toString(),
163
+ eventType,
164
+ alertByEmail: true,
165
+ alertBySMS: false,
166
+ alertByCall: false,
167
+ alertByPush: false,
168
+ alertByWhatsApp: false,
169
+ alertByTelegram: false,
170
+ alertBySlack: false,
171
+ alertByMicrosoftTeams: false,
172
+ alertByWebhook: false,
173
+ isRoot: true,
174
+ });
175
+ }
176
+ });
177
+
178
+ test("remains idempotent without recreating the disabled default", async () => {
179
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
180
+ USER_ID,
181
+ PROJECT_ID,
182
+ );
183
+
184
+ const settingsCreatedByFirstCall: number = createdSettings.length;
185
+ const rowsCreatedByFirstCall: number = store.size;
186
+
187
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
188
+ USER_ID,
189
+ PROJECT_ID,
190
+ );
191
+
192
+ expect(createdSettings).toHaveLength(settingsCreatedByFirstCall);
193
+ expect(store.size).toBe(rowsCreatedByFirstCall);
194
+ expect(stored(STATUS_CHANGE_EVENT)).toBeUndefined();
195
+ });
196
+
197
+ test("preserves an existing explicit status-change preference", async () => {
198
+ const optedInSetting: StoredSetting = {
199
+ userId: USER_ID.toString(),
200
+ projectId: PROJECT_ID.toString(),
201
+ eventType: STATUS_CHANGE_EVENT,
202
+ alertByEmail: false,
203
+ alertBySMS: false,
204
+ alertByCall: false,
205
+ alertByPush: true,
206
+ alertByWhatsApp: false,
207
+ alertByTelegram: false,
208
+ alertBySlack: true,
209
+ alertByMicrosoftTeams: false,
210
+ alertByWebhook: false,
211
+ isRoot: true,
212
+ };
213
+ seed(optedInSetting);
214
+
215
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
216
+ USER_ID,
217
+ PROJECT_ID,
218
+ );
219
+
220
+ expect(stored(STATUS_CHANGE_EVENT)).toEqual(optedInSetting);
221
+ expect(
222
+ createdSettings.some((setting: StoredSetting) => {
223
+ return setting.eventType === STATUS_CHANGE_EVENT;
224
+ }),
225
+ ).toBe(false);
226
+ });
227
+
228
+ test("applies the off default independently to every user and project", async () => {
229
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
230
+ USER_ID,
231
+ PROJECT_ID,
232
+ );
233
+ await UserNotificationSettingService.addDefaultNotificationSettingsForUser(
234
+ OTHER_USER_ID,
235
+ OTHER_PROJECT_ID,
236
+ );
237
+
238
+ expect(stored(STATUS_CHANGE_EVENT, USER_ID, PROJECT_ID)).toBeUndefined();
239
+ expect(
240
+ stored(STATUS_CHANGE_EVENT, OTHER_USER_ID, OTHER_PROJECT_ID),
241
+ ).toBeUndefined();
242
+
243
+ for (const eventType of ENABLED_MONITOR_EVENTS) {
244
+ expect(stored(eventType, USER_ID, PROJECT_ID)).toBeDefined();
245
+ expect(stored(eventType, OTHER_USER_ID, OTHER_PROJECT_ID)).toBeDefined();
246
+ }
247
+ });
248
+ });
@@ -3,10 +3,13 @@ import UserSlackService from "../../../Server/Services/UserSlackService";
3
3
  import UserMicrosoftTeamsService from "../../../Server/Services/UserMicrosoftTeamsService";
4
4
  import WorkspaceUserNotificationService from "../../../Server/Services/WorkspaceUserNotificationService";
5
5
  import ProjectCallSMSConfigService from "../../../Server/Services/ProjectCallSMSConfigService";
6
+ import MailService from "../../../Server/Services/MailService";
7
+ import UserEmailService from "../../../Server/Services/UserEmailService";
6
8
  import logger from "../../../Server/Utils/Logger";
7
9
  import UserNotificationSetting from "../../../Models/DatabaseModels/UserNotificationSetting";
8
10
  import UserSlack from "../../../Models/DatabaseModels/UserSlack";
9
11
  import UserMicrosoftTeams from "../../../Models/DatabaseModels/UserMicrosoftTeams";
12
+ import UserEmail from "../../../Models/DatabaseModels/UserEmail";
10
13
  import { CallRequestMessage } from "../../../Types/Call/CallRequest";
11
14
  import { EmailEnvelope } from "../../../Types/Email/EmailMessage";
12
15
  import EmailTemplateType from "../../../Types/Email/EmailTemplateType";
@@ -60,6 +63,8 @@ const TEAMS_USER_ID: string = "entra-object-id-1";
60
63
 
61
64
  const EVENT_TYPE: NotificationSettingEventType =
62
65
  NotificationSettingEventType.SEND_INCIDENT_CREATED_OWNER_NOTIFICATION;
66
+ const MONITOR_STATUS_CHANGE_EVENT: NotificationSettingEventType =
67
+ NotificationSettingEventType.SEND_MONITOR_STATUS_CHANGED_OWNER_NOTIFICATION;
63
68
 
64
69
  type SendUserNotificationData = Parameters<
65
70
  typeof UserNotificationSettingService.sendUserNotification
@@ -114,6 +119,8 @@ describe("UserNotificationSettingService.sendUserNotification - workspace channe
114
119
  let findSettings: jest.SpyInstance;
115
120
  let findSlacks: jest.SpyInstance;
116
121
  let findTeams: jest.SpyInstance;
122
+ let findEmails: jest.SpyInstance;
123
+ let sendMail: jest.SpyInstance;
117
124
  let sendDm: jest.SpyInstance;
118
125
  let loggerError: jest.SpyInstance;
119
126
 
@@ -133,6 +140,16 @@ describe("UserNotificationSettingService.sendUserNotification - workspace channe
133
140
  .spyOn(ProjectCallSMSConfigService, "getProjectDefaultTwilioConfig")
134
141
  .mockResolvedValue(undefined as never);
135
142
 
143
+ findEmails = jest.spyOn(UserEmailService, "findBy").mockResolvedValue([
144
+ {
145
+ email: "user@example.com",
146
+ } as unknown as UserEmail,
147
+ ] as never);
148
+
149
+ sendMail = jest
150
+ .spyOn(MailService, "sendMail")
151
+ .mockResolvedValue(undefined as never);
152
+
136
153
  findSlacks = jest.spyOn(UserSlackService, "findBy").mockResolvedValue([
137
154
  {
138
155
  id: new ObjectID("44444444-4444-4444-8444-444444444444"),
@@ -251,13 +268,37 @@ describe("UserNotificationSettingService.sendUserNotification - workspace channe
251
268
  expect(findTeams).not.toHaveBeenCalled();
252
269
  });
253
270
 
254
- test("no settings row at all sends nothing", async () => {
271
+ test("no monitor status-change setting sends nothing on any channel", async () => {
255
272
  findSettings.mockResolvedValue(null as never);
256
273
 
257
274
  await UserNotificationSettingService.sendUserNotification(
258
- notificationData(),
275
+ notificationData({ eventType: MONITOR_STATUS_CHANGE_EVENT }),
276
+ );
277
+
278
+ expect(findEmails).not.toHaveBeenCalled();
279
+ expect(sendMail).not.toHaveBeenCalled();
280
+ expect(
281
+ ProjectCallSMSConfigService.getProjectDefaultTwilioConfig,
282
+ ).not.toHaveBeenCalled();
283
+ expect(findSlacks).not.toHaveBeenCalled();
284
+ expect(findTeams).not.toHaveBeenCalled();
285
+ expect(sendDm).not.toHaveBeenCalled();
286
+ });
287
+
288
+ test("an explicit monitor status-change email opt-in still delivers", async () => {
289
+ findSettings.mockResolvedValue(
290
+ settingsRow({ alertByEmail: true }) as never,
259
291
  );
260
292
 
293
+ await UserNotificationSettingService.sendUserNotification(
294
+ notificationData({ eventType: MONITOR_STATUS_CHANGE_EVENT }),
295
+ );
296
+
297
+ expect(findEmails).toHaveBeenCalledTimes(1);
298
+ expect(sendMail).toHaveBeenCalledTimes(1);
299
+ expect(sendMail.mock.calls[0]?.[0]).toMatchObject({
300
+ toEmail: "user@example.com",
301
+ });
261
302
  expect(sendDm).not.toHaveBeenCalled();
262
303
  });
263
304
  });
@@ -0,0 +1,283 @@
1
+ import { beforeEach, describe, expect, jest, test } from "@jest/globals";
2
+ import cron from "node-cron";
3
+ import logger from "../../../Server/Utils/Logger";
4
+ import BasicCron from "../../../Server/Utils/BasicCron";
5
+
6
+ /*
7
+ * The wrapper every scheduled job in the app is registered through.
8
+ *
9
+ * Its job is not scheduling — node-cron does that — it is making sure one bad
10
+ * run cannot end the schedule. A cron callback that throws takes its timer
11
+ * with it, so a job that failed once at 03:00 would simply never run again,
12
+ * and nothing would say so: the next morning looks exactly like a night with
13
+ * nothing to do. That is why the tick is wrapped, and it is the behaviour
14
+ * worth pinning here.
15
+ *
16
+ * The startup run is deliberately NOT inside that guard, and the asymmetry is
17
+ * the interesting part of this module — see the last block.
18
+ */
19
+
20
+ jest.mock("node-cron", () => {
21
+ return { __esModule: true, default: { schedule: jest.fn() } };
22
+ });
23
+
24
+ jest.mock("../../../Server/Utils/Logger", () => {
25
+ return {
26
+ __esModule: true,
27
+ default: {
28
+ debug: jest.fn(),
29
+ info: jest.fn(),
30
+ warn: jest.fn(),
31
+ error: jest.fn(),
32
+ },
33
+ };
34
+ });
35
+
36
+ const mockedCron: { schedule: jest.Mock } = cron as unknown as {
37
+ schedule: jest.Mock;
38
+ };
39
+
40
+ const mockedLogger: { debug: jest.Mock; error: jest.Mock } =
41
+ logger as unknown as { debug: jest.Mock; error: jest.Mock };
42
+
43
+ const EVERY_FIVE_MINUTES: string = "*/5 * * * *";
44
+
45
+ /*
46
+ * The run function BasicCron is handed. jest.Mock's first type parameter is
47
+ * the RETURN type, so this is "a mock taking no arguments and answering with
48
+ * a promise" - named once here rather than restated at every call site.
49
+ */
50
+ type RunMock = jest.Mock<Promise<void>, []>;
51
+
52
+ function succeedingRun(): RunMock {
53
+ const mock: RunMock = jest.fn() as unknown as RunMock;
54
+ mock.mockImplementation((): Promise<void> => {
55
+ return Promise.resolve();
56
+ });
57
+ return mock;
58
+ }
59
+
60
+ function failingRun(message: string): RunMock {
61
+ const mock: RunMock = jest.fn() as unknown as RunMock;
62
+ mock.mockImplementation((): Promise<void> => {
63
+ return Promise.reject(new Error(message));
64
+ });
65
+ return mock;
66
+ }
67
+
68
+ /*
69
+ * BasicCron is declared as returning void but its implementation is async, so
70
+ * every call site here goes through this. That mismatch is not incidental
71
+ * tidying - it is the reason a startup failure reaches callers as an
72
+ * unhandled rejection rather than a thrown error, which the last test covers.
73
+ */
74
+ function startCron(data: {
75
+ jobName: string;
76
+ schedule: string;
77
+ runOnStartup: boolean;
78
+ runFunction: RunMock;
79
+ }): Promise<void> {
80
+ return BasicCron({
81
+ jobName: data.jobName,
82
+ options: {
83
+ schedule: data.schedule,
84
+ runOnStartup: data.runOnStartup,
85
+ },
86
+ runFunction: data.runFunction as never,
87
+ }) as unknown as Promise<void>;
88
+ }
89
+
90
+ /*
91
+ * The callback node-cron was handed. Invoking it is what "the schedule fired"
92
+ * means, without waiting five real minutes for it.
93
+ */
94
+ function scheduledTick(): () => Promise<void> {
95
+ return mockedCron.schedule.mock.calls[0]![1] as () => Promise<void>;
96
+ }
97
+
98
+ describe("BasicCron", () => {
99
+ beforeEach(() => {
100
+ jest.clearAllMocks();
101
+ });
102
+
103
+ describe("registration", () => {
104
+ test("registers the job on the schedule it was given", async () => {
105
+ const run: RunMock = succeedingRun();
106
+
107
+ await startCron({
108
+ jobName: "TestJob",
109
+ schedule: EVERY_FIVE_MINUTES,
110
+ runOnStartup: false,
111
+ runFunction: run,
112
+ });
113
+
114
+ expect(mockedCron.schedule).toHaveBeenCalledTimes(1);
115
+ expect(mockedCron.schedule.mock.calls[0]![0]).toBe(EVERY_FIVE_MINUTES);
116
+ });
117
+
118
+ /*
119
+ * Registering must not run the job. A job that ran once per process start
120
+ * would fire on every rolling restart and every replica.
121
+ */
122
+ test("registering alone does not run the job", async () => {
123
+ const run: RunMock = succeedingRun();
124
+
125
+ await startCron({
126
+ jobName: "TestJob",
127
+ schedule: EVERY_FIVE_MINUTES,
128
+ runOnStartup: false,
129
+ runFunction: run,
130
+ });
131
+
132
+ expect(run).not.toHaveBeenCalled();
133
+ });
134
+ });
135
+
136
+ describe("a scheduled run", () => {
137
+ test("runs the job when the schedule fires", async () => {
138
+ const run: RunMock = succeedingRun();
139
+
140
+ await startCron({
141
+ jobName: "TestJob",
142
+ schedule: EVERY_FIVE_MINUTES,
143
+ runOnStartup: false,
144
+ runFunction: run,
145
+ });
146
+
147
+ await scheduledTick()();
148
+
149
+ expect(run).toHaveBeenCalledTimes(1);
150
+ });
151
+
152
+ /*
153
+ * The whole point of the wrapper. An unhandled throw inside a node-cron
154
+ * callback ends that timer, so the job silently stops running for the
155
+ * lifetime of the process - and a job that never runs looks identical to
156
+ * a job with nothing to do.
157
+ */
158
+ test("a throwing run is swallowed, so the schedule survives it", async () => {
159
+ const run: RunMock = failingRun("job blew up");
160
+
161
+ await startCron({
162
+ jobName: "TestJob",
163
+ schedule: EVERY_FIVE_MINUTES,
164
+ runOnStartup: false,
165
+ runFunction: run,
166
+ });
167
+
168
+ await expect(scheduledTick()()).resolves.toBeUndefined();
169
+ expect(mockedLogger.error).toHaveBeenCalled();
170
+ });
171
+
172
+ test("a failed run does not stop later runs from firing", async () => {
173
+ const run: RunMock = succeedingRun();
174
+ run.mockImplementationOnce((): Promise<void> => {
175
+ return Promise.reject(new Error("first run blew up"));
176
+ });
177
+
178
+ await startCron({
179
+ jobName: "TestJob",
180
+ schedule: EVERY_FIVE_MINUTES,
181
+ runOnStartup: false,
182
+ runFunction: run,
183
+ });
184
+
185
+ await scheduledTick()();
186
+ await scheduledTick()();
187
+
188
+ expect(run).toHaveBeenCalledTimes(2);
189
+ });
190
+
191
+ /*
192
+ * The job's name is what makes a line in the log attributable to one of
193
+ * many scheduled jobs. Logged without it, a failure says only that
194
+ * something somewhere failed.
195
+ */
196
+ test("the job's name is in what it logs", async () => {
197
+ const run: RunMock = succeedingRun();
198
+
199
+ await startCron({
200
+ jobName: "DistinctiveJobName",
201
+ schedule: EVERY_FIVE_MINUTES,
202
+ runOnStartup: false,
203
+ runFunction: run,
204
+ });
205
+
206
+ await scheduledTick()();
207
+
208
+ const logged: string = mockedLogger.debug.mock.calls
209
+ .map((callArgs: Array<unknown>): string => {
210
+ return String(callArgs[0]);
211
+ })
212
+ .join("\n");
213
+
214
+ expect(logged).toContain("DistinctiveJobName");
215
+ });
216
+ });
217
+
218
+ describe("the startup run", () => {
219
+ test("runs the job immediately when asked to", async () => {
220
+ const run: RunMock = succeedingRun();
221
+
222
+ await startCron({
223
+ jobName: "TestJob",
224
+ schedule: EVERY_FIVE_MINUTES,
225
+ runOnStartup: true,
226
+ runFunction: run,
227
+ });
228
+
229
+ expect(run).toHaveBeenCalledTimes(1);
230
+ });
231
+
232
+ /*
233
+ * The startup run is in addition to the schedule, not instead of it - a
234
+ * job that ran once at boot and was never registered would go quiet after
235
+ * the first tick.
236
+ */
237
+ test("it still registers the recurring schedule", async () => {
238
+ const run: RunMock = succeedingRun();
239
+
240
+ await startCron({
241
+ jobName: "TestJob",
242
+ schedule: EVERY_FIVE_MINUTES,
243
+ runOnStartup: true,
244
+ runFunction: run,
245
+ });
246
+
247
+ expect(mockedCron.schedule).toHaveBeenCalledTimes(1);
248
+
249
+ await scheduledTick()();
250
+
251
+ expect(run).toHaveBeenCalledTimes(2);
252
+ });
253
+
254
+ /*
255
+ * DELIBERATE ASYMMETRY, pinned so a later reader does not "fix" it by
256
+ * reflex: unlike a scheduled tick, a startup failure is NOT swallowed. It
257
+ * rejects out of BasicCron.
258
+ *
259
+ * That is the right way round. A scheduled run fails against a live
260
+ * system and the next tick is a genuine retry, so surviving it is what
261
+ * keeps the job alive. A startup run fails against a process that is
262
+ * still coming up - usually because configuration or a dependency is
263
+ * wrong - and a boot that swallowed that would come up looking healthy
264
+ * with a job that never worked.
265
+ *
266
+ * Note the callers' side of this: BasicCronFunction is typed as returning
267
+ * void, so a caller that does not await gets an unhandled rejection
268
+ * rather than a thrown error. Loud either way, which is the intent.
269
+ */
270
+ test("a startup failure propagates rather than being swallowed", async () => {
271
+ const run: RunMock = failingRun("bad configuration at boot");
272
+
273
+ await expect(
274
+ startCron({
275
+ jobName: "TestJob",
276
+ schedule: EVERY_FIVE_MINUTES,
277
+ runOnStartup: true,
278
+ runFunction: run,
279
+ }),
280
+ ).rejects.toThrow("bad configuration at boot");
281
+ });
282
+ });
283
+ });