Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import _ from 'lodash';
2
3import getBottle, {
4 type Dependencies,
5 type PublicInterface,
6} from '../../iocContainer/index.js';
7import { type Rule as TRule } from '../../models/rules/RuleModel.js';
8import { type NotificationsService } from '../../services/notificationsService/notificationsService.js';
9import { type GetCurrentPeriodRuleAlarmStatuses } from '../../services/ruleAnomalyDetectionService/getCurrentPeriodRuleAlarmStatuses.js';
10import createOrg from '../../test/fixtureHelpers/createOrg.js';
11import createRule from '../../test/fixtureHelpers/createRule.js';
12import createUser from '../../test/fixtureHelpers/createUser.js';
13import { mocked, type Mocked } from '../../test/mockHelpers/jestMocks.js';
14import { RuleAlarmStatus } from '../moderationConfigService/index.js';
15import DetectRulePassRateAnomaliesJob from './detectRulePassRateAnomaliesJob.js';
16
17describe('Detect Rule Anomalies', () => {
18 describe('worker', () => {
19 let OrgModel: Dependencies['Sequelize']['Org'],
20 deleteMockData: () => Promise<void>,
21 mockDummyRules: Mocked<TRule, 'save'>[],
22 mockRuleModel: Mocked<Dependencies['Sequelize']['Rule'], 'findAll'>,
23 mockGetCurrentPeriodRuleAlarmStatuses: GetCurrentPeriodRuleAlarmStatuses,
24 mockNotificationsService: Mocked<
25 PublicInterface<NotificationsService>,
26 'createNotifications'
27 >;
28
29 beforeAll(async () => {
30 /* eslint-disable better-mutation/no-mutation */
31 const {
32 Sequelize: models,
33 ModerationConfigService,
34 ApiKeyService,
35 } = (await getBottle()).container;
36 OrgModel = models.Org;
37
38 // make some fake rules (w/ stable ids so we can match them in a snapshot)
39 // in different initial alarm statuses, to test all 9 combinations [i.e.,
40 // starting and ending at one of (OK, ALARM, or INSUFFICENT_DATA), where
41 // the start and end states can be the same].
42 const { org } = await createOrg(models, ModerationConfigService, ApiKeyService);
43 const { org: org2 } = await createOrg(
44 models,
45 ModerationConfigService,
46 ApiKeyService,
47 undefined,
48 { onCallAlertEmail: 'test@gmail.com' },
49 );
50 const { user: ruleOwner } = await createUser(models, org.id, {
51 id: 'cb34377bcc3',
52 });
53 const { user: ruleOwner2 } = await createUser(models, org.id, {
54 id: 'cb34377bcc4',
55 });
56 const fakeRules = await Promise.all([
57 createRule(models, org.id, {
58 alarmStatus: RuleAlarmStatus.ALARM,
59 id: '9d237a650c1',
60 creator: ruleOwner,
61 }),
62 createRule(models, org.id, {
63 alarmStatus: RuleAlarmStatus.ALARM,
64 id: '386da8abc3b',
65 creator: ruleOwner,
66 }),
67 createRule(models, org.id, {
68 alarmStatus: RuleAlarmStatus.ALARM,
69 id: 'd237a650c13',
70 creator: ruleOwner,
71 }),
72
73 createRule(models, org.id, {
74 alarmStatus: RuleAlarmStatus.OK,
75 id: '86da8abc3b6',
76 creator: ruleOwner,
77 }),
78 createRule(models, org.id, {
79 alarmStatus: RuleAlarmStatus.OK,
80 id: 'fdb4ee86f93',
81 creator: ruleOwner,
82 }),
83 createRule(models, org.id, {
84 alarmStatus: RuleAlarmStatus.OK,
85 id: '237a650c134',
86 creator: ruleOwner,
87 }),
88
89 createRule(models, org2.id, {
90 alarmStatus: RuleAlarmStatus.INSUFFICIENT_DATA,
91 id: 'db4ee86f938',
92 creator: ruleOwner2,
93 }),
94 createRule(models, org2.id, {
95 alarmStatus: RuleAlarmStatus.INSUFFICIENT_DATA,
96 id: '37a650c1342',
97 creator: ruleOwner2,
98 }),
99 createRule(models, org2.id, {
100 alarmStatus: RuleAlarmStatus.INSUFFICIENT_DATA,
101 id: 'b4ee86f9386',
102 creator: ruleOwner2,
103 }),
104 ]);
105
106 mockDummyRules = fakeRules.map((it) => mocked(it, ['save']));
107 mockGetCurrentPeriodRuleAlarmStatuses = async () => {
108 const newAlarmStatusByRule =
109 // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
110 {} as Awaited<ReturnType<GetCurrentPeriodRuleAlarmStatuses>>;
111
112 fakeRules.forEach((rule, i) => {
113 newAlarmStatusByRule[rule.id] = {
114 status:
115 i % 3 === 0
116 ? RuleAlarmStatus.ALARM
117 : i % 3 === 1
118 ? RuleAlarmStatus.OK
119 : RuleAlarmStatus.INSUFFICIENT_DATA,
120 meta: { lastPeriodPassRate: 0.5, secondToLastPeriodPassRate: 0.4 },
121 };
122 });
123 return newAlarmStatusByRule;
124 };
125
126 mockNotificationsService = mocked(
127 {
128 async createNotifications(_it: any) {},
129 async getNotificationsForUser() {
130 return [];
131 },
132 },
133 ['createNotifications'],
134 );
135
136 mockRuleModel = mocked(models.Rule, ['findAll']);
137 mockRuleModel.findAll.mockResolvedValue(mockDummyRules);
138
139 // It might be nice if we could create the mock data at the start of a
140 // transaction, run the tests with that transaction open, and then just
141 // roll it back at the end to automatically delete and leave the db in a
142 // consistent/clean state. That's a little tricky, though, as it requires
143 // feeding the transacation object (or keeping a managed transaction
144 // callback open) all the way into calling the worker. So, instead, we
145 // settle for manually defining this compensating transacaction, which we
146 // call at the end.
147 deleteMockData = async () => {
148 await Promise.all(fakeRules.map(async (it) => it.destroy()));
149 await Promise.all([ruleOwner.destroy(), ruleOwner2.destroy()]);
150 await Promise.all([org.destroy(), org2.destroy()]);
151 await models.sequelize.close();
152 };
153 /* eslint-enable better-mutation/no-mutation */
154 });
155
156 afterAll(async () => {
157 return deleteMockData();
158 });
159
160 test('should generate the proper notifications + update rules', async () => {
161 const worker = DetectRulePassRateAnomaliesJob(
162 mockRuleModel,
163 OrgModel,
164 mockNotificationsService,
165 mockGetCurrentPeriodRuleAlarmStatuses,
166 jest.fn<() => Promise<void>>(),
167 );
168 await worker.run();
169
170 // We should've sent 4 notifications: one for each of the two rules that
171 // was in alarm and transitioned to 'not alarm' (ok or insufficient data),
172 // and one for each of the rules that was in 'not alarm' and went to alarm.
173 const mockCreateNotifications =
174 mockNotificationsService.createNotifications;
175
176 expect(mockCreateNotifications).toHaveBeenCalledTimes(1);
177 expect(
178 mockCreateNotifications.mock.calls[0][0]
179 .slice(0)
180 .sort((a, b) => a.data.ruleId.localeCompare(b.data.ruleId)),
181 ).toMatchInlineSnapshot(`
182 [
183 {
184 "data": {
185 "lastPeriodPassRate": 0.5,
186 "ruleId": "386da8abc3b",
187 "ruleName": "Dummy_Rule_Name_386da8abc3b",
188 "secondToLastPeriodPassRate": 0.4,
189 },
190 "message": "[Alarm Cleared - Live Rule] Dummy_Rule_Name_386da8abc3b has stopped passing at an anomalous rate.",
191 "recipients": [
192 {
193 "type": "user_id",
194 "value": "cb34377bcc3",
195 },
196 ],
197 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_END",
198 },
199 {
200 "data": {
201 "lastPeriodPassRate": 0.5,
202 "ruleId": "86da8abc3b6",
203 "ruleName": "Dummy_Rule_Name_86da8abc3b6",
204 "secondToLastPeriodPassRate": 0.4,
205 },
206 "message": "[Alarm Triggered - Live Rule] Dummy_Rule_Name_86da8abc3b6 has started passing at an anomalous rate.",
207 "recipients": [
208 {
209 "type": "user_id",
210 "value": "cb34377bcc3",
211 },
212 ],
213 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_START",
214 },
215 {
216 "data": {
217 "lastPeriodPassRate": 0.5,
218 "ruleId": "d237a650c13",
219 "ruleName": "Dummy_Rule_Name_d237a650c13",
220 "secondToLastPeriodPassRate": 0.4,
221 },
222 "message": "[Alarm Cleared - Live Rule] Dummy_Rule_Name_d237a650c13 has stopped passing at an anomalous rate.",
223 "recipients": [
224 {
225 "type": "user_id",
226 "value": "cb34377bcc3",
227 },
228 ],
229 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_END",
230 },
231 {
232 "data": {
233 "lastPeriodPassRate": 0.5,
234 "ruleId": "db4ee86f938",
235 "ruleName": "Dummy_Rule_Name_db4ee86f938",
236 "secondToLastPeriodPassRate": 0.4,
237 },
238 "message": "[Alarm Triggered - Live Rule] Dummy_Rule_Name_db4ee86f938 has started passing at an anomalous rate.",
239 "recipients": [
240 {
241 "type": "user_id",
242 "value": "cb34377bcc4",
243 },
244 {
245 "type": "email_address",
246 "value": "test@gmail.com",
247 },
248 ],
249 "type": "RULE_PASS_RATE_INCREASE_ANOMALY_START",
250 },
251 ]
252 `);
253
254 // Except for the rules that stayed the same state (indexes 0, 4, 8), all
255 // the rules should've been saved with their new state.
256 const newRuleStatuses = await mockGetCurrentPeriodRuleAlarmStatuses();
257 mockDummyRules.forEach((rule, i) => {
258 if (![0, 4, 8].includes(i)) {
259 expect(rule.save).toHaveBeenCalledTimes(1);
260 expect(rule.alarmStatus).toEqual(newRuleStatuses[rule.id].status);
261 } else {
262 expect(rule.save).toHaveBeenCalledTimes(0);
263 }
264 });
265 });
266 });
267});