From 11cd77bedd596cd0e4b9bd22e3f6df5c4f7d67e6 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 19 Mar 2026 20:26:11 +0200 Subject: [PATCH] Fix Telegram notification spam with channel-level rate limiting (#951) * Fix Telegram notification spam with channel-level rate limiting Addresses issue #950 where Telegram notifications were sent excessively (96-288 messages per day per task) due to the delete-and-recreate pattern added in commit 105a913a to fix navbar notification pile-up. Changes: - Add channel_sent_at JSON field to notifications table to track when each channel (telegram, email, push) was last sent - Add helper methods to notification model: - markChannelAsSent(channel): Records send timestamp - wasChannelRecentlySent(channel, threshold): Checks if sent within 24h - Modify sendTelegramNotification() to check rate limit before sending - Update service layer (dueTaskService, deferredTaskService, dueProjectService) to preserve channel_sent_at when recreating notifications - Add comprehensive unit and integration tests (20 tests, all passing) Impact: - Reduces Telegram notifications from 96-288/day to 1/day per item - Preserves in-app notification refresh behavior (every 5-15 min) - Maintains navbar pile-up fix from original commit - Rate limit configurable (default: 24 hours) Fixes #950 * Fix linting and formatting issues * Fix integration test that was trying to access private function * Fix prettier formatting in integration test --- ...-add-notification-channel-sent-tracking.js | 36 ++ backend/models/notification.js | 60 ++- backend/modules/projects/dueProjectService.js | 7 + backend/modules/tasks/deferredTaskService.js | 7 + backend/modules/tasks/dueTaskService.js | 7 + .../notification-telegram-rate-limit.test.js | 354 ++++++++++++++++++ .../tests/unit/models/notification.test.js | 182 +++++++++ .../unit/modules/tasks/dueTaskService.test.js | 88 +++++ 8 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/20260320000001-add-notification-channel-sent-tracking.js create mode 100644 backend/tests/integration/notification-telegram-rate-limit.test.js create mode 100644 backend/tests/unit/models/notification.test.js diff --git a/backend/migrations/20260320000001-add-notification-channel-sent-tracking.js b/backend/migrations/20260320000001-add-notification-channel-sent-tracking.js new file mode 100644 index 0000000..19366a6 --- /dev/null +++ b/backend/migrations/20260320000001-add-notification-channel-sent-tracking.js @@ -0,0 +1,36 @@ +'use strict'; + +const { + safeAddColumns, + safeRemoveColumn, +} = require('../utils/migration-utils'); + +/** + * Migration to add channel_sent_at JSON column to notifications table. + * This tracks when each notification channel (telegram, email, push) was last sent, + * enabling rate limiting to prevent notification spam. + * + * Example value: {"telegram": "2026-03-19T10:30:00Z", "email": "2026-03-19T11:00:00Z"} + */ +module.exports = { + async up(queryInterface, Sequelize) { + await safeAddColumns(queryInterface, 'notifications', [ + { + name: 'channel_sent_at', + definition: { + type: Sequelize.JSON, + allowNull: true, + defaultValue: null, + }, + }, + ]); + }, + + async down(queryInterface) { + await safeRemoveColumn( + queryInterface, + 'notifications', + 'channel_sent_at' + ); + }, +}; diff --git a/backend/models/notification.js b/backend/models/notification.js index c5a2970..5888dfc 100644 --- a/backend/models/notification.js +++ b/backend/models/notification.js @@ -97,6 +97,11 @@ module.exports = (sequelize) => { type: DataTypes.DATE, allowNull: true, }, + channel_sent_at: { + type: DataTypes.JSON, + allowNull: true, + defaultValue: null, + }, }, { tableName: 'notifications', @@ -142,6 +147,7 @@ module.exports = (sequelize) => { sources = [], sentAt = null, level = 'info', + channel_sent_at = null, }) { const notification = await Notification.create({ user_id: userId, @@ -152,6 +158,7 @@ module.exports = (sequelize) => { sources, level, sent_at: sentAt || new Date(), + channel_sent_at, }); if (sources.includes('email')) { @@ -164,7 +171,8 @@ module.exports = (sequelize) => { title, message, data, - Notification + Notification, + notification ); } @@ -209,7 +217,8 @@ module.exports = (sequelize) => { title, message, data, - NotificationModel + NotificationModel, + notificationInstance ) { try { const telegramService = require('../modules/telegram/telegramNotificationService'); @@ -218,6 +227,18 @@ module.exports = (sequelize) => { return; } + // Check if Telegram was recently sent for this notification (within 24 hours) + // to prevent spam from delete-and-recreate pattern + if ( + notificationInstance && + notificationInstance.wasChannelRecentlySent( + 'telegram', + 24 * 60 * 60 * 1000 + ) + ) { + return; // Skip sending to prevent spam + } + const UserModel = NotificationModel.sequelize.models.User; const user = await UserModel.findByPk(userId, { attributes: [ @@ -236,6 +257,11 @@ module.exports = (sequelize) => { data, level: 'info', }); + + // Mark that Telegram was sent for this notification + if (notificationInstance) { + await notificationInstance.markChannelAsSent('telegram'); + } } } catch (error) { console.error('Failed to send Telegram notification:', error); @@ -275,6 +301,36 @@ module.exports = (sequelize) => { return this.dismissed_at !== null; }; + /** + * Mark a notification channel as sent + * @param {string} channel - The channel name (telegram, email, push) + */ + Notification.prototype.markChannelAsSent = async function (channel) { + const sentTimes = this.channel_sent_at || {}; + sentTimes[channel] = new Date().toISOString(); + this.channel_sent_at = sentTimes; + await this.save(); + return this; + }; + + /** + * Check if a channel was recently sent for this notification + * @param {string} channel - The channel name (telegram, email, push) + * @param {number} thresholdMs - Time threshold in milliseconds (default: 24 hours) + * @returns {boolean} True if channel was sent within threshold + */ + Notification.prototype.wasChannelRecentlySent = function ( + channel, + thresholdMs = 24 * 60 * 60 * 1000 + ) { + if (!this.channel_sent_at || !this.channel_sent_at[channel]) { + return false; + } + const lastSent = new Date(this.channel_sent_at[channel]); + const now = new Date(); + return now - lastSent < thresholdMs; + }; + Notification.getUserNotifications = async function (userId, options = {}) { const { limit = 10, diff --git a/backend/modules/projects/dueProjectService.js b/backend/modules/projects/dueProjectService.js index 51bb310..25bbe59 100644 --- a/backend/modules/projects/dueProjectService.js +++ b/backend/modules/projects/dueProjectService.js @@ -89,6 +89,9 @@ async function checkDueProjects() { notif.type === notificationType ); + // Preserve channel_sent_at for rate limiting when recreating notifications + let preservedChannelSentAt = null; + if (existingNotification) { // If notification was dismissed, don't create it again if (existingNotification.dismissed_at) { @@ -98,6 +101,9 @@ async function checkDueProjects() { // If notification is unread, delete it before creating the new one // This prevents duplicate notifications from piling up if (!existingNotification.read_at) { + // Preserve channel_sent_at to maintain rate limiting across recreations + preservedChannelSentAt = + existingNotification.channel_sent_at; await existingNotification.destroy(); } else { // If it was already read, skip creating a new one @@ -137,6 +143,7 @@ async function checkDueProjects() { isOverdue, }, sentAt: new Date(), + channel_sent_at: preservedChannelSentAt, }); notificationsCreated++; diff --git a/backend/modules/tasks/deferredTaskService.js b/backend/modules/tasks/deferredTaskService.js index 240ab3b..b0a2ead 100644 --- a/backend/modules/tasks/deferredTaskService.js +++ b/backend/modules/tasks/deferredTaskService.js @@ -67,6 +67,9 @@ async function checkDeferredTasks() { notif.data?.reason === 'defer_until_reached' ); + // Preserve channel_sent_at for rate limiting when recreating notifications + let preservedChannelSentAt = null; + if (existingNotification) { // If notification was dismissed, don't create it again if (existingNotification.dismissed_at) { @@ -76,6 +79,9 @@ async function checkDeferredTasks() { // If notification is unread, delete it before creating the new one // This prevents duplicate notifications from piling up if (!existingNotification.read_at) { + // Preserve channel_sent_at to maintain rate limiting across recreations + preservedChannelSentAt = + existingNotification.channel_sent_at; await existingNotification.destroy(); } else { // If it was already read, skip creating a new one @@ -101,6 +107,7 @@ async function checkDeferredTasks() { reason: 'defer_until_reached', }, sentAt: new Date(), + channel_sent_at: preservedChannelSentAt, }); notificationsCreated++; diff --git a/backend/modules/tasks/dueTaskService.js b/backend/modules/tasks/dueTaskService.js index d2815cc..fe599f2 100644 --- a/backend/modules/tasks/dueTaskService.js +++ b/backend/modules/tasks/dueTaskService.js @@ -87,6 +87,9 @@ async function checkDueTasks() { notif.type === notificationType ); + // Preserve channel_sent_at for rate limiting when recreating notifications + let preservedChannelSentAt = null; + if (existingNotification) { // If notification was dismissed, don't create it again if (existingNotification.dismissed_at) { @@ -96,6 +99,9 @@ async function checkDueTasks() { // If notification is unread, delete it before creating the new one // This prevents duplicate notifications from piling up if (!existingNotification.read_at) { + // Preserve channel_sent_at to maintain rate limiting across recreations + preservedChannelSentAt = + existingNotification.channel_sent_at; await existingNotification.destroy(); } else { // If it was already read, skip creating a new one @@ -132,6 +138,7 @@ async function checkDueTasks() { isOverdue, }, sentAt: new Date(), + channel_sent_at: preservedChannelSentAt, }); notificationsCreated++; diff --git a/backend/tests/integration/notification-telegram-rate-limit.test.js b/backend/tests/integration/notification-telegram-rate-limit.test.js new file mode 100644 index 0000000..51d00fc --- /dev/null +++ b/backend/tests/integration/notification-telegram-rate-limit.test.js @@ -0,0 +1,354 @@ +const { Notification, User, Task } = require('../../models'); +const { createTestUser } = require('../helpers/testUtils'); +const telegramNotificationService = require('../../modules/telegram/telegramNotificationService'); + +// Mock the Telegram notification service +jest.mock('../../modules/telegram/telegramNotificationService'); + +describe('Notification Telegram Rate Limiting', () => { + let user; + let sendTelegramSpy; + + beforeEach(async () => { + user = await createTestUser({ + email: 'test@example.com', + telegram_bot_token: '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678', + telegram_chat_id: '123456789', + notification_preferences: { + dueTasks: { + inApp: true, + telegram: true, + }, + overdueTasks: { + inApp: true, + telegram: true, + }, + }, + }); + + // Mock Telegram service methods + telegramNotificationService.isTelegramConfigured = jest + .fn() + .mockReturnValue(true); + sendTelegramSpy = jest + .spyOn(telegramNotificationService, 'sendTelegramNotification') + .mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('First notification creation', () => { + it('should send Telegram notification immediately', async () => { + await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Task Due Soon', + message: 'Your task is due tomorrow', + sources: ['telegram'], + level: 'info', + }); + + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + expect(sendTelegramSpy).toHaveBeenCalledWith( + expect.objectContaining({ + id: user.id, + telegram_bot_token: user.telegram_bot_token, + telegram_chat_id: user.telegram_chat_id, + }), + expect.objectContaining({ + title: 'Task Due Soon', + message: 'Your task is due tomorrow', + level: 'info', + }) + ); + }); + + it('should mark telegram as sent in channel_sent_at', async () => { + const notification = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Task Due Soon', + message: 'Your task is due tomorrow', + sources: ['telegram'], + level: 'info', + }); + + // Reload to get updated data + await notification.reload(); + + expect(notification.channel_sent_at).toBeDefined(); + expect(notification.channel_sent_at.telegram).toBeDefined(); + + const sentTime = new Date(notification.channel_sent_at.telegram); + expect(sentTime).toBeInstanceOf(Date); + expect(sentTime.getTime()).toBeLessThanOrEqual(Date.now()); + }); + }); + + describe('Delete and recreate pattern (navbar pile-up fix)', () => { + it('should NOT resend Telegram when notification recreated within 24 hours', async () => { + // Create initial notification + const firstNotification = await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Task Overdue', + message: 'Your task is now overdue', + sources: ['telegram'], + data: { taskUid: 'test-task-123' }, + level: 'warning', + }); + + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + + // Verify channel_sent_at was set + await firstNotification.reload(); + expect(firstNotification.channel_sent_at).toBeDefined(); + expect(firstNotification.channel_sent_at.telegram).toBeDefined(); + expect(firstNotification.wasChannelRecentlySent('telegram')).toBe( + true + ); + + sendTelegramSpy.mockClear(); + + // Simulate delete-and-recreate pattern (what cron jobs do) + const preservedChannelSentAt = firstNotification.channel_sent_at; + await firstNotification.destroy(); + + // Create new notification with preserved channel_sent_at (within 24h) + // This simulates what the service layer does + const secondNotification = await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Task Overdue', + message: 'Your task is still overdue', + sources: ['telegram'], + data: { taskUid: 'test-task-123' }, + level: 'warning', + channel_sent_at: preservedChannelSentAt, + }); + + // Telegram should NOT have been sent again (rate limited) + expect(sendTelegramSpy).toHaveBeenCalledTimes(0); + + // Channel tracking should be preserved + expect(secondNotification.channel_sent_at).toEqual( + preservedChannelSentAt + ); + expect(secondNotification.wasChannelRecentlySent('telegram')).toBe( + true + ); + }); + + it('should NOT send Telegram multiple times for same notification context', async () => { + // Simulate the actual cron job pattern: + // 1. Create notification + // 2. Check for existing notification + // 3. Delete if exists and unread + // 4. Create new notification + + const createNotificationWithTelegramTracking = async () => { + // Check for existing notification + const existing = await Notification.findOne({ + where: { + user_id: user.id, + type: 'task_due_soon', + }, + order: [['created_at', 'DESC']], + }); + + let channelSentAt = null; + if (existing && !existing.dismissed_at && !existing.read_at) { + // Preserve channel tracking before deletion + channelSentAt = existing.channel_sent_at; + await existing.destroy(); + } + + // Create new notification, preserving channel_sent_at + const notification = await Notification.create({ + user_id: user.id, + type: 'task_due_soon', + title: 'Task Due Soon', + message: 'Your task is due tomorrow', + sources: ['telegram'], + level: 'info', + sent_at: new Date(), + channel_sent_at: channelSentAt, + }); + + // Only send if not recently sent + if ( + !notification.wasChannelRecentlySent( + 'telegram', + 24 * 60 * 60 * 1000 + ) + ) { + await telegramNotificationService.sendTelegramNotification( + user, + { + title: notification.title, + message: notification.message, + level: notification.level, + } + ); + await notification.markChannelAsSent('telegram'); + } + + return notification; + }; + + // First creation - should send + await createNotificationWithTelegramTracking(); + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + sendTelegramSpy.mockClear(); + + // Second creation within 24h - should NOT send + await createNotificationWithTelegramTracking(); + expect(sendTelegramSpy).toHaveBeenCalledTimes(0); + + // Third creation within 24h - should NOT send + await createNotificationWithTelegramTracking(); + expect(sendTelegramSpy).toHaveBeenCalledTimes(0); + }); + }); + + describe('Telegram rate limit threshold', () => { + it('should resend Telegram after 24 hours have passed', async () => { + // Create notification with telegram sent 25 hours ago + const moreThanADayAgo = new Date(); + moreThanADayAgo.setHours(moreThanADayAgo.getHours() - 25); + + const notification = await Notification.create({ + user_id: user.id, + type: 'task_overdue', + title: 'Task Overdue', + message: 'Your task is still overdue', + sources: ['telegram'], + level: 'warning', + sent_at: new Date(), + channel_sent_at: { + telegram: moreThanADayAgo.toISOString(), + }, + }); + + // Channel was sent more than 24h ago + expect(notification.wasChannelRecentlySent('telegram')).toBe(false); + + // Now create a new notification via createNotification + // (simulating cron job running after 24h) + await notification.destroy(); + + await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Task Overdue', + message: 'Your task is still overdue', + sources: ['telegram'], + level: 'warning', + }); + + // Should have sent Telegram again + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('User dismisses notification', () => { + it('should not create new notification if previous was dismissed', async () => { + // This tests existing behavior - not related to rate limiting + // but important for overall notification flow + + const notification = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Task Due Soon', + message: 'Your task is due tomorrow', + sources: ['telegram'], + level: 'info', + }); + + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + + // User dismisses the notification + await notification.dismiss(); + + // Cron job should check for dismissed_at and NOT create new notification + // (This is handled in the service layer, not model layer) + // So Telegram won't be sent again + }); + }); + + describe('Different notification types', () => { + it('should track telegram sends independently for different types', async () => { + // Create due_soon notification + const dueSoonNotif = await Notification.createNotification({ + userId: user.id, + type: 'task_due_soon', + title: 'Task Due Soon', + message: 'Task due tomorrow', + sources: ['telegram'], + level: 'info', + }); + + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + sendTelegramSpy.mockClear(); + + // Create overdue notification for same task + // (different type, so different notification) + const overdueNotif = await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Task Overdue', + message: 'Task is now overdue', + sources: ['telegram'], + level: 'warning', + }); + + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + + // Both notifications should have their own channel_sent_at + await dueSoonNotif.reload(); + await overdueNotif.reload(); + + expect(dueSoonNotif.channel_sent_at.telegram).toBeDefined(); + expect(overdueNotif.channel_sent_at.telegram).toBeDefined(); + }); + }); + + describe('Multiple tasks', () => { + it('should rate limit each task notification independently', async () => { + // Create notification for task 1 + const task1Notif = await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Task 1 Overdue', + message: 'Task 1 is overdue', + sources: ['telegram'], + data: { taskUid: 'task-1' }, + level: 'warning', + }); + + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + sendTelegramSpy.mockClear(); + + // Create notification for task 2 + const task2Notif = await Notification.createNotification({ + userId: user.id, + type: 'task_overdue', + title: 'Task 2 Overdue', + message: 'Task 2 is overdue', + sources: ['telegram'], + data: { taskUid: 'task-2' }, + level: 'warning', + }); + + // Should send for task 2 (different notification) + expect(sendTelegramSpy).toHaveBeenCalledTimes(1); + + // Each has their own rate limiting + expect(task1Notif.channel_sent_at).not.toBe( + task2Notif.channel_sent_at + ); + }); + }); +}); diff --git a/backend/tests/unit/models/notification.test.js b/backend/tests/unit/models/notification.test.js new file mode 100644 index 0000000..a6a3fbb --- /dev/null +++ b/backend/tests/unit/models/notification.test.js @@ -0,0 +1,182 @@ +const { Notification, User } = require('../../../models'); + +describe('Notification Model', () => { + let testUser; + + beforeEach(async () => { + const bcrypt = require('bcrypt'); + testUser = await User.create({ + email: 'test@example.com', + password_digest: await bcrypt.hash('password123', 10), + }); + }); + + describe('channel tracking methods', () => { + let notification; + + beforeEach(async () => { + notification = await Notification.create({ + user_id: testUser.id, + type: 'task_due_soon', + title: 'Test Notification', + message: 'This is a test notification', + sources: ['telegram'], + level: 'info', + sent_at: new Date(), + }); + }); + + describe('markChannelAsSent', () => { + it('should mark a channel as sent with current timestamp', async () => { + const beforeMark = new Date(); + await notification.markChannelAsSent('telegram'); + + expect(notification.channel_sent_at).toBeDefined(); + expect(notification.channel_sent_at.telegram).toBeDefined(); + + const sentTime = new Date( + notification.channel_sent_at.telegram + ); + expect(sentTime).toBeInstanceOf(Date); + expect(sentTime.getTime()).toBeGreaterThanOrEqual( + beforeMark.getTime() + ); + }); + + it('should track multiple channels independently', async () => { + await notification.markChannelAsSent('telegram'); + await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay + await notification.markChannelAsSent('email'); + + expect(notification.channel_sent_at.telegram).toBeDefined(); + expect(notification.channel_sent_at.email).toBeDefined(); + + const telegramTime = new Date( + notification.channel_sent_at.telegram + ); + const emailTime = new Date(notification.channel_sent_at.email); + + expect(emailTime.getTime()).toBeGreaterThanOrEqual( + telegramTime.getTime() + ); + }); + + it('should update existing channel timestamp when marked again', async () => { + await notification.markChannelAsSent('telegram'); + const firstTime = notification.channel_sent_at.telegram; + + await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay + await notification.markChannelAsSent('telegram'); + const secondTime = notification.channel_sent_at.telegram; + + expect(secondTime).not.toBe(firstTime); + expect(new Date(secondTime).getTime()).toBeGreaterThan( + new Date(firstTime).getTime() + ); + }); + + it('should persist to database', async () => { + await notification.markChannelAsSent('telegram'); + + const reloaded = await Notification.findByPk(notification.id); + expect(reloaded.channel_sent_at).toBeDefined(); + expect(reloaded.channel_sent_at.telegram).toBe( + notification.channel_sent_at.telegram + ); + }); + }); + + describe('wasChannelRecentlySent', () => { + it('should return false when channel was never sent', () => { + expect(notification.wasChannelRecentlySent('telegram')).toBe( + false + ); + }); + + it('should return false when channel_sent_at is null', () => { + notification.channel_sent_at = null; + expect(notification.wasChannelRecentlySent('telegram')).toBe( + false + ); + }); + + it('should return true when channel was sent within threshold', async () => { + await notification.markChannelAsSent('telegram'); + expect( + notification.wasChannelRecentlySent('telegram', 1000) + ).toBe(true); + }); + + it('should return false when channel was sent outside threshold', async () => { + // Manually set a timestamp from 2 days ago + const twoDaysAgo = new Date(); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + + notification.channel_sent_at = { + telegram: twoDaysAgo.toISOString(), + }; + + // Check with 24h threshold (should be false) + expect( + notification.wasChannelRecentlySent( + 'telegram', + 24 * 60 * 60 * 1000 + ) + ).toBe(false); + }); + + it('should use default threshold of 24 hours', async () => { + const oneDayAgo = new Date(); + oneDayAgo.setHours(oneDayAgo.getHours() - 23); // 23 hours ago + + notification.channel_sent_at = { + telegram: oneDayAgo.toISOString(), + }; + + // Should be true (within 24h default) + expect(notification.wasChannelRecentlySent('telegram')).toBe( + true + ); + + // Set to 25 hours ago + const moreThanADayAgo = new Date(); + moreThanADayAgo.setHours(moreThanADayAgo.getHours() - 25); + notification.channel_sent_at = { + telegram: moreThanADayAgo.toISOString(), + }; + + // Should be false (outside 24h default) + expect(notification.wasChannelRecentlySent('telegram')).toBe( + false + ); + }); + + it('should check channels independently', async () => { + await notification.markChannelAsSent('telegram'); + + expect(notification.wasChannelRecentlySent('telegram')).toBe( + true + ); + expect(notification.wasChannelRecentlySent('email')).toBe( + false + ); + }); + + it('should handle different thresholds for different channels', async () => { + await notification.markChannelAsSent('telegram'); + await notification.markChannelAsSent('email'); + + // Both within 1 hour + expect( + notification.wasChannelRecentlySent( + 'telegram', + 60 * 60 * 1000 + ) + ).toBe(true); + expect( + notification.wasChannelRecentlySent('email', 60 * 60 * 1000) + ).toBe(true); + }); + }); + }); +}); diff --git a/backend/tests/unit/modules/tasks/dueTaskService.test.js b/backend/tests/unit/modules/tasks/dueTaskService.test.js index 905805f..1466311 100644 --- a/backend/tests/unit/modules/tasks/dueTaskService.test.js +++ b/backend/tests/unit/modules/tasks/dueTaskService.test.js @@ -301,5 +301,93 @@ describe('dueTaskService', () => { expect(result.notificationsCreated).toBe(0); }); }); + + describe('Telegram rate limiting', () => { + const telegramNotificationService = require('../../../../modules/telegram/telegramNotificationService'); + + beforeEach(() => { + // Mock Telegram service + jest.spyOn( + telegramNotificationService, + 'isTelegramConfigured' + ).mockReturnValue(true); + jest.spyOn( + telegramNotificationService, + 'sendTelegramNotification' + ).mockResolvedValue({ success: true }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should not resend Telegram if notification recreated within 24 hours', async () => { + // Setup user with Telegram enabled + user.telegram_bot_token = + '123456789:ABCdefGHIjklMNOPQRSTUVwxyz-12345678'; + user.telegram_chat_id = '123456789'; + user.notification_preferences = { + dueTasks: { inApp: true, telegram: true }, + overdueTasks: { inApp: true, telegram: true }, + }; + await user.save(); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const task = await Task.create({ + name: 'Test Task', + user_id: user.id, + due_date: tomorrow, + status: Task.STATUS.NOT_STARTED, + }); + + const sendTelegramSpy = jest.spyOn( + telegramNotificationService, + 'sendTelegramNotification' + ); + + // First check - should send Telegram + await checkDueTasks(); + + const firstCallCount = sendTelegramSpy.mock.calls.length; + expect(firstCallCount).toBeGreaterThan(0); + + // Get the created notification + const notification = await Notification.findOne({ + where: { + user_id: user.id, + type: 'task_due_soon', + }, + order: [['created_at', 'DESC']], + }); + + expect(notification).not.toBeNull(); + expect(notification.channel_sent_at).toBeDefined(); + expect(notification.channel_sent_at.telegram).toBeDefined(); + + // Verify notification is still within 24h threshold + expect(notification.wasChannelRecentlySent('telegram')).toBe( + true + ); + + sendTelegramSpy.mockClear(); + + // Second check within 24h - notification will be recreated but Telegram should NOT be resent + await checkDueTasks(); + + const secondCallCount = sendTelegramSpy.mock.calls.length; + expect(secondCallCount).toBe(0); + + // Notification should still exist (recreated in-app) + const notifications = await Notification.findAll({ + where: { + user_id: user.id, + type: 'task_due_soon', + }, + }); + expect(notifications.length).toBe(1); + }); + }); }); });