From 40fa6491fb46fd7a61caae0b2915e66ca6815665 Mon Sep 17 00:00:00 2001 From: KoshaevEugeny <103786108+akulistus@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:34:49 +0300 Subject: [PATCH 1/5] feat(workers): catch-unhandled-exceptions (#532) * feat(workers): add decorator to catch unhandled exceptions and report them to Hawk with worker type context * fix lint * Revert "fix lint" This reverts commit 7a7c0a94b3cc95b1e97af1b33b9d899935631cf3. * Revert "feat(workers): add decorator to catch unhandled exceptions and report them to Hawk with worker type context" This reverts commit ebb1fd74a0ee627ba903fc763760ca0361739283. * refactor(runner): add active worker names to HawkCatcher context --- runner.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/runner.ts b/runner.ts index 443beea8..5dd05b1b 100644 --- a/runner.ts +++ b/runner.ts @@ -12,10 +12,6 @@ import * as dotenv from 'dotenv'; dotenv.config(); -if (process.env.HAWK_CATCHER_TOKEN) { - HawkCatcher.init(process.env.HAWK_CATCHER_TOKEN); -} - type WorkerConstructor = new () => Worker; const BEGINNING_OF_ARGS = 2; @@ -27,6 +23,18 @@ const BEGINNING_OF_ARGS = 2; */ const workerNames = process.argv.slice(BEGINNING_OF_ARGS); +/** + * Initialize HawkCatcher +*/ +if (process.env.HAWK_CATCHER_TOKEN) { + HawkCatcher.init({ + token: process.env.HAWK_CATCHER_TOKEN, + context: { + workerTypes: workerNames.join(","), + } + }); +} + /** * Workers dispatcher. * Load, run and finish workers. From d1c7af4d724dbe3ae62906282c877f88b4ec0cf2 Mon Sep 17 00:00:00 2001 From: e11sy <130844513+e11sy@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:27:33 +0300 Subject: [PATCH 2/5] fix(): redis never throws key does not exist (#539) --- workers/grouper/src/redisHelper.ts | 32 ++++++++++-------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/workers/grouper/src/redisHelper.ts b/workers/grouper/src/redisHelper.ts index ec486ddb..57a35543 100644 --- a/workers/grouper/src/redisHelper.ts +++ b/workers/grouper/src/redisHelper.ts @@ -193,17 +193,11 @@ export default class RedisHelper { ): Promise { const timestamp = Date.now(); - try { - await this.tsIncrBy(key, value, timestamp, labels); - } catch (error) { - if (error instanceof Error && error.message.includes('TSDB: key does not exist')) { - this.logger.warn(`TS key ${key} does not exist, creating it...`); - await this.tsCreateIfNotExists(key, labels, retentionMs); - await this.tsIncrBy(key, value, timestamp, labels); - } else { - throw error; - } - } + /** + * Create key if not exists — then call increment + */ + await this.tsCreateIfNotExists(key, labels, retentionMs); + await this.tsIncrBy(key, value, timestamp, labels); } /** @@ -252,17 +246,11 @@ export default class RedisHelper { ): Promise { const timestamp = Date.now(); - try { - await this.tsAdd(key, value, timestamp, labels); - } catch (error) { - if (error instanceof Error && error.message.includes('TSDB: key does not exist')) { - this.logger.warn(`TS key ${key} does not exist, creating it...`); - await this.tsCreateIfNotExists(key, labels, retentionMs); - await this.tsAdd(key, value, timestamp, labels); - } else { - throw error; - } - } + /** + * Create key if not exists — then call increment + */ + await this.tsCreateIfNotExists(key, labels, retentionMs); + await this.tsAdd(key, value, timestamp, labels); } /** From 152362b09cb3091899486d92e9587d6b072326d8 Mon Sep 17 00:00:00 2001 From: Pavel Zotikov Date: Tue, 14 Apr 2026 21:18:35 +0300 Subject: [PATCH 3/5] fix(metrics): round Redis TimeSeries timestamps to bucket boundaries (#542) * fix(metrics): round Redis TimeSeries timestamps to bucket boundaries * refactor(metrics): extract bucketTimestampMs to utils and add unit tests * fix(metrics): replace magic number 90 with named constant --- workers/grouper/src/index.ts | 43 +++++------- workers/grouper/src/redisHelper.ts | 6 +- workers/grouper/src/utils/bucketTimestamp.ts | 17 +++++ workers/grouper/tests/bucketTimestamp.test.ts | 65 +++++++++++++++++++ workers/grouper/tests/index.test.ts | 9 ++- 5 files changed, 107 insertions(+), 33 deletions(-) create mode 100644 workers/grouper/src/utils/bucketTimestamp.ts create mode 100644 workers/grouper/tests/bucketTimestamp.test.ts diff --git a/workers/grouper/src/index.ts b/workers/grouper/src/index.ts index f016c850..ea74084b 100644 --- a/workers/grouper/src/index.ts +++ b/workers/grouper/src/index.ts @@ -23,6 +23,7 @@ import TimeMs from '../../../lib/utils/time'; import DataFilter from './data-filter'; import RedisHelper from './redisHelper'; import { computeDelta } from './utils/repetitionDiff'; +import { bucketTimestampMs } from './utils/bucketTimestamp'; import { rightTrim } from '../../../lib/utils/string'; import { hasValue } from '../../../lib/utils/hasValue'; @@ -48,6 +49,11 @@ const CACHE_CLEANUP_INTERVAL_SECONDS = 30; */ const DB_DUPLICATE_KEY_ERROR = '11000'; +/** + * Retention period for daily Redis TimeSeries metrics in days + */ +const DAILY_METRICS_RETENTION_DAYS = 90; + /** * Maximum length for backtrace code line or title */ @@ -343,37 +349,18 @@ export default class GrouperWorker extends Worker { }; const series = [ - { - key: minutelyKey, - label: 'minutely', - retentionMs: TimeMs.DAY, - }, - { - key: hourlyKey, - label: 'hourly', - retentionMs: TimeMs.WEEK, - }, - { - key: dailyKey, - label: 'daily', - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - retentionMs: 90 * TimeMs.DAY, - }, + { key: minutelyKey, label: 'minutely', retentionMs: TimeMs.DAY, timestampMs: bucketTimestampMs('minutely') }, + { key: hourlyKey, label: 'hourly', retentionMs: TimeMs.WEEK, timestampMs: bucketTimestampMs('hourly') }, + { key: dailyKey, label: 'daily', retentionMs: DAILY_METRICS_RETENTION_DAYS * TimeMs.DAY, timestampMs: bucketTimestampMs('daily') }, ]; - const operations = series.map(({ key, label, retentionMs }) => ({ - label, - promise: this.redis.safeTsAdd(key, 1, labels, retentionMs), - })); - - const results = await Promise.allSettled(operations.map((op) => op.promise)); - - results.forEach((result, index) => { - if (result.status === 'rejected') { - const { label } = operations[index]; - this.logger.error(`Failed to add ${label} TS for ${metricType}`, result.reason); + for (const { key, label, retentionMs, timestampMs } of series) { + try { + await this.redis.safeTsAdd(key, 1, labels, retentionMs, timestampMs); + } catch (error) { + this.logger.error(`Failed to add ${label} TS for ${metricType}`, error); } - }); + } } /** diff --git a/workers/grouper/src/redisHelper.ts b/workers/grouper/src/redisHelper.ts index 57a35543..242b74f8 100644 --- a/workers/grouper/src/redisHelper.ts +++ b/workers/grouper/src/redisHelper.ts @@ -237,14 +237,16 @@ export default class RedisHelper { * @param value - value to add * @param labels - labels to attach to the time series * @param retentionMs - optional retention in milliseconds + * @param timestampMs - timestamp in milliseconds; defaults to current time */ public async safeTsAdd( key: string, value: number, labels: Record, - retentionMs = 0 + retentionMs = 0, + timestampMs = 0 ): Promise { - const timestamp = Date.now(); + const timestamp = timestampMs === 0 ? Date.now() : timestampMs; /** * Create key if not exists — then call increment diff --git a/workers/grouper/src/utils/bucketTimestamp.ts b/workers/grouper/src/utils/bucketTimestamp.ts new file mode 100644 index 00000000..29dbd8aa --- /dev/null +++ b/workers/grouper/src/utils/bucketTimestamp.ts @@ -0,0 +1,17 @@ +import TimeMs from '../../../../lib/utils/time'; + +/** + * Returns the current time truncated to the start of the given granularity + * bucket in milliseconds (UTC). All events within the same bucket share one + * timestamp so ON_DUPLICATE SUM accumulates them into a single sample. + * + * @param granularity - time granularity level + * @param now - current timestamp in ms, defaults to Date.now() + */ +export function bucketTimestampMs(granularity: 'minutely' | 'hourly' | 'daily', now = Date.now()): number { + switch (granularity) { + case 'hourly': return now - (now % TimeMs.HOUR); + case 'daily': return now - (now % TimeMs.DAY); + default: return now - (now % TimeMs.MINUTE); // minutely + } +} diff --git a/workers/grouper/tests/bucketTimestamp.test.ts b/workers/grouper/tests/bucketTimestamp.test.ts new file mode 100644 index 00000000..53ebac45 --- /dev/null +++ b/workers/grouper/tests/bucketTimestamp.test.ts @@ -0,0 +1,65 @@ +import '../../../env-test'; +import { bucketTimestampMs } from '../src/utils/bucketTimestamp'; + +describe('bucketTimestampMs', () => { + /** + * 2026-04-14T15:37:42.500Z + * minute start: 2026-04-14T15:37:00.000Z + * hour start: 2026-04-14T15:00:00.000Z + * day start: 2026-04-14T00:00:00.000Z + */ + const now = new Date('2026-04-14T15:37:42.500Z').getTime(); + + it('truncates to the start of the current minute', () => { + const expected = new Date('2026-04-14T15:37:00.000Z').getTime(); + + expect(bucketTimestampMs('minutely', now)).toBe(expected); + }); + + it('truncates to the start of the current hour', () => { + const expected = new Date('2026-04-14T15:00:00.000Z').getTime(); + + expect(bucketTimestampMs('hourly', now)).toBe(expected); + }); + + it('truncates to the start of the current day (UTC midnight)', () => { + const expected = new Date('2026-04-14T00:00:00.000Z').getTime(); + + expect(bucketTimestampMs('daily', now)).toBe(expected); + }); + + it('returns the same value for two calls within the same minute', () => { + const t1 = new Date('2026-04-14T15:37:00.000Z').getTime(); + const t2 = new Date('2026-04-14T15:37:59.999Z').getTime(); + + expect(bucketTimestampMs('minutely', t1)).toBe(bucketTimestampMs('minutely', t2)); + }); + + it('returns different values for two calls in different minutes', () => { + const t1 = new Date('2026-04-14T15:37:59.999Z').getTime(); + const t2 = new Date('2026-04-14T15:38:00.000Z').getTime(); + + expect(bucketTimestampMs('minutely', t1)).not.toBe(bucketTimestampMs('minutely', t2)); + }); + + it('returns the same value for two calls within the same hour', () => { + const t1 = new Date('2026-04-14T15:00:00.000Z').getTime(); + const t2 = new Date('2026-04-14T15:59:59.999Z').getTime(); + + expect(bucketTimestampMs('hourly', t1)).toBe(bucketTimestampMs('hourly', t2)); + }); + + it('returns the same value for two calls within the same day', () => { + const t1 = new Date('2026-04-14T00:00:00.000Z').getTime(); + const t2 = new Date('2026-04-14T23:59:59.999Z').getTime(); + + expect(bucketTimestampMs('daily', t1)).toBe(bucketTimestampMs('daily', t2)); + }); + + it('returns different values for two calls on different days', () => { + const t1 = new Date('2026-04-14T23:59:59.999Z').getTime(); + const t2 = new Date('2026-04-15T00:00:00.000Z').getTime(); + + expect(bucketTimestampMs('daily', t1)).not.toBe(bucketTimestampMs('daily', t2)); + }); +}); diff --git a/workers/grouper/tests/index.test.ts b/workers/grouper/tests/index.test.ts index 6ad01812..c75351a9 100644 --- a/workers/grouper/tests/index.test.ts +++ b/workers/grouper/tests/index.test.ts @@ -763,21 +763,24 @@ describe('GrouperWorker', () => { `ts:project-events-accepted:${projectIdMock}:minutely`, 1, expectedLabels, - TimeMs.DAY + TimeMs.DAY, + expect.any(Number), ); expect(safeTsAddSpy).toHaveBeenNthCalledWith( 2, `ts:project-events-accepted:${projectIdMock}:hourly`, 1, expectedLabels, - TimeMs.WEEK + TimeMs.WEEK, + expect.any(Number), ); expect(safeTsAddSpy).toHaveBeenNthCalledWith( 3, `ts:project-events-accepted:${projectIdMock}:daily`, 1, expectedLabels, - 90 * TimeMs.DAY + 90 * TimeMs.DAY, + expect.any(Number), ); } finally { safeTsAddSpy.mockRestore(); From 3092d391931654e57d0fe8767b7b2fe6d29a8572 Mon Sep 17 00:00:00 2001 From: KoshaevEugeny <103786108+akulistus@users.noreply.github.com> Date: Wed, 15 Apr 2026 18:37:19 +0300 Subject: [PATCH 4/5] feat(sentry): include message into title (#541) * feat(sentry): include message into title * fix(sentry): message is included in title if exception is missing --- workers/sentry/src/utils/converter.ts | 8 +++++++- workers/sentry/tests/converter.test.ts | 22 ++++++++++++++++++++++ workers/sentry/tests/index.test.ts | 2 +- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/workers/sentry/src/utils/converter.ts b/workers/sentry/src/utils/converter.ts index 916f158c..ba7f6eb6 100644 --- a/workers/sentry/src/utils/converter.ts +++ b/workers/sentry/src/utils/converter.ts @@ -54,7 +54,13 @@ function flattenObject(obj: unknown, prefix = ''): string[] { * @param eventPayload - Sentry event payload */ export function composeTitle(eventPayload: SentryEvent): string { - return `${eventPayload.exception?.values?.[0]?.type || 'Unknown'}: ${eventPayload.exception?.values?.[0]?.value || ''}`; + const exception = eventPayload.exception?.values?.[0]; + + if (exception) { + return `${exception.type || 'Unknown'}: ${exception.value || ''}`; + } + + return eventPayload.message || 'Unknown: '; } /** diff --git a/workers/sentry/tests/converter.test.ts b/workers/sentry/tests/converter.test.ts index 7c630c69..17517150 100644 --- a/workers/sentry/tests/converter.test.ts +++ b/workers/sentry/tests/converter.test.ts @@ -21,6 +21,28 @@ describe('converter utils', () => { expect(composeTitle(event)).toBe('Unknown: '); }); + + it('should compose title from message if exception is missing', () => { + const event: SentryEvent = { + message: 'message' + }; + + expect(composeTitle(event)).toBe('message'); + }); + + it('should compose title from exception type and value even if message is present', () => { + const event: SentryEvent = { + exception: { + values: [ { + type: 'Error', + value: 'Something went wrong', + } ], + }, + message: 'message' + }; + + expect(composeTitle(event)).toBe('Error: Something went wrong'); + }); }); describe('composeBacktrace()', () => { diff --git a/workers/sentry/tests/index.test.ts b/workers/sentry/tests/index.test.ts index 7d964c1b..6e8f34e2 100644 --- a/workers/sentry/tests/index.test.ts +++ b/workers/sentry/tests/index.test.ts @@ -304,7 +304,7 @@ describe('SentryEventWorker', () => { }, }, catcherVersion: '1.0.1', - title: 'Unknown: ', + title: 'Test timestamp', type: 'error', }, })); From ab8db60b21b970cef1f571870e905a92ba0c838f Mon Sep 17 00:00:00 2001 From: Dobrunia Kostrigin <48620984+Dobrunia@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:40:58 +0300 Subject: [PATCH 5/5] fix: include repetitionId in notification event URLs for Telegram, Slack, and Loop (#544) --- package.json | 2 +- workers/loop/src/templates/event.ts | 3 +- workers/loop/tests/provider.test.ts | 48 ++++++++++++++++++++++++ workers/slack/src/templates/event.ts | 2 +- workers/slack/src/templates/utils.ts | 7 +++- workers/slack/tests/utils.test.ts | 40 ++++++++++++++++++++ workers/telegram/src/templates/event.ts | 3 +- workers/telegram/tests/provider.test.ts | 49 +++++++++++++++++++++++++ 8 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 workers/slack/tests/utils.test.ts diff --git a/package.json b/package.json index 175b7bc5..f2e1e154 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hawk.workers", "private": true, - "version": "0.1.3", + "version": "0.1.4", "description": "Hawk workers", "repository": "git@github.com:codex-team/hawk.workers.git", "license": "BUSL-1.1", diff --git a/workers/loop/src/templates/event.ts b/workers/loop/src/templates/event.ts index bd1f9ced..afc6622e 100644 --- a/workers/loop/src/templates/event.ts +++ b/workers/loop/src/templates/event.ts @@ -39,7 +39,8 @@ function renderBacktrace(event: GroupedEventDBScheme): string { 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 + '/'; + const repetitionId = eventInfo.repetitionId; + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/' + (repetitionId ? repetitionId + '/overview' : ''); let location = 'Неизвестное место'; if (event.payload.backtrace && event.payload.backtrace.length > 0) { diff --git a/workers/loop/tests/provider.test.ts b/workers/loop/tests/provider.test.ts index b8185928..955ad491 100644 --- a/workers/loop/tests/provider.test.ts +++ b/workers/loop/tests/provider.test.ts @@ -137,6 +137,54 @@ describe('LoopProvider', () => { expect(message).toBeDefined(); }); + /** + * Event URL should include repetitionId when provided + */ + describe('event URL contains correct repetitionId', () => { + const eventId = new ObjectId('5d206f7f9aaf7c0071d64597'); + const projectId = new ObjectId('5d206f7f9aaf7c0071d64596'); + const host = 'https://garage.hawk.so'; + + const basePayload = { + events: [ { + event: { + _id: eventId, + totalCount: 1, + timestamp: Date.now(), + payload: { title: 'Err', backtrace: [] }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + } ], + period: 60, + host, + hostOfStatic: '', + project: { + _id: projectId, + token: 'tok', + name: 'P', + workspaceId: projectId, + uidAdded: projectId, + notifications: [], + } as ProjectDBScheme, + }; + + it('should include repetitionId and /overview in URL when repetitionId is set', () => { + const repetitionId = '5d206f7f9aaf7c0071d64599'; + const payload = { ...basePayload, events: [ { ...basePayload.events[0], repetitionId } ] }; + const message = templates.EventTpl(payload); + + expect(message).toContain(`/event/${eventId}/${repetitionId}/overview`); + }); + + it('should omit repetitionId from URL when repetitionId is not set', () => { + const message = templates.EventTpl(basePayload); + + expect(message).toContain(`/event/${eventId}/`); + expect(message).not.toContain('/overview'); + }); + }); + /** * Check that rendering of a several events message works without errors */ diff --git a/workers/slack/src/templates/event.ts b/workers/slack/src/templates/event.ts index d11fa66f..00833bce 100644 --- a/workers/slack/src/templates/event.ts +++ b/workers/slack/src/templates/event.ts @@ -45,7 +45,7 @@ function renderBacktrace(event: GroupedEventDBScheme): string { export default function render(tplData: EventsTemplateVariables): IncomingWebhookSendArguments { const eventInfo = tplData.events[0] as TemplateEventData; const event = eventInfo.event; - const eventURL = getEventUrl(tplData.host, tplData.project, event); + const eventURL = getEventUrl(tplData.host, tplData.project, event, eventInfo.repetitionId); const location = getEventLocation(event); const blocks = [ diff --git a/workers/slack/src/templates/utils.ts b/workers/slack/src/templates/utils.ts index cc0af00d..9b14e586 100644 --- a/workers/slack/src/templates/utils.ts +++ b/workers/slack/src/templates/utils.ts @@ -32,9 +32,12 @@ export function getEventLocation(event: DecodedGroupedEvent): string { * @param host - garage host. Also, can be accessed from process.env.GARAGE_URL * @param project - parent project * @param event - event to compose its URL + * @param repetitionId - id of the specific repetition that triggered the notification */ -export function getEventUrl(host: string, project: ProjectDBScheme, event: GroupedEventDBScheme): string { - return host + '/project/' + project._id + '/event/' + event._id + '/'; +export function getEventUrl(host: string, project: ProjectDBScheme, event: GroupedEventDBScheme, repetitionId?: string | null): string { + const base = host + '/project/' + project._id + '/event/' + event._id + '/'; + + return repetitionId ? base + repetitionId + '/overview' : base; } /** diff --git a/workers/slack/tests/utils.test.ts b/workers/slack/tests/utils.test.ts new file mode 100644 index 00000000..7c67f75d --- /dev/null +++ b/workers/slack/tests/utils.test.ts @@ -0,0 +1,40 @@ +import { ObjectId } from 'mongodb'; +import { ProjectDBScheme, GroupedEventDBScheme } from '@hawk.so/types'; +import { getEventUrl } from '../src/templates/utils'; + +const project = { + _id: new ObjectId('5d206f7f9aaf7c0071d64596'), + token: 'project-token', + name: 'Project', + workspaceId: new ObjectId('5d206f7f9aaf7c0071d64596'), + uidAdded: new ObjectId('5d206f7f9aaf7c0071d64596'), + notifications: [], +} as ProjectDBScheme; + +const event = { + _id: new ObjectId('5d206f7f9aaf7c0071d64597'), + payload: { title: 'Error' }, +} as unknown as GroupedEventDBScheme; + +const host = 'https://garage.hawk.so'; + +describe('getEventUrl', () => { + it('should return base URL with trailing slash when no repetitionId', () => { + const url = getEventUrl(host, project, event); + + expect(url).toBe(`${host}/project/${project._id}/event/${event._id}/`); + }); + + it('should return base URL with trailing slash when repetitionId is null', () => { + const url = getEventUrl(host, project, event, null); + + expect(url).toBe(`${host}/project/${project._id}/event/${event._id}/`); + }); + + it('should append repetitionId and /overview when repetitionId is provided', () => { + const repetitionId = '5d206f7f9aaf7c0071d64599'; + const url = getEventUrl(host, project, event, repetitionId); + + expect(url).toBe(`${host}/project/${project._id}/event/${event._id}/${repetitionId}/overview`); + }); +}); diff --git a/workers/telegram/src/templates/event.ts b/workers/telegram/src/templates/event.ts index c8676c00..46da0cba 100644 --- a/workers/telegram/src/templates/event.ts +++ b/workers/telegram/src/templates/event.ts @@ -8,7 +8,8 @@ import type { EventsTemplateVariables, TemplateEventData } from 'hawk-worker-sen 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 + '/'; + const repetitionId = eventInfo.repetitionId; + const eventURL = tplData.host + '/project/' + tplData.project._id + '/event/' + event._id + '/' + (repetitionId ? repetitionId + '/overview' : ''); let location = ''; if (event.payload.backtrace && event.payload.backtrace.length > 0) { diff --git a/workers/telegram/tests/provider.test.ts b/workers/telegram/tests/provider.test.ts index 7cb4ca4b..51ba45c4 100644 --- a/workers/telegram/tests/provider.test.ts +++ b/workers/telegram/tests/provider.test.ts @@ -2,6 +2,7 @@ import { EventNotification, SeveralEventsNotification } from 'hawk-worker-sender import { DecodedGroupedEvent, ProjectDBScheme } from '@hawk.so/types'; import TelegramProvider from 'hawk-worker-telegram/src/provider'; import templates from '../src/templates'; +import EventTpl from '../src/templates/event'; import { ObjectId } from 'mongodb'; /** @@ -64,6 +65,54 @@ describe('TelegramProvider', () => { expect(message).toBeDefined(); }); + /** + * Event URL should include repetitionId when provided + */ + describe('event URL contains correct repetitionId', () => { + const eventId = new ObjectId('5d206f7f9aaf7c0071d64597'); + const projectId = new ObjectId('5d206f7f9aaf7c0071d64596'); + const host = 'https://garage.hawk.so'; + + const basePayload = { + events: [ { + event: { + _id: eventId, + totalCount: 1, + timestamp: Date.now(), + payload: { title: 'Err', backtrace: [] }, + } as DecodedGroupedEvent, + daysRepeated: 1, + newCount: 1, + } ], + period: 60, + host, + hostOfStatic: '', + project: { + _id: projectId, + token: 'tok', + name: 'P', + workspaceId: projectId, + uidAdded: projectId, + notifications: [], + } as ProjectDBScheme, + }; + + it('should include repetitionId and /overview in URL when repetitionId is set', () => { + const repetitionId = '5d206f7f9aaf7c0071d64599'; + const payload = { ...basePayload, events: [ { ...basePayload.events[0], repetitionId } ] }; + const message = EventTpl(payload); + + expect(message).toContain(`/event/${eventId}/${repetitionId}/overview`); + }); + + it('should omit repetitionId from URL when repetitionId is not set', () => { + const message = EventTpl(basePayload); + + expect(message).toContain(`/event/${eventId}/`); + expect(message).not.toContain('/overview'); + }); + }); + /** * Check that rendering of a several events message works without errors */