@oneuptime/common 11.7.1 → 11.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/Models/DatabaseModels/DashboardDomain.ts +1 -0
  2. package/Models/DatabaseModels/Incident.ts +2 -0
  3. package/Models/DatabaseModels/IncidentEpisode.ts +1 -0
  4. package/Models/DatabaseModels/IncidentEpisodePublicNote.ts +1 -0
  5. package/Models/DatabaseModels/IncidentEpisodeStateTimeline.ts +1 -0
  6. package/Models/DatabaseModels/IncidentPublicNote.ts +1 -0
  7. package/Models/DatabaseModels/IncidentStateTimeline.ts +1 -0
  8. package/Models/DatabaseModels/Monitor.ts +1 -0
  9. package/Models/DatabaseModels/MonitorStatusTimeline.ts +1 -0
  10. package/Models/DatabaseModels/NetworkSite.ts +1 -0
  11. package/Models/DatabaseModels/NetworkSiteStatusTimeline.ts +1 -0
  12. package/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.ts +1 -0
  13. package/Models/DatabaseModels/OnCallDutyPolicySchedule.ts +2 -0
  14. package/Models/DatabaseModels/Probe.ts +13 -13
  15. package/Models/DatabaseModels/ScheduledMaintenance.ts +1 -0
  16. package/Models/DatabaseModels/ScheduledMaintenancePublicNote.ts +1 -0
  17. package/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.ts +1 -0
  18. package/Models/DatabaseModels/ScheduledMaintenanceTemplate.ts +1 -0
  19. package/Models/DatabaseModels/StatusPage.ts +1 -0
  20. package/Models/DatabaseModels/StatusPageAnnouncement.ts +1 -0
  21. package/Models/DatabaseModels/StatusPageDomain.ts +1 -0
  22. package/Models/DatabaseModels/WorkspaceNotificationSummary.ts +1 -0
  23. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +11 -0
  24. package/Server/API/BaseAPI.ts +17 -1
  25. package/Server/API/CommonAPI.ts +32 -0
  26. package/Server/API/MicrosoftTeamsAPI.ts +99 -2
  27. package/Server/API/ProbeAPI.ts +2 -0
  28. package/Server/API/SlackAPI.ts +62 -0
  29. package/Server/API/StatusPageAPI.ts +157 -192
  30. package/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.ts +89 -0
  31. package/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.ts +61 -0
  32. package/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.ts +280 -0
  33. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  34. package/Server/Services/DatabaseService.ts +11 -3
  35. package/Server/Services/MonitorGroupService.ts +145 -0
  36. package/Server/Services/MonitorService.ts +231 -30
  37. package/Server/Services/ProbeService.ts +319 -16
  38. package/Server/Services/StatusPageService.ts +72 -42
  39. package/Server/Services/WorkspaceNotificationRuleService.ts +232 -2
  40. package/Server/Utils/Database/PostgresErrorTranslator.ts +131 -0
  41. package/Server/Utils/Monitor/MonitorResource.ts +88 -35
  42. package/Server/Utils/Monitor/MonitorStatusTimeline.ts +21 -1
  43. package/Server/Utils/Monitor/MonitorStepsProjectValidator.ts +158 -0
  44. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +706 -6
  45. package/Tests/Models/DatabaseModels/ProbeColumnAccessControl.test.ts +103 -0
  46. package/Tests/Server/API/BaseAPIUpdateNoOp.test.ts +135 -0
  47. package/Tests/Server/API/CommonAPIAuthGuard.test.ts +317 -0
  48. package/Tests/Server/API/MicrosoftTeamsManifest.test.ts +228 -0
  49. package/Tests/Server/API/ProbeAPI.test.ts +6 -0
  50. package/Tests/Server/API/ZZVerifyChatsAuth.test.ts +167 -0
  51. package/Tests/Server/Services/AddHotQueryIndexesMigration.test.ts +255 -0
  52. package/Tests/Server/Services/AddHotQueryIndexesSecondPassMigration.test.ts +258 -0
  53. package/Tests/Server/Services/DatabaseServiceSanitizeUpdateData.test.ts +1 -1
  54. package/Tests/Server/Services/DiscoveryScanClaimHookFreeSafety.test.ts +121 -0
  55. package/Tests/Server/Services/HeartbeatWriteFastPathSafety.test.ts +200 -0
  56. package/Tests/Server/Services/HookFreeWriteSafetySecondPass.test.ts +181 -0
  57. package/Tests/Server/Services/MonitorGroupServiceBatchedStatuses.test.ts +513 -0
  58. package/Tests/Server/Services/MonitorResourceProbeAgreementReuse.test.ts +492 -0
  59. package/Tests/Server/Services/MonitorServiceProbeSelection.test.ts +401 -0
  60. package/Tests/Server/Services/MonitorStatusTimelineFastPath.test.ts +453 -0
  61. package/Tests/Server/Services/ProbeAuthKeyCacheAndHeartbeat.test.ts +679 -0
  62. package/Tests/Server/Services/ServiceLevelObjectiveService.test.ts +3 -3
  63. package/Tests/Server/Services/StatusPageDomainResolutionCache.test.ts +245 -0
  64. package/Tests/Server/Services/StatusPageOverviewTimelineWindow.test.ts +283 -0
  65. package/Tests/Server/Services/StatusPageServiceMcp.test.ts +17 -1
  66. package/Tests/Server/Services/StatusPageServiceMonitorGroupBatching.test.ts +331 -0
  67. package/Tests/Server/Services/UserOnCallLogClaimNotificationRule.test.ts +12 -12
  68. package/Tests/Server/Services/WorkspaceNotificationRuleChats.test.ts +1390 -0
  69. package/Tests/Server/Utils/AI/Insights/InsightFixRouting.test.ts +4 -4
  70. package/Tests/Server/Utils/AI/Insights/InsightScanner.test.ts +2 -2
  71. package/Tests/Server/Utils/AI/Insights/InsightStore.test.ts +1 -3
  72. package/Tests/Server/Utils/AI/Insights/InsightStoreHardening.test.ts +1 -3
  73. package/Tests/Server/Utils/AI/Insights/InsightTriage.test.ts +1 -1
  74. package/Tests/Server/Utils/AI/Insights/InsightTriageRunner.test.ts +5 -5
  75. package/Tests/Server/Utils/Alert/AlertPrivacyFilter.test.ts +187 -0
  76. package/Tests/Server/Utils/AlertEpisode/AlertEpisodePrivacyFilter.test.ts +170 -0
  77. package/Tests/Server/Utils/Database/PostgresErrorTranslator.test.ts +125 -0
  78. package/Tests/Server/Utils/Incident/IncidentPrivacyFilter.test.ts +264 -0
  79. package/Tests/Server/Utils/IncidentEpisode/IncidentEpisodePrivacyFilter.test.ts +175 -0
  80. package/Tests/Server/Utils/Monitor/MonitorStepsProjectValidator.test.ts +181 -0
  81. package/Tests/Server/Utils/Monitor/NetworkDeviceWalkUtil.test.ts +1 -1
  82. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +2 -2
  83. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChannelsList.test.ts +341 -0
  84. package/Tests/Server/Utils/Workspace/MicrosoftTeamsChats.test.ts +2175 -0
  85. package/Tests/Server/Utils/Workspace/SlackChannelsList.test.ts +585 -0
  86. package/Tests/Types/Billing/MeteredPlan.test.ts +46 -0
  87. package/Tests/Types/Workspace/NotificationRules/NotificationRuleCondition.test.ts +219 -1
  88. package/Tests/UI/Components/BulkUpdateForm.test.tsx +174 -0
  89. package/Tests/UI/Components/CardModelDetailEdit.test.tsx +315 -0
  90. package/Tests/UI/Components/ModelDetailSelect.test.tsx +229 -0
  91. package/Tests/UI/Components/ModelTable/BaseModelTableBulkSelectAll.test.tsx +1258 -0
  92. package/Tests/UI/Components/ModelTableExportFromColumns.test.ts +153 -0
  93. package/Tests/UI/Components/MonacoLoader.test.ts +67 -0
  94. package/Tests/UI/Components/MonacoRuntime.test.ts +121 -0
  95. package/Tests/UI/Components/TableBulkCsvExport.test.tsx +212 -3
  96. package/Tests/UI/Utils/Project.test.ts +809 -0
  97. package/Tests/UI/Utils/TableColumnsToCsv.test.ts +360 -0
  98. package/Tests/Utils/Alerts/AlertMetricType.test.ts +143 -0
  99. package/Tests/Utils/Dashboard/VariableUrlState.test.ts +219 -0
  100. package/Tests/Utils/Incident/IncidentMetricType.test.ts +174 -0
  101. package/Tests/Utils/Monitor/MonitorProbeSelectionUtil.test.ts +145 -0
  102. package/Tests/Utils/StatusPage/ResourceUptime.test.ts +263 -0
  103. package/Types/Workspace/NotificationRules/BaseNotificationRule.ts +4 -0
  104. package/Types/Workspace/NotificationRules/NotificationRuleCondition.ts +25 -10
  105. package/Types/Workspace/WorkspaceMessagePayload.ts +1 -0
  106. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +63 -8
  107. package/UI/Components/CodeEditor/CodeEditor.tsx +3 -0
  108. package/UI/Components/CodeEditor/MonacoLoader.ts +36 -0
  109. package/UI/Components/Forms/ModelForm.tsx +41 -18
  110. package/UI/Components/ModelDetail/CardModelDetail.tsx +23 -1
  111. package/UI/Components/ModelDetail/ModelDetail.tsx +46 -1
  112. package/UI/Components/ModelTable/BaseModelTable.tsx +300 -30
  113. package/UI/Components/ModelTable/Column.ts +12 -0
  114. package/UI/Components/ModelTable/ExportFromColumns.ts +57 -0
  115. package/UI/Components/Navbar/NavBar.tsx +66 -1
  116. package/UI/Components/Table/Table.tsx +52 -13
  117. package/UI/Components/Table/Types/Column.ts +18 -0
  118. package/UI/Utils/Project.ts +214 -18
  119. package/UI/Utils/TableColumnsToCsv.ts +86 -6
  120. package/UI/esbuild-config.js +34 -0
  121. package/Utils/Monitor/MonitorProbeSelectionUtil.ts +72 -0
  122. package/build/dist/Models/DatabaseModels/DashboardDomain.js +1 -0
  123. package/build/dist/Models/DatabaseModels/DashboardDomain.js.map +1 -1
  124. package/build/dist/Models/DatabaseModels/Incident.js +2 -0
  125. package/build/dist/Models/DatabaseModels/Incident.js.map +1 -1
  126. package/build/dist/Models/DatabaseModels/IncidentEpisode.js +1 -0
  127. package/build/dist/Models/DatabaseModels/IncidentEpisode.js.map +1 -1
  128. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js +1 -0
  129. package/build/dist/Models/DatabaseModels/IncidentEpisodePublicNote.js.map +1 -1
  130. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js +1 -0
  131. package/build/dist/Models/DatabaseModels/IncidentEpisodeStateTimeline.js.map +1 -1
  132. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js +1 -0
  133. package/build/dist/Models/DatabaseModels/IncidentPublicNote.js.map +1 -1
  134. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js +1 -0
  135. package/build/dist/Models/DatabaseModels/IncidentStateTimeline.js.map +1 -1
  136. package/build/dist/Models/DatabaseModels/Monitor.js +1 -0
  137. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  138. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js +1 -0
  139. package/build/dist/Models/DatabaseModels/MonitorStatusTimeline.js.map +1 -1
  140. package/build/dist/Models/DatabaseModels/NetworkSite.js +1 -0
  141. package/build/dist/Models/DatabaseModels/NetworkSite.js.map +1 -1
  142. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js +1 -0
  143. package/build/dist/Models/DatabaseModels/NetworkSiteStatusTimeline.js.map +1 -1
  144. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js +1 -0
  145. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyExecutionLog.js.map +1 -1
  146. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js +2 -0
  147. package/build/dist/Models/DatabaseModels/OnCallDutyPolicySchedule.js.map +1 -1
  148. package/build/dist/Models/DatabaseModels/Probe.js +13 -13
  149. package/build/dist/Models/DatabaseModels/Probe.js.map +1 -1
  150. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js +1 -0
  151. package/build/dist/Models/DatabaseModels/ScheduledMaintenance.js.map +1 -1
  152. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js +1 -0
  153. package/build/dist/Models/DatabaseModels/ScheduledMaintenancePublicNote.js.map +1 -1
  154. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js +1 -0
  155. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceStateTimeline.js.map +1 -1
  156. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js +1 -0
  157. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceTemplate.js.map +1 -1
  158. package/build/dist/Models/DatabaseModels/StatusPage.js +1 -0
  159. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  160. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js +1 -0
  161. package/build/dist/Models/DatabaseModels/StatusPageAnnouncement.js.map +1 -1
  162. package/build/dist/Models/DatabaseModels/StatusPageDomain.js +1 -0
  163. package/build/dist/Models/DatabaseModels/StatusPageDomain.js.map +1 -1
  164. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js +1 -0
  165. package/build/dist/Models/DatabaseModels/WorkspaceNotificationSummary.js.map +1 -1
  166. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  167. package/build/dist/Server/API/BaseAPI.js +12 -1
  168. package/build/dist/Server/API/BaseAPI.js.map +1 -1
  169. package/build/dist/Server/API/CommonAPI.js +21 -0
  170. package/build/dist/Server/API/CommonAPI.js.map +1 -1
  171. package/build/dist/Server/API/MicrosoftTeamsAPI.js +72 -2
  172. package/build/dist/Server/API/MicrosoftTeamsAPI.js.map +1 -1
  173. package/build/dist/Server/API/ProbeAPI.js +2 -0
  174. package/build/dist/Server/API/ProbeAPI.js.map +1 -1
  175. package/build/dist/Server/API/SlackAPI.js +44 -0
  176. package/build/dist/Server/API/SlackAPI.js.map +1 -1
  177. package/build/dist/Server/API/StatusPageAPI.js +98 -152
  178. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  179. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js +36 -0
  180. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785140242697-AddHotQueryIndexes.js.map +1 -0
  181. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js +26 -0
  182. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785148065137-AddHotQueryIndexesSecondPass.js.map +1 -0
  183. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js +254 -0
  184. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1785240000000-RepairCrossProjectMonitorStatusReferences.js.map +1 -0
  185. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  186. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  187. package/build/dist/Server/Services/DatabaseService.js +10 -2
  188. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  189. package/build/dist/Server/Services/MonitorGroupService.js +118 -0
  190. package/build/dist/Server/Services/MonitorGroupService.js.map +1 -1
  191. package/build/dist/Server/Services/MonitorService.js +198 -24
  192. package/build/dist/Server/Services/MonitorService.js.map +1 -1
  193. package/build/dist/Server/Services/ProbeService.js +238 -9
  194. package/build/dist/Server/Services/ProbeService.js.map +1 -1
  195. package/build/dist/Server/Services/StatusPageService.js +46 -39
  196. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  197. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js +178 -3
  198. package/build/dist/Server/Services/WorkspaceNotificationRuleService.js.map +1 -1
  199. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js +80 -0
  200. package/build/dist/Server/Utils/Database/PostgresErrorTranslator.js.map +1 -0
  201. package/build/dist/Server/Utils/Monitor/MonitorResource.js +67 -29
  202. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  203. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js +31 -14
  204. package/build/dist/Server/Utils/Monitor/MonitorStatusTimeline.js.map +1 -1
  205. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js +117 -0
  206. package/build/dist/Server/Utils/Monitor/MonitorStepsProjectValidator.js.map +1 -0
  207. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +549 -7
  208. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  209. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js +17 -7
  210. package/build/dist/Types/Workspace/NotificationRules/NotificationRuleCondition.js.map +1 -1
  211. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +37 -11
  212. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  213. package/build/dist/UI/Components/CodeEditor/CodeEditor.js +2 -0
  214. package/build/dist/UI/Components/CodeEditor/CodeEditor.js.map +1 -1
  215. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js +34 -0
  216. package/build/dist/UI/Components/CodeEditor/MonacoLoader.js.map +1 -0
  217. package/build/dist/UI/Components/Forms/ModelForm.js +37 -17
  218. package/build/dist/UI/Components/Forms/ModelForm.js.map +1 -1
  219. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js +22 -1
  220. package/build/dist/UI/Components/ModelDetail/CardModelDetail.js.map +1 -1
  221. package/build/dist/UI/Components/ModelDetail/ModelDetail.js +31 -1
  222. package/build/dist/UI/Components/ModelDetail/ModelDetail.js.map +1 -1
  223. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +234 -21
  224. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  225. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js +30 -0
  226. package/build/dist/UI/Components/ModelTable/ExportFromColumns.js.map +1 -0
  227. package/build/dist/UI/Components/Navbar/NavBar.js +39 -1
  228. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  229. package/build/dist/UI/Components/Table/Table.js +31 -11
  230. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  231. package/build/dist/UI/Utils/Project.js +161 -15
  232. package/build/dist/UI/Utils/Project.js.map +1 -1
  233. package/build/dist/UI/Utils/TableColumnsToCsv.js +67 -6
  234. package/build/dist/UI/Utils/TableColumnsToCsv.js.map +1 -1
  235. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js +52 -0
  236. package/build/dist/Utils/Monitor/MonitorProbeSelectionUtil.js.map +1 -0
  237. package/package.json +4 -3
@@ -0,0 +1,181 @@
1
+ import SmsLogService, {
2
+ Service as SmsLogServiceClass,
3
+ } from "../../../Server/Services/SmsLogService";
4
+ import WorkflowLogService, {
5
+ Service as WorkflowLogServiceClass,
6
+ } from "../../../Server/Services/WorkflowLogService";
7
+ import DatabaseService from "../../../Server/Services/DatabaseService";
8
+ import SmsLog from "../../../Models/DatabaseModels/SmsLog";
9
+ import WorkflowLog from "../../../Models/DatabaseModels/WorkflowLog";
10
+ import { describe, expect, test } from "@jest/globals";
11
+
12
+ /*
13
+ * Second-pass hookless-write conversions: SmsLog and WorkflowLog status
14
+ * stamps were moved off the full updateOneById pipeline onto
15
+ * DatabaseService.updateColumnsByIdWithoutHooks (one raw parameterized
16
+ * UPDATE; skips ALL on-update hooks — workflow HTTP triggers, audit-log
17
+ * inserts, realtime events, service onBeforeUpdate/onUpdateSuccess):
18
+ *
19
+ * - App/FeatureSet/Notification/Services/SmsService.ts
20
+ * SmsLog.status/statusMessage/smsCostInUSDCents/errorCode after send
21
+ * - App/FeatureSet/Notification/API/SMS.ts
22
+ * SmsLog.status/statusMessage/errorCode on delivery callbacks
23
+ * - App/FeatureSet/Workflow/Services/RunWorkflow.ts
24
+ * WorkflowLog.workflowStatus/logs/started-completed-resume stamps
25
+ * - App/FeatureSet/Workers/Jobs/Workflow/TimeoutJobs.ts
26
+ * WorkflowLog.workflowStatus/completedAt/logs on stalled runs
27
+ *
28
+ * These conversions dropped NOTHING: neither model has update workflows,
29
+ * audit logging, or realtime events, and neither service overrides the
30
+ * update hooks — so the old pipeline's hook stages were inert for these
31
+ * writes. But the fast path skips hooks UNCONDITIONALLY: if someone later
32
+ * adds a decorator to SmsLog/WorkflowLog or an onUpdateSuccess override to
33
+ * their services, nothing at the call sites fails — the new hook is just
34
+ * silently never fired for these status stamps. This suite turns that
35
+ * silent drift into a loud test failure: when an assertion here starts
36
+ * failing, revisit the fast-path call sites above before changing it.
37
+ *
38
+ * Pure model-metadata + class-shape tests — no Postgres, no Redis.
39
+ */
40
+
41
+ describe("second-pass hookless write safety preconditions", () => {
42
+ describe("SmsLog model (status stamps in SmsService.ts / SMS.ts)", () => {
43
+ /*
44
+ * SmsLog DOES declare @EnableWorkflow — but with update: false. The
45
+ * distinguishing shape (metadata present, update explicitly off) is
46
+ * pinned exactly so that flipping update to true is caught even though
47
+ * `enableWorkflowOn?.update` would also be falsy on a model with no
48
+ * decorator at all.
49
+ */
50
+ test("declares @EnableWorkflow with update explicitly disabled", () => {
51
+ const smsLog: SmsLog = new SmsLog();
52
+ expect(smsLog.enableWorkflowOn).toBeDefined();
53
+ expect(smsLog.enableWorkflowOn?.create).toBe(true);
54
+ expect(smsLog.enableWorkflowOn?.update).toBe(false);
55
+ });
56
+
57
+ test("has no on-update audit log", () => {
58
+ const smsLog: SmsLog = new SmsLog();
59
+ expect(smsLog.enableAuditLogOn?.update).toBeFalsy();
60
+ });
61
+
62
+ test("has no realtime events", () => {
63
+ const smsLog: SmsLog = new SmsLog();
64
+ expect(smsLog.enableRealtimeEventsOn).toBeFalsy();
65
+ });
66
+ });
67
+
68
+ describe("WorkflowLog model (status stamps in RunWorkflow.ts / TimeoutJobs.ts)", () => {
69
+ /*
70
+ * WorkflowLog has no @EnableWorkflow decorator AT ALL — the accessor is
71
+ * entirely unset, which is the other shape that resolves to "no update
72
+ * workflow". If this becomes defined, someone added @EnableWorkflow to
73
+ * WorkflowLog and must decide whether the fast-path stamps should keep
74
+ * skipping it.
75
+ */
76
+ test("has no @EnableWorkflow metadata at all", () => {
77
+ const workflowLog: WorkflowLog = new WorkflowLog();
78
+ expect(workflowLog.enableWorkflowOn).toBeFalsy();
79
+ expect(workflowLog.enableWorkflowOn?.update).toBeFalsy();
80
+ });
81
+
82
+ test("has no on-update audit log", () => {
83
+ const workflowLog: WorkflowLog = new WorkflowLog();
84
+ expect(workflowLog.enableAuditLogOn?.update).toBeFalsy();
85
+ });
86
+
87
+ test("has no realtime events", () => {
88
+ const workflowLog: WorkflowLog = new WorkflowLog();
89
+ expect(workflowLog.enableRealtimeEventsOn).toBeFalsy();
90
+ });
91
+ });
92
+
93
+ describe("fast-path columns exist on their entities", () => {
94
+ /*
95
+ * updateColumnsByIdWithoutHooks validates column names against entity
96
+ * metadata at runtime and throws BadDataException on an unknown column.
97
+ * Pinning column existence here means a rename breaks this suite at CI
98
+ * time instead of breaking every SMS/workflow status stamp at runtime.
99
+ */
100
+ test("SmsLog has every column the SMS status stamps write", () => {
101
+ const smsLog: SmsLog = new SmsLog();
102
+ const fastPathColumns: Array<string> = [
103
+ "status",
104
+ "statusMessage",
105
+ "smsCostInUSDCents",
106
+ "errorCode",
107
+ ];
108
+ for (const column of fastPathColumns) {
109
+ expect(smsLog.isTableColumn(column)).toBe(true);
110
+ }
111
+ // Negative control: isTableColumn actually discriminates.
112
+ expect(smsLog.isTableColumn("notARealColumn")).toBe(false);
113
+ });
114
+
115
+ test("WorkflowLog has every column the workflow status stamps write", () => {
116
+ const workflowLog: WorkflowLog = new WorkflowLog();
117
+ const fastPathColumns: Array<string> = [
118
+ "workflowStatus",
119
+ "logs",
120
+ "startedAt",
121
+ "completedAt",
122
+ "resumeAt",
123
+ "resumeData",
124
+ ];
125
+ for (const column of fastPathColumns) {
126
+ expect(workflowLog.isTableColumn(column)).toBe(true);
127
+ }
128
+ expect(workflowLog.isTableColumn("notARealColumn")).toBe(false);
129
+ });
130
+ });
131
+
132
+ describe("services define no update hooks of their own", () => {
133
+ /*
134
+ * DatabaseService's base onBeforeUpdate/onUpdateSuccess are no-op
135
+ * pass-throughs; a service only gets update behavior by OVERRIDING them.
136
+ * Own-property checks on the concrete service prototypes pin that
137
+ * neither SmsLogService nor WorkflowLogService does — so the fast path
138
+ * skips nothing. If an override appears, these fail loudly and the
139
+ * call sites above must be re-evaluated.
140
+ */
141
+ const updateHooks: Array<string> = ["onBeforeUpdate", "onUpdateSuccess"];
142
+
143
+ test("SmsLogService does not override onBeforeUpdate/onUpdateSuccess", () => {
144
+ for (const hook of updateHooks) {
145
+ expect(
146
+ Object.prototype.hasOwnProperty.call(
147
+ SmsLogServiceClass.prototype,
148
+ hook,
149
+ ),
150
+ ).toBe(false);
151
+ }
152
+ // The default export is an instance of the class checked above.
153
+ expect(SmsLogService).toBeInstanceOf(SmsLogServiceClass);
154
+ });
155
+
156
+ test("WorkflowLogService does not override onBeforeUpdate/onUpdateSuccess", () => {
157
+ for (const hook of updateHooks) {
158
+ expect(
159
+ Object.prototype.hasOwnProperty.call(
160
+ WorkflowLogServiceClass.prototype,
161
+ hook,
162
+ ),
163
+ ).toBe(false);
164
+ }
165
+ expect(WorkflowLogService).toBeInstanceOf(WorkflowLogServiceClass);
166
+ });
167
+
168
+ /*
169
+ * Positive control: the hooks DO exist on the DatabaseService base
170
+ * prototype, so the own-property checks above cannot pass vacuously
171
+ * (e.g. after a rename of the hook methods themselves).
172
+ */
173
+ test("the base DatabaseService prototype defines both hooks", () => {
174
+ for (const hook of updateHooks) {
175
+ expect(
176
+ Object.prototype.hasOwnProperty.call(DatabaseService.prototype, hook),
177
+ ).toBe(true);
178
+ }
179
+ });
180
+ });
181
+ });
@@ -0,0 +1,513 @@
1
+ /*
2
+ * MonitorGroupService batched status helpers.
3
+ *
4
+ * The public status-page read paths used to issue one MonitorGroupResource
5
+ * query per monitor group per page view (plus a MonitorGroup and a
6
+ * MonitorStatus fetch each in getCurrentStatus) — an N+1 of 3-4 queries per
7
+ * group per request. getMonitorGroupResourcesByGroupIds /
8
+ * getMonitorIdsInMonitorGroups / getCurrentStatusesForMonitorGroups collapse
9
+ * that to at most ONE batched query. These tests pin:
10
+ * - exactly one findAllBy regardless of group count, keyed by
11
+ * QueryHelper.any over DEDUPED ids, with an entry ([] if empty) per
12
+ * requested group and zero queries for empty input,
13
+ * - getCurrentStatusesForMonitorGroups preserving getCurrentStatus's EXACT
14
+ * semantics (operational default, HIGHER priority number wins, throws
15
+ * when no operational state exists), including a direct equivalence check
16
+ * against the single-group path so the two cannot silently drift, and
17
+ * - no resource fetch at all when the caller already supplies the
18
+ * group-resource dictionary.
19
+ *
20
+ * All service reads are spied on — no database is touched.
21
+ */
22
+
23
+ import MonitorGroupService from "../../../Server/Services/MonitorGroupService";
24
+ import MonitorGroupResourceService from "../../../Server/Services/MonitorGroupResourceService";
25
+ import MonitorStatusService from "../../../Server/Services/MonitorStatusService";
26
+ import QueryHelper from "../../../Server/Types/Database/QueryHelper";
27
+ import MonitorGroup from "../../../Models/DatabaseModels/MonitorGroup";
28
+ import MonitorGroupResource from "../../../Models/DatabaseModels/MonitorGroupResource";
29
+ import MonitorStatus from "../../../Models/DatabaseModels/MonitorStatus";
30
+ import Monitor from "../../../Models/DatabaseModels/Monitor";
31
+ import Dictionary from "../../../Types/Dictionary";
32
+ import ObjectID from "../../../Types/ObjectID";
33
+ import BadDataException from "../../../Types/Exception/BadDataException";
34
+ import { afterEach, describe, expect, jest, test } from "@jest/globals";
35
+
36
+ function makeStatus(data: {
37
+ priority: number;
38
+ isOperationalState?: boolean;
39
+ name?: string;
40
+ }): MonitorStatus {
41
+ const status: MonitorStatus = new MonitorStatus();
42
+ status.id = ObjectID.generate();
43
+ status.priority = data.priority;
44
+ status.isOperationalState = data.isOperationalState || false;
45
+ status.name = data.name || `status-p${data.priority}`;
46
+ return status;
47
+ }
48
+
49
+ function makeGroupResource(data: {
50
+ monitorGroupId?: ObjectID | undefined;
51
+ monitorId?: ObjectID | undefined;
52
+ currentMonitorStatusId?: ObjectID | undefined;
53
+ omitMonitor?: boolean;
54
+ }): MonitorGroupResource {
55
+ const resource: MonitorGroupResource = new MonitorGroupResource();
56
+ if (data.monitorGroupId) {
57
+ resource.monitorGroupId = data.monitorGroupId;
58
+ }
59
+ if (data.monitorId) {
60
+ resource.monitorId = data.monitorId;
61
+ }
62
+ if (!data.omitMonitor) {
63
+ const monitor: Monitor = new Monitor();
64
+ if (data.currentMonitorStatusId) {
65
+ monitor.currentMonitorStatusId = data.currentMonitorStatusId;
66
+ }
67
+ resource.monitor = monitor;
68
+ }
69
+ return resource;
70
+ }
71
+
72
+ function spyOnFindAllBy(rows: Array<MonitorGroupResource>): jest.SpyInstance {
73
+ return (
74
+ jest.spyOn(
75
+ MonitorGroupResourceService,
76
+ "findAllBy",
77
+ ) as unknown as jest.SpyInstance
78
+ ).mockResolvedValue(rows as never);
79
+ }
80
+
81
+ function toStrings(ids: Array<ObjectID>): Array<string> {
82
+ return ids.map((id: ObjectID) => {
83
+ return id.toString();
84
+ });
85
+ }
86
+
87
+ describe("MonitorGroupService batched status helpers", () => {
88
+ afterEach(() => {
89
+ jest.restoreAllMocks();
90
+ });
91
+
92
+ describe("getMonitorGroupResourcesByGroupIds", () => {
93
+ test("issues exactly one findAllBy for N groups, keyed by QueryHelper.any over deduped ids", async () => {
94
+ const groupA: ObjectID = ObjectID.generate();
95
+ const groupB: ObjectID = ObjectID.generate();
96
+ const groupC: ObjectID = ObjectID.generate();
97
+
98
+ const rowA1: MonitorGroupResource = makeGroupResource({
99
+ monitorGroupId: groupA,
100
+ monitorId: ObjectID.generate(),
101
+ });
102
+ const rowA2: MonitorGroupResource = makeGroupResource({
103
+ monitorGroupId: groupA,
104
+ monitorId: ObjectID.generate(),
105
+ });
106
+ const rowB1: MonitorGroupResource = makeGroupResource({
107
+ monitorGroupId: groupB,
108
+ monitorId: ObjectID.generate(),
109
+ });
110
+ // Defensive-guard row: a row without monitorGroupId must be dropped, not crash.
111
+ const rowNoGroup: MonitorGroupResource = makeGroupResource({
112
+ monitorId: ObjectID.generate(),
113
+ });
114
+
115
+ const findAllBySpy: jest.SpyInstance = spyOnFindAllBy([
116
+ rowA1,
117
+ rowB1,
118
+ rowA2,
119
+ rowNoGroup,
120
+ ]);
121
+ const anySpy: jest.SpyInstance = jest.spyOn(
122
+ QueryHelper,
123
+ "any",
124
+ ) as unknown as jest.SpyInstance;
125
+
126
+ const result: Dictionary<Array<MonitorGroupResource>> =
127
+ await MonitorGroupService.getMonitorGroupResourcesByGroupIds([
128
+ groupA,
129
+ groupB,
130
+ groupA, // duplicate must collapse
131
+ groupC,
132
+ groupB, // duplicate must collapse
133
+ ]);
134
+
135
+ // The whole point of the batching: ONE query for all groups.
136
+ expect(findAllBySpy).toHaveBeenCalledTimes(1);
137
+
138
+ // The IN-list is built via QueryHelper.any over DEDUPED ids, in first-seen order.
139
+ expect(anySpy).toHaveBeenCalledTimes(1);
140
+ expect(toStrings(anySpy.mock.calls[0]![0] as Array<ObjectID>)).toEqual(
141
+ toStrings([groupA, groupB, groupC]),
142
+ );
143
+
144
+ const callArg: any = findAllBySpy.mock.calls[0]![0];
145
+ // The exact FindOperator QueryHelper.any built is what findAllBy receives.
146
+ expect(callArg.query.monitorGroupId).toBe(anySpy.mock.results[0]!.value);
147
+ // The select must carry the grouping key and the member's current status.
148
+ expect(callArg.select).toEqual({
149
+ monitorGroupId: true,
150
+ monitorId: true,
151
+ monitor: {
152
+ currentMonitorStatusId: true,
153
+ },
154
+ });
155
+ expect(callArg.props).toEqual({ isRoot: true });
156
+
157
+ // Every requested group gets an entry; a group with no rows gets [].
158
+ expect(Object.keys(result).sort()).toEqual(
159
+ [groupA.toString(), groupB.toString(), groupC.toString()].sort(),
160
+ );
161
+ expect(result[groupA.toString()]).toEqual([rowA1, rowA2]);
162
+ expect(result[groupB.toString()]).toEqual([rowB1]);
163
+ expect(result[groupC.toString()]).toEqual([]);
164
+ });
165
+
166
+ test("empty input returns {} without querying at all", async () => {
167
+ const findAllBySpy: jest.SpyInstance = spyOnFindAllBy([]);
168
+
169
+ const result: Dictionary<Array<MonitorGroupResource>> =
170
+ await MonitorGroupService.getMonitorGroupResourcesByGroupIds([]);
171
+
172
+ expect(result).toEqual({});
173
+ expect(findAllBySpy).not.toHaveBeenCalled();
174
+ });
175
+ });
176
+
177
+ describe("getMonitorIdsInMonitorGroups", () => {
178
+ test("maps rows to monitor ids, drops null monitorIds, keeps empty-group keys", async () => {
179
+ const groupA: ObjectID = ObjectID.generate();
180
+ const groupB: ObjectID = ObjectID.generate();
181
+ const monitor1: ObjectID = ObjectID.generate();
182
+ const monitor2: ObjectID = ObjectID.generate();
183
+
184
+ spyOnFindAllBy([
185
+ makeGroupResource({ monitorGroupId: groupA, monitorId: monitor1 }),
186
+ // A resource row with no monitorId must not surface as a null id.
187
+ makeGroupResource({ monitorGroupId: groupA }),
188
+ makeGroupResource({ monitorGroupId: groupA, monitorId: monitor2 }),
189
+ ]);
190
+
191
+ const result: Dictionary<Array<ObjectID>> =
192
+ await MonitorGroupService.getMonitorIdsInMonitorGroups([
193
+ groupA,
194
+ groupB,
195
+ ]);
196
+
197
+ expect(Object.keys(result).sort()).toEqual(
198
+ [groupA.toString(), groupB.toString()].sort(),
199
+ );
200
+ expect(toStrings(result[groupA.toString()]!)).toEqual(
201
+ toStrings([monitor1, monitor2]),
202
+ );
203
+ // The empty group still gets an (empty) entry — callers key off it.
204
+ expect(result[groupB.toString()]).toEqual([]);
205
+ });
206
+ });
207
+
208
+ describe("getCurrentStatusesForMonitorGroups", () => {
209
+ const operational: MonitorStatus = makeStatus({
210
+ priority: 2,
211
+ isOperationalState: true,
212
+ name: "Operational",
213
+ });
214
+ // Lower priority NUMBER than operational: must never replace it here.
215
+ const lowerThanOperational: MonitorStatus = makeStatus({
216
+ priority: 1,
217
+ name: "Maintenance",
218
+ });
219
+ // Same priority number as operational: a tie must not replace either.
220
+ const tiedWithOperational: MonitorStatus = makeStatus({
221
+ priority: 2,
222
+ name: "Degraded-Tie",
223
+ });
224
+ const degraded: MonitorStatus = makeStatus({
225
+ priority: 3,
226
+ name: "Degraded",
227
+ });
228
+ const offline: MonitorStatus = makeStatus({ priority: 4, name: "Offline" });
229
+ const allStatuses: Array<MonitorStatus> = [
230
+ operational,
231
+ lowerThanOperational,
232
+ tiedWithOperational,
233
+ degraded,
234
+ offline,
235
+ ];
236
+
237
+ test("empty group defaults to the operational status", async () => {
238
+ const groupA: ObjectID = ObjectID.generate();
239
+ spyOnFindAllBy([]);
240
+
241
+ const result: Dictionary<MonitorStatus> =
242
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
243
+ monitorGroupIds: [groupA],
244
+ monitorStatuses: allStatuses,
245
+ });
246
+
247
+ expect(result[groupA.toString()]).toBe(operational);
248
+ });
249
+
250
+ test("a member with a HIGHER priority number replaces the current status; ties and lower do not", async () => {
251
+ const groupHigh: ObjectID = ObjectID.generate();
252
+ const groupTie: ObjectID = ObjectID.generate();
253
+ const groupLower: ObjectID = ObjectID.generate();
254
+
255
+ spyOnFindAllBy([
256
+ makeGroupResource({
257
+ monitorGroupId: groupHigh,
258
+ monitorId: ObjectID.generate(),
259
+ currentMonitorStatusId: degraded.id!,
260
+ }),
261
+ makeGroupResource({
262
+ monitorGroupId: groupHigh,
263
+ monitorId: ObjectID.generate(),
264
+ currentMonitorStatusId: offline.id!,
265
+ }),
266
+ makeGroupResource({
267
+ monitorGroupId: groupTie,
268
+ monitorId: ObjectID.generate(),
269
+ currentMonitorStatusId: tiedWithOperational.id!,
270
+ }),
271
+ makeGroupResource({
272
+ monitorGroupId: groupLower,
273
+ monitorId: ObjectID.generate(),
274
+ currentMonitorStatusId: lowerThanOperational.id!,
275
+ }),
276
+ ]);
277
+
278
+ const result: Dictionary<MonitorStatus> =
279
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
280
+ monitorGroupIds: [groupHigh, groupTie, groupLower],
281
+ monitorStatuses: allStatuses,
282
+ });
283
+
284
+ // In MonitorGroupService semantics the HIGHEST priority number wins.
285
+ expect(result[groupHigh.toString()]).toBe(offline);
286
+ // Strict `<` comparison: a tie keeps the operational default.
287
+ expect(result[groupTie.toString()]).toBe(operational);
288
+ // A lower priority number never demotes the operational default.
289
+ expect(result[groupLower.toString()]).toBe(operational);
290
+ });
291
+
292
+ test("members with unknown statuses or missing monitor relation are ignored", async () => {
293
+ const groupA: ObjectID = ObjectID.generate();
294
+
295
+ spyOnFindAllBy([
296
+ // Status id that resolves to nothing in monitorStatuses.
297
+ makeGroupResource({
298
+ monitorGroupId: groupA,
299
+ monitorId: ObjectID.generate(),
300
+ currentMonitorStatusId: ObjectID.generate(),
301
+ }),
302
+ // No monitor relation loaded at all.
303
+ makeGroupResource({
304
+ monitorGroupId: groupA,
305
+ monitorId: ObjectID.generate(),
306
+ omitMonitor: true,
307
+ }),
308
+ ]);
309
+
310
+ const result: Dictionary<MonitorStatus> =
311
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
312
+ monitorGroupIds: [groupA],
313
+ monitorStatuses: allStatuses,
314
+ });
315
+
316
+ expect(result[groupA.toString()]).toBe(operational);
317
+ });
318
+
319
+ test("throws 'Operational state not found.' when groups are requested but no operational status exists", async () => {
320
+ const findAllBySpy: jest.SpyInstance = spyOnFindAllBy([]);
321
+
322
+ await expect(
323
+ MonitorGroupService.getCurrentStatusesForMonitorGroups({
324
+ monitorGroupIds: [ObjectID.generate()],
325
+ monitorStatuses: [degraded, offline],
326
+ }),
327
+ ).rejects.toThrow(new BadDataException("Operational state not found."));
328
+
329
+ // The throw happens before any resource fetch is attempted.
330
+ expect(findAllBySpy).not.toHaveBeenCalled();
331
+ });
332
+
333
+ test("empty group ids return {} without fetching, even when no operational status exists", async () => {
334
+ const findAllBySpy: jest.SpyInstance = spyOnFindAllBy([]);
335
+ const resourcesSpy: jest.SpyInstance = jest.spyOn(
336
+ MonitorGroupService,
337
+ "getMonitorGroupResourcesByGroupIds",
338
+ ) as unknown as jest.SpyInstance;
339
+
340
+ const result: Dictionary<MonitorStatus> =
341
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
342
+ monitorGroupIds: [],
343
+ // No operational entry: the empty-input early return wins over the throw.
344
+ monitorStatuses: [degraded],
345
+ });
346
+
347
+ expect(result).toEqual({});
348
+ expect(resourcesSpy).not.toHaveBeenCalled();
349
+ expect(findAllBySpy).not.toHaveBeenCalled();
350
+ });
351
+
352
+ test("uses supplied monitorGroupResources without fetching", async () => {
353
+ const groupA: ObjectID = ObjectID.generate();
354
+ const findAllBySpy: jest.SpyInstance = spyOnFindAllBy([]);
355
+ const resourcesSpy: jest.SpyInstance = jest.spyOn(
356
+ MonitorGroupService,
357
+ "getMonitorGroupResourcesByGroupIds",
358
+ ) as unknown as jest.SpyInstance;
359
+
360
+ const supplied: Dictionary<Array<MonitorGroupResource>> = {
361
+ [groupA.toString()]: [
362
+ makeGroupResource({
363
+ monitorGroupId: groupA,
364
+ monitorId: ObjectID.generate(),
365
+ currentMonitorStatusId: offline.id!,
366
+ }),
367
+ ],
368
+ };
369
+
370
+ const result: Dictionary<MonitorStatus> =
371
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
372
+ monitorGroupIds: [groupA],
373
+ monitorStatuses: allStatuses,
374
+ monitorGroupResources: supplied,
375
+ });
376
+
377
+ expect(result[groupA.toString()]).toBe(offline);
378
+ expect(resourcesSpy).not.toHaveBeenCalled();
379
+ expect(findAllBySpy).not.toHaveBeenCalled();
380
+ });
381
+ });
382
+
383
+ describe("equivalence with the single-group getCurrentStatus path", () => {
384
+ /*
385
+ * The batched method claims to be an exact twin of getCurrentStatus. This
386
+ * runs BOTH implementations over the same fixture (3 groups: empty /
387
+ * partially degraded / offline-with-noise) and requires identical
388
+ * per-group answers, so a semantic change to either path fails here.
389
+ */
390
+ test("returns the same status per group as getCurrentStatus", async () => {
391
+ const projectId: ObjectID = ObjectID.generate();
392
+
393
+ const operational: MonitorStatus = makeStatus({
394
+ priority: 1,
395
+ isOperationalState: true,
396
+ name: "Operational",
397
+ });
398
+ const degraded: MonitorStatus = makeStatus({
399
+ priority: 2,
400
+ name: "Degraded",
401
+ });
402
+ const offline: MonitorStatus = makeStatus({
403
+ priority: 3,
404
+ name: "Offline",
405
+ });
406
+ const statuses: Array<MonitorStatus> = [operational, degraded, offline];
407
+
408
+ const groupEmpty: ObjectID = ObjectID.generate();
409
+ const groupDegraded: ObjectID = ObjectID.generate();
410
+ const groupOffline: ObjectID = ObjectID.generate();
411
+
412
+ const rowsByGroup: Dictionary<Array<MonitorGroupResource>> = {
413
+ [groupEmpty.toString()]: [],
414
+ [groupDegraded.toString()]: [
415
+ makeGroupResource({
416
+ monitorGroupId: groupDegraded,
417
+ monitorId: ObjectID.generate(),
418
+ currentMonitorStatusId: operational.id!,
419
+ }),
420
+ makeGroupResource({
421
+ monitorGroupId: groupDegraded,
422
+ monitorId: ObjectID.generate(),
423
+ currentMonitorStatusId: degraded.id!,
424
+ }),
425
+ ],
426
+ [groupOffline.toString()]: [
427
+ makeGroupResource({
428
+ monitorGroupId: groupOffline,
429
+ monitorId: ObjectID.generate(),
430
+ currentMonitorStatusId: offline.id!,
431
+ }),
432
+ makeGroupResource({
433
+ monitorGroupId: groupOffline,
434
+ monitorId: ObjectID.generate(),
435
+ currentMonitorStatusId: degraded.id!,
436
+ }),
437
+ // Noise both paths must ignore: unknown status + missing relation.
438
+ makeGroupResource({
439
+ monitorGroupId: groupOffline,
440
+ monitorId: ObjectID.generate(),
441
+ currentMonitorStatusId: ObjectID.generate(),
442
+ }),
443
+ makeGroupResource({
444
+ monitorGroupId: groupOffline,
445
+ monitorId: ObjectID.generate(),
446
+ omitMonitor: true,
447
+ }),
448
+ ],
449
+ };
450
+
451
+ // --- mocks for the single-group path (3 queries per group) ---
452
+ (
453
+ jest.spyOn(
454
+ MonitorGroupService,
455
+ "findOneById",
456
+ ) as unknown as jest.SpyInstance
457
+ ).mockImplementation((...args: Array<unknown>): Promise<MonitorGroup> => {
458
+ const findOneById: { id: ObjectID } = args[0] as { id: ObjectID };
459
+ const group: MonitorGroup = new MonitorGroup();
460
+ group.id = findOneById.id;
461
+ group.projectId = projectId;
462
+ return Promise.resolve(group);
463
+ });
464
+
465
+ (
466
+ jest.spyOn(
467
+ MonitorGroupResourceService,
468
+ "findBy",
469
+ ) as unknown as jest.SpyInstance
470
+ ).mockImplementation(
471
+ (...args: Array<unknown>): Promise<Array<MonitorGroupResource>> => {
472
+ const findBy: { query: { monitorGroupId: ObjectID } } = args[0] as {
473
+ query: { monitorGroupId: ObjectID };
474
+ };
475
+ return Promise.resolve(
476
+ rowsByGroup[findBy.query.monitorGroupId.toString()] || [],
477
+ );
478
+ },
479
+ );
480
+
481
+ (
482
+ jest.spyOn(
483
+ MonitorStatusService,
484
+ "findBy",
485
+ ) as unknown as jest.SpyInstance
486
+ ).mockResolvedValue(statuses as never);
487
+
488
+ // --- mock for the batched path (ONE query) ---
489
+ spyOnFindAllBy([
490
+ ...rowsByGroup[groupEmpty.toString()]!,
491
+ ...rowsByGroup[groupDegraded.toString()]!,
492
+ ...rowsByGroup[groupOffline.toString()]!,
493
+ ]);
494
+
495
+ const batched: Dictionary<MonitorStatus> =
496
+ await MonitorGroupService.getCurrentStatusesForMonitorGroups({
497
+ monitorGroupIds: [groupEmpty, groupDegraded, groupOffline],
498
+ monitorStatuses: statuses,
499
+ });
500
+
501
+ for (const groupId of [groupEmpty, groupDegraded, groupOffline]) {
502
+ const single: MonitorStatus =
503
+ await MonitorGroupService.getCurrentStatus(groupId, { isRoot: true });
504
+ expect(batched[groupId.toString()]).toBe(single);
505
+ }
506
+
507
+ // Anchor the shared expectation so both paths being wrong together fails too.
508
+ expect(batched[groupEmpty.toString()]).toBe(operational);
509
+ expect(batched[groupDegraded.toString()]).toBe(degraded);
510
+ expect(batched[groupOffline.toString()]).toBe(offline);
511
+ });
512
+ });
513
+ });