diff --git a/lib/utils/decl.ts b/lib/utils/decl.ts new file mode 100644 index 000000000..7a4b93cc9 --- /dev/null +++ b/lib/utils/decl.ts @@ -0,0 +1,29 @@ +/** + * Decl of number + * + * @param value - value to decl + * @param titles - titles to decl: ['новое событие', 'новых события', 'новых событий'] + * @example declOfNum(1, ['новое событие', 'новых события', 'новых событий']) -> 'новое событие' + * @example declOfNum(2, ['новое событие', 'новых события', 'новых событий']) -> 'новых события' + * @example declOfNum(10, ['новое событие', 'новых события', 'новых событий']) -> 'новых событий' + * @example declOfNum(21, ['новое событие', 'новых события', 'новых событий']) -> 'новое событие' + * @returns decl of number + */ +export function declOfNum(value: number, titles: string[]): string { + const decimalBase = 10; + const hundredBase = 100; + const minExclusiveTeens = 4; + const maxExclusiveTeens = 20; + const manyFormIndex = 2; + const maxCaseIndex = 5; + const declCases = [manyFormIndex, 0, 1, 1, 1, manyFormIndex]; + + const valueModHundred = value % hundredBase; + const valueModTen = value % decimalBase; + const isTeens = valueModHundred > minExclusiveTeens && valueModHundred < maxExclusiveTeens; + const caseIndex = isTeens + ? manyFormIndex + : declCases[valueModTen < maxCaseIndex ? valueModTen : maxCaseIndex]; + + return titles[caseIndex]; +} \ No newline at end of file diff --git a/package.json b/package.json index 9f094dc2c..69cfe1e81 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:javascript": "jest workers/javascript", "test:release": "jest workers/release", "test:slack": "jest workers/slack", + "test:loop": "jest workers/loop", "test:limiter": "jest workers/limiter --runInBand", "test:grouper": "jest workers/grouper", "test:diff": "jest ./workers/grouper/tests/diff.test.ts", @@ -37,6 +38,7 @@ "run-sentry": "yarn worker hawk-worker-sentry", "run-js": "yarn worker hawk-worker-javascript", "run-slack": "yarn worker hawk-worker-slack", + "run-loop": "yarn worker hawk-worker-loop", "run-grouper": "yarn worker hawk-worker-grouper", "run-archiver": "yarn worker hawk-worker-archiver", "run-accountant": "yarn worker hawk-worker-accountant", diff --git a/workers/loop/package.json b/workers/loop/package.json new file mode 100644 index 000000000..eccaaec7f --- /dev/null +++ b/workers/loop/package.json @@ -0,0 +1,15 @@ +{ + "name": "hawk-worker-loop", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "license": "MIT", + "workerType": "sender/loop", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@slack/webhook": "^5.0.3", + "json-templater": "^1.2.0" + } +} diff --git a/workers/loop/src/deliverer.ts b/workers/loop/src/deliverer.ts new file mode 100644 index 000000000..6645dec82 --- /dev/null +++ b/workers/loop/src/deliverer.ts @@ -0,0 +1,47 @@ +import { IncomingWebhook } from '@slack/webhook'; +import { createLogger, format, Logger, transports } from 'winston'; + +/** + * Deliverer is the man who will send messages to external service + * Separated from the provider to allow testing 'send' method + * Loop is Slack-like platform, so we use Slack API to send messages. + * + */ +export default class LoopDeliverer { + /** + * Logger module + * (default level='info') + */ + private logger: Logger = createLogger({ + level: process.env.LOG_LEVEL || 'info', + transports: [ + new transports.Console({ + format: format.combine( + format.timestamp(), + format.colorize(), + format.simple(), + format.printf((msg) => `${msg.timestamp} - ${msg.level}: ${msg.message}`) + ), + }), + ], + }); + + /** + * Sends message to the Loop through the Incoming Webhook app + * https://developers.loop.ru/integrate/webhooks/incoming/ + * + * @param endpoint - where to send + * @param message - what to send + */ + public async deliver(endpoint: string, message: string): Promise { + try { + const webhook = new IncomingWebhook(endpoint, { + username: 'Hawk', + }); + + await webhook.send(message); + } catch (e) { + this.logger.log('error', 'Can\'t deliver Incoming Webhook. Loop returns an error: ', e); + } + } +} diff --git a/workers/loop/src/index.ts b/workers/loop/src/index.ts new file mode 100644 index 000000000..174a5ac37 --- /dev/null +++ b/workers/loop/src/index.ts @@ -0,0 +1,24 @@ +import * as pkg from './../package.json'; +import LoopProvider from './provider'; +import SenderWorker from 'hawk-worker-sender/src'; +import { ChannelType } from 'hawk-worker-notifier/types/channel'; + +/** + * Worker to send email notifications + */ +export default class LoopSenderWorker extends SenderWorker { + /** + * Worker type + */ + public readonly type: string = pkg.workerType; + + /** + * Email channel type + */ + protected channelType = ChannelType.Loop; + + /** + * Email provider + */ + protected provider = new LoopProvider(); +} diff --git a/workers/loop/src/provider.ts b/workers/loop/src/provider.ts new file mode 100644 index 000000000..d1143c6de --- /dev/null +++ b/workers/loop/src/provider.ts @@ -0,0 +1,44 @@ +import NotificationsProvider from 'hawk-worker-sender/src/provider'; +import { Notification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import templates from './templates'; +import { LoopTemplate } from '../types/template'; +import LoopDeliverer from './deliverer'; + +/** + * This class provides a 'send' method that will renders and sends a notification + */ +export default class LoopProvider extends NotificationsProvider { + /** + * Class with the 'deliver' method for sending messages to the Loop + */ + private readonly deliverer: LoopDeliverer; + + /** + * Constructor allows to separate dependencies that can't be tested, + * so in tests they will be mocked. + */ + constructor() { + super(); + + this.deliverer = new LoopDeliverer(); + } + + /** + * Send loop message to recipient + * + * @param to - recipient endpoint + * @param notification - notification with payload and type + */ + public async send(to: string, notification: Notification): Promise { + let template: LoopTemplate; + + switch (notification.type) { + case 'event': template = templates.EventTpl; break; + case 'several-events':template = templates.SeveralEventsTpl; break; + } + + const message = template(notification.payload as EventsTemplateVariables); + + await this.deliverer.deliver(to, message); + } +} diff --git a/workers/loop/src/templates/event.ts b/workers/loop/src/templates/event.ts new file mode 100644 index 000000000..bd1f9ced0 --- /dev/null +++ b/workers/loop/src/templates/event.ts @@ -0,0 +1,57 @@ +import { GroupedEventDBScheme } from '@hawk.so/types'; +import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sender/types/template-variables'; +import { toMaxLen } from '../../../slack/src/templates/utils'; + +/** + * Renders backtrace overview + * + * @param event - event to render + */ +function renderBacktrace(event: GroupedEventDBScheme): string { + let code = ''; + + const firstNotEmptyFrame = event.payload.backtrace.find(frame => !!frame.sourceCode); + + if (!firstNotEmptyFrame) { + return code; + } + + code = firstNotEmptyFrame.sourceCode.map(({ line, content }) => { + let colDelimiter = ': '; + + if (line === firstNotEmptyFrame.line) { + colDelimiter = ' ->'; + } + + const MAX_SOURCE_CODE_LINE_LENGTH = 65; + + return `${line}${colDelimiter} ${toMaxLen(content, MAX_SOURCE_CODE_LINE_LENGTH)}`; + }).join('\n'); + + return code; +} + +/** + * Return tpl with data substitutions + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): string { + const eventInfo = tplData.events[0] as TemplateEventData; + const event = eventInfo.event; + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/'; + let location = 'Неизвестное место'; + + if (event.payload.backtrace && event.payload.backtrace.length > 0) { + location = event.payload.backtrace[0].file; + } + + return ''.concat( + `**${event.payload.title}**`, + '\n', + `*${location}*\n`, + '```\n' + renderBacktrace(event) + '\n```', + '\n', + `[Посмотреть подробности](${eventURL}) `, `| *${tplData.project.name}*`, ` | ${eventInfo.newCount} новых (${eventInfo.event.totalCount} всего)` + ); +} diff --git a/workers/loop/src/templates/index.ts b/workers/loop/src/templates/index.ts new file mode 100644 index 000000000..6e23324c1 --- /dev/null +++ b/workers/loop/src/templates/index.ts @@ -0,0 +1,7 @@ +import EventTpl from './event'; +import SeveralEventsTpl from './several-events'; + +export default { + EventTpl, + SeveralEventsTpl, +}; diff --git a/workers/loop/src/templates/several-events.ts b/workers/loop/src/templates/several-events.ts new file mode 100644 index 000000000..bc7923060 --- /dev/null +++ b/workers/loop/src/templates/several-events.ts @@ -0,0 +1,23 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import { declOfNum } from '../../../../lib/utils/decl'; + +/** + * Return tpl with data substitutions + * + * @param tplData - event template data + */ +export default function render(tplData: EventsTemplateVariables): string { + const projectUrl = tplData.host + '/project/' + tplData.project._id; + let message = tplData.events.length + ' ' + declOfNum( + tplData.events.length, + ['новое событие', 'новых события', 'новых событий'] + ) + '\n\n'; + + tplData.events.forEach(({ event, newCount }) => { + message += `(${newCount}) ${event.payload.title} \n`; + }); + + message += `\n[Посмотреть все события](${projectUrl}) | *${tplData.project.name}*`; + + return message; +} diff --git a/workers/loop/tests/__mocks__/event-notify.ts b/workers/loop/tests/__mocks__/event-notify.ts new file mode 100644 index 000000000..77a53b54a --- /dev/null +++ b/workers/loop/tests/__mocks__/event-notify.ts @@ -0,0 +1,43 @@ +import { EventNotification } from 'hawk-worker-sender/types/template-variables'; +import { ObjectId } from 'mongodb'; + +/** + * Example of new-events notify template variables + */ +export default { + type: 'event', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 1, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as EventNotification; diff --git a/workers/loop/tests/__mocks__/several-events-notify.ts b/workers/loop/tests/__mocks__/several-events-notify.ts new file mode 100644 index 000000000..24ed30e87 --- /dev/null +++ b/workers/loop/tests/__mocks__/several-events-notify.ts @@ -0,0 +1,63 @@ +import { SeveralEventsNotification } from 'hawk-worker-sender/types/template-variables'; +import { GroupedEventDBScheme } from '@hawk.so/types'; +import { ObjectId } from 'mongodb'; + +/** + * Example of several-events notify template variables + */ +export default { + type: 'several-events', + payload: { + events: [ + { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as GroupedEventDBScheme, + daysRepeated: 1, + newCount: 1, + }, + { + event: { + totalCount: 5, + payload: { + title: 'New event 2', + timestamp: Date.now(), + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + }, + daysRepeated: 100, + newCount: 1, + }, + ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + }, + }, +} as SeveralEventsNotification; diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts new file mode 100644 index 000000000..b8185928e --- /dev/null +++ b/workers/loop/tests/provider.test.ts @@ -0,0 +1,246 @@ +import { EventNotification, SeveralEventsNotification, EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; +import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; +import LoopProvider from '../src/provider'; +import templates from '../src/templates'; +import SeveralEventsTpl from '../src/templates/several-events'; +import EventNotifyMock from './__mocks__/event-notify'; +import SeveralEventsNotifyMock from './__mocks__/several-events-notify'; +import { ObjectId } from 'mongodb'; + +/** + * The sample of the Loop Incoming Webhook endpoint + */ +const loopEndpointSample = 'https://hooks.loop.com/services/XXXXXXXXX/XXXXXXXXXX/XXXXXXXXXXX'; + +/** + * Mock the 'deliver' method of LoopDeliverer + */ +const deliver = jest.fn(); + +/** + * Loop Deliverer mock + */ +jest.mock('./../src/deliverer.ts', () => { + return jest.fn().mockImplementation(() => { + /** + * Now we can track calls to 'deliver' + */ + return { + deliver: deliver, + }; + }); +}); + +/** + * Clear all records of mock calls between tests + */ +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('LoopProvider', () => { + /** + * Check that the 'send' method works without errors + */ + it('The "send" method should render and deliver message', async () => { + const provider = new LoopProvider(); + + await provider.send(loopEndpointSample, EventNotifyMock); + + expect(deliver).toHaveBeenCalledTimes(1); + expect(deliver).toHaveBeenCalledWith(loopEndpointSample, expect.anything()); + }); + + /** + * Logic for select the template depended on events count + */ + describe('Select correct template', () => { + /** + * If there is a single event in payload, use the 'new-event' template + */ + it('Select the new-event template if there is a single event in notify payload', async () => { + const provider = new LoopProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTplSpy = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(loopEndpointSample, EventNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(1); + expect(SeveralEventsTplSpy).toHaveBeenCalledTimes(0); + }); + + /** + * If there are several events in payload, use the 'several-events' template + */ + it('Select the several-events template if there are several events in notify payload', async () => { + const provider = new LoopProvider(); + const EventTpl = jest.spyOn(templates, 'EventTpl'); + const SeveralEventsTplSpy = jest.spyOn(templates, 'SeveralEventsTpl'); + + await provider.send(loopEndpointSample, SeveralEventsNotifyMock); + + expect(EventTpl).toHaveBeenCalledTimes(0); + expect(SeveralEventsTplSpy).toHaveBeenCalledTimes(1); + }); + }); + + /** + * Check templates rendering + */ + describe('templates', () => { + /** + * Check that rendering of a single event message works without errors + */ + it('should successfully render a new-event template', async () => { + const vars: EventNotification = { + type: 'event', + payload: { + events: [ { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + } ], + period: 60, + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme, + }, + }; + + const render = (): string => templates.EventTpl(vars.payload); + + expect(render).not.toThrowError(); + + const message = render(); + + expect(message).toBeDefined(); + }); + + /** + * Check that rendering of a several events message works without errors + */ + it('should successfully render a several-events template', () => { + const vars: SeveralEventsNotification = SeveralEventsNotifyMock; + + const render = (): string => templates.SeveralEventsTpl(vars.payload); + + expect(render).not.toThrowError(); + + const message = render(); + + expect(message).toBeDefined(); + + // Header contains number of events and declension + expect(message).toContain(`${vars.payload.events.length} `); + + // Each event should be listed with "(newCount) title" + vars.payload.events.forEach(({ event, newCount }) => { + expect(message).toContain(`(${newCount}) ${event.payload.title}`); + }); + + // Footer should contain link to the project and project name + const projectUrl = vars.payload.host + '/project/' + vars.payload.project._id; + + expect(message).toContain(`[Посмотреть все события](${projectUrl})`); + expect(message).toContain(`*${vars.payload.project.name}*`); + }); + + /** + * Check declensions in several-events template header + */ + describe('several-events declensions', () => { + const baseEvent = { + event: { + totalCount: 10, + timestamp: Date.now(), + payload: { + title: 'New event', + backtrace: [ { + file: 'file', + line: 1, + sourceCode: [ { + line: 1, + content: 'code', + } ], + } ], + }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + }; + + const baseProject: ProjectDBScheme = { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], + } as ProjectDBScheme; + + const makePayload = (count: number): EventsTemplateVariables => ({ + events: Array.from({ length: count }, () => baseEvent), + host: process.env.GARAGE_URL, + hostOfStatic: process.env.API_STATIC_URL, + project: baseProject, + period: 60, + }); + + it('uses correct declension for 1 event', () => { + const payload = makePayload(1); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('1 новое событие'); + }); + + it('uses correct declension for 2 events', () => { + const payload = makePayload(2); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('2 новых события'); + }); + + it('uses correct declension for 5 events', () => { + const payload = makePayload(5); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('5 новых событий'); + }); + + it('uses correct declension for 10 events', () => { + const payload = makePayload(10); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('10 новых событий'); + }); + + it('uses correct declension for 21 events', () => { + const payload = makePayload(21); + const message = SeveralEventsTpl(payload); + + expect(message).toContain('21 новое событие'); + }); + }); + }); +}); diff --git a/workers/loop/types/template.d.ts b/workers/loop/types/template.d.ts new file mode 100644 index 000000000..8a5623786 --- /dev/null +++ b/workers/loop/types/template.d.ts @@ -0,0 +1,13 @@ +import type { EventsTemplateVariables } from 'hawk-worker-sender/types/template-variables'; + +/** + * Loop templates should implement this interface + */ +export interface LoopTemplate { + /** + * Rendering method that accepts tpl args and return rendered string + * + * @param tplData - template variables + */ + (tplData: EventsTemplateVariables): string; +} diff --git a/workers/loop/yarn.lock b/workers/loop/yarn.lock new file mode 100644 index 000000000..1f72f556a --- /dev/null +++ b/workers/loop/yarn.lock @@ -0,0 +1,48 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@slack/types@^1.2.1": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@slack/types/-/types-1.5.0.tgz#5c2cb0f718689266ff295aad33301d489272c842" + integrity sha512-oCYgatJYxHf9wE3tKXzOLeeTsF0ghX1TIcguNfVmO2V6NDe+cHAzZRglEOmJLdRINDS5gscAgSkeZpDhpKBeUA== + +"@slack/webhook@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@slack/webhook/-/webhook-5.0.3.tgz#2205cba9a8d49d2ae84ca93f11ab4a1dba2f963b" + integrity sha512-51vnejJ2zABNumPVukOLyerpHQT39/Lt0TYFtOEz/N2X77bPofOgfPj2atB3etaM07mxWHLT9IRJ4Zuqx38DkQ== + dependencies: + "@slack/types" "^1.2.1" + "@types/node" ">=8.9.0" + axios "^0.19.0" + +"@types/node@>=8.9.0": + version "13.11.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.11.0.tgz#390ea202539c61c8fa6ba4428b57e05bc36dc47b" + integrity sha512-uM4mnmsIIPK/yeO+42F2RQhGUIs39K2RFmugcJANppXe6J1nvH87PvzPZYpza7Xhhs8Yn9yIAVdLZ84z61+0xQ== + +axios@^0.19.0: + version "0.19.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.2.tgz#3ea36c5d8818d0d5f8a8a97a6d36b86cdc00cb27" + integrity sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA== + dependencies: + follow-redirects "1.5.10" + +debug@=3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" + integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== + dependencies: + ms "2.0.0" + +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= diff --git a/workers/notifier/tests/worker.test.ts b/workers/notifier/tests/worker.test.ts index b757d31e8..1f128ac16 100644 --- a/workers/notifier/tests/worker.test.ts +++ b/workers/notifier/tests/worker.test.ts @@ -42,6 +42,11 @@ const rule = { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }, } as any; @@ -70,6 +75,11 @@ const alternativeRule = { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }, }; @@ -361,11 +371,16 @@ describe('NotifierWorker', () => { endpoint: 'emailEndpoint', minPeriod: 0.5, }, + loop: { + isEnabled: true, + endpoint: 'loopEndpoint', + minPeriod: 0.5, + }, }; await worker.handle(message); - expect(worker.sendToSenderWorker).toBeCalledTimes(2); + expect(worker.sendToSenderWorker).toBeCalledTimes(3); }); it('should compute event count for period for each fitted rule', async () => { diff --git a/workers/notifier/types/channel.ts b/workers/notifier/types/channel.ts index c6b1bc70c..3a195c3d7 100644 --- a/workers/notifier/types/channel.ts +++ b/workers/notifier/types/channel.ts @@ -5,6 +5,7 @@ export enum ChannelType { Email = 'email', Telegram = 'telegram', Slack = 'slack', + Loop = 'loop', } /**