Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import getBottle, {
2 type Dependencies,
3 type PublicInterface,
4} from '../../iocContainer/index.js';
5import { type NotificationsService } from '../../services/notificationsService/notificationsService.js';
6import { type GetCurrentPeriodRuleAlarmStatuses } from '../../services/ruleAnomalyDetectionService/getCurrentPeriodRuleAlarmStatuses.js';
7import createOrg from '../../test/fixtureHelpers/createOrg.js';
8import createRule from '../../test/fixtureHelpers/createRule.js';
9import createUser from '../../test/fixtureHelpers/createUser.js';
10import { type Mocked } from '../../test/mockHelpers/jestMocks.js';
11import { RuleAlarmStatus } from '../moderationConfigService/index.js';
12import DetectRulePassRateAnomaliesJob from './detectRulePassRateAnomaliesJob.js';
13
14function makeMockKyselyForRules(
15 fakeRules: Array<{
16 id: string;
17 orgId: string;
18 creatorId: string;
19 name: string;
20 alarmStatus: RuleAlarmStatus;
21 statusIfUnexpired: string;
22 }>,
23 orgRows: Array<{ id: string; on_call_alert_email: string | null }>,
24) {
25 const updateExecute = jest.fn().mockResolvedValue(undefined);
26 const mockDb = {
27 selectFrom: jest.fn((table: string) => {
28 const chain: {
29 select: jest.Mock;
30 where: jest.Mock;
31 execute: jest.Mock;
32 } = {
33 select: jest.fn(),
34 where: jest.fn(),
35 execute: jest.fn(),
36 };
37 chain.select.mockReturnValue(chain);
38 chain.where.mockReturnValue(chain);
39 chain.execute.mockImplementation(async () => {
40 if (table === 'public.rules') {
41 return fakeRules.map((r) => ({
42 id: r.id,
43 org_id: r.orgId,
44 creator_id: r.creatorId,
45 name: r.name,
46 alarm_status: r.alarmStatus,
47 status_if_unexpired: r.statusIfUnexpired,
48 }));
49 }
50 if (table === 'public.orgs') {
51 return orgRows;
52 }
53 return [];
54 });
55 return chain;
56 }),
57 updateTable: jest.fn(() => ({
58 set: jest.fn().mockReturnValue({
59 where: jest.fn().mockReturnValue({
60 execute: updateExecute,
61 }),
62 }),
63 })),
64 __updateExecute: updateExecute,
65 };
66 return mockDb;
67}
68
69describe('Detect Rule Anomalies', () => {
70 describe('worker', () => {
71 let deleteMockData: () => Promise<void>,
72 mockDummyRules: Array<{
73 id: string;
74 orgId: string;
75 creatorId: string;
76 name: string;
77 alarmStatus: RuleAlarmStatus;
78 statusIfUnexpired: string;
79 }>,
80 mockKysely: ReturnType<typeof makeMockKyselyForRules>,
81 mockGetCurrentPeriodRuleAlarmStatuses: GetCurrentPeriodRuleAlarmStatuses,
82 mockNotificationsService: Mocked<
83 PublicInterface<NotificationsService>,
84 'createNotifications'
85 >;
86
87 beforeAll(async () => {
88 /* eslint-disable functional/immutable-data */
89 const {
90 Sequelize: models,
91 ModerationConfigService,
92 ApiKeyService,
93 KyselyPg,
94 } = (await getBottle()).container;
95
96 // make some fake rules (w/ stable ids so we can match them in a snapshot)
97 // in different initial alarm statuses, to test all 9 combinations [i.e.,
98 // starting and ending at one of (OK, ALARM, or INSUFFICENT_DATA), where
99 // the start and end states can be the same].
100 const { org, cleanup: orgCleanup } = await createOrg({
101 KyselyPg,
102 ModerationConfigService,
103 ApiKeyService,
104 });
105 const { org: org2, cleanup: org2Cleanup } = await createOrg(
106 {
107 KyselyPg,
108 ModerationConfigService,
109 ApiKeyService,
110 },
111 undefined,
112 { onCallAlertEmail: 'test@gmail.com' },
113 );
114 const { user: ruleOwner } = await createUser(models, org.id, {
115 id: 'cb34377bcc3',
116 });
117 const { user: ruleOwner2 } = await createUser(models, org.id, {
118 id: 'cb34377bcc4',
119 });
120 const fakeRules = await Promise.all([
121 createRule(models, org.id, {
122 alarmStatus: RuleAlarmStatus.ALARM,
123 id: '9d237a650c1',
124 creator: ruleOwner,
125 }),
126 createRule(models, org.id, {
127 alarmStatus: RuleAlarmStatus.ALARM,
128 id: '386da8abc3b',
129 creator: ruleOwner,
130 }),
131 createRule(models, org.id, {
132 alarmStatus: RuleAlarmStatus.ALARM,
133 id: 'd237a650c13',
134 creator: ruleOwner,
135 }),
136
137 createRule(models, org.id, {
138 alarmStatus: RuleAlarmStatus.OK,
139 id: '86da8abc3b6',
140 creator: ruleOwner,
141 }),
142 createRule(models, org.id, {
143 alarmStatus: RuleAlarmStatus.OK,
144 id: 'fdb4ee86f93',
145 creator: ruleOwner,
146 }),
147 createRule(models, org.id, {
148 alarmStatus: RuleAlarmStatus.OK,
149 id: '237a650c134',
150 creator: ruleOwner,
151 }),
152
153 createRule(models, org2.id, {
154 alarmStatus: RuleAlarmStatus.INSUFFICIENT_DATA,
155 id: 'db4ee86f938',
156 creator: ruleOwner2,
157 }),
158 createRule(models, org2.id, {
159 alarmStatus: RuleAlarmStatus.INSUFFICIENT_DATA,
160 id: '37a650c1342',
161 creator: ruleOwner2,
162 }),
163 createRule(models, org2.id, {
164 alarmStatus: RuleAlarmStatus.INSUFFICIENT_DATA,
165 id: 'b4ee86f9386',
166 creator: ruleOwner2,
167 }),
168 ]);
169
170 mockDummyRules = fakeRules.map((r) => ({
171 id: r.id,
172 orgId: r.orgId,
173 creatorId: r.creatorId,
174 name: r.name,
175 alarmStatus: r.alarmStatus,
176 statusIfUnexpired: r.statusIfUnexpired,
177 }));
178
179 mockGetCurrentPeriodRuleAlarmStatuses = async () => {
180 const newAlarmStatusByRule =
181 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
182 {} as Awaited<ReturnType<GetCurrentPeriodRuleAlarmStatuses>>;
183
184 fakeRules.forEach((rule, i) => {
185 newAlarmStatusByRule[rule.id] = {
186 status:
187 i % 3 === 0
188 ? RuleAlarmStatus.ALARM
189 : i % 3 === 1
190 ? RuleAlarmStatus.OK
191 : RuleAlarmStatus.INSUFFICIENT_DATA,
192 meta: { lastPeriodPassRate: 0.5, secondToLastPeriodPassRate: 0.4 },
193 };
194 });
195 return newAlarmStatusByRule;
196 };
197
198 mockNotificationsService = {
199 createNotifications: jest.fn(),
200 getNotificationsForUser: jest.fn(),
201 } as unknown as Mocked<
202 PublicInterface<NotificationsService>,
203 'createNotifications'
204 >;
205
206 mockKysely = makeMockKyselyForRules(mockDummyRules, [
207 { id: org.id, on_call_alert_email: null },
208 { id: org2.id, on_call_alert_email: 'test@gmail.com' },
209 ]);
210
211 deleteMockData = async () => {
212 await Promise.all(fakeRules.map(async (it) => it.destroy()));
213 await Promise.all([ruleOwner.destroy(), ruleOwner2.destroy()]);
214 await orgCleanup();
215 await org2Cleanup();
216 await models.sequelize.close();
217 };
218 /* eslint-enable functional/immutable-data */
219 });
220
221 afterAll(async () => {
222 return deleteMockData();
223 });
224
225 test('should generate the proper notifications + update rules', async () => {
226 const worker = DetectRulePassRateAnomaliesJob(
227 mockKysely as unknown as Dependencies['KyselyPg'],
228 mockNotificationsService,
229 mockGetCurrentPeriodRuleAlarmStatuses,
230 jest.fn<() => Promise<void>>(),
231 );
232 await worker.run();
233
234 const mockCreateNotifications =
235 mockNotificationsService.createNotifications;
236
237 expect(mockCreateNotifications).toHaveBeenCalledTimes(1);
238 expect(
239 mockCreateNotifications.mock.calls[0][0]
240 .slice(0)
241 .sort((a, b) => a.data.ruleId.localeCompare(b.data.ruleId)),
242 ).toMatchInlineSnapshot(`
243 [
244 {
245 "data": {
246 "lastPeriodPassRate": 0.5,
247 "ruleId": "386da8abc3b",
248 "ruleName": "Dummy_Rule_Name_386da8abc3b",
249 "secondToLastPeriodPassRate": 0.4,
250 },
251 "message": "[Alarm Cleared - Live Rule] Dummy_Rule_Name_386da8abc3b has stopped passing at an anomalous rate.",
252 "recipients": [
253 {
254 "type": "user_id",
255 "value": "cb34377bcc3",
256 },
257 ],
258 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_END",
259 },
260 {
261 "data": {
262 "lastPeriodPassRate": 0.5,
263 "ruleId": "86da8abc3b6",
264 "ruleName": "Dummy_Rule_Name_86da8abc3b6",
265 "secondToLastPeriodPassRate": 0.4,
266 },
267 "message": "[Alarm Triggered - Live Rule] Dummy_Rule_Name_86da8abc3b6 has started passing at an anomalous rate.",
268 "recipients": [
269 {
270 "type": "user_id",
271 "value": "cb34377bcc3",
272 },
273 ],
274 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_START",
275 },
276 {
277 "data": {
278 "lastPeriodPassRate": 0.5,
279 "ruleId": "d237a650c13",
280 "ruleName": "Dummy_Rule_Name_d237a650c13",
281 "secondToLastPeriodPassRate": 0.4,
282 },
283 "message": "[Alarm Cleared - Live Rule] Dummy_Rule_Name_d237a650c13 has stopped passing at an anomalous rate.",
284 "recipients": [
285 {
286 "type": "user_id",
287 "value": "cb34377bcc3",
288 },
289 ],
290 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_END",
291 },
292 {
293 "data": {
294 "lastPeriodPassRate": 0.5,
295 "ruleId": "db4ee86f938",
296 "ruleName": "Dummy_Rule_Name_db4ee86f938",
297 "secondToLastPeriodPassRate": 0.4,
298 },
299 "message": "[Alarm Triggered - Live Rule] Dummy_Rule_Name_db4ee86f938 has started passing at an anomalous rate.",
300 "recipients": [
301 {
302 "type": "user_id",
303 "value": "cb34377bcc4",
304 },
305 {
306 "type": "email_address",
307 "value": "test@gmail.com",
308 },
309 ],
310 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_START",
311 },
312 ]
313 `);
314
315 await mockGetCurrentPeriodRuleAlarmStatuses();
316 const expectedUpdates = mockDummyRules.filter(
317 (_rule, i) => ![0, 4, 8].includes(i),
318 ).length;
319 expect(mockKysely.updateTable).toHaveBeenCalledTimes(expectedUpdates);
320 });
321 });
322});