tududi/backend/tests/integration/notification-telegram-rate-limit.test.js
Chris 11cd77bedd
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
2026-03-19 20:26:11 +02:00

354 lines
13 KiB
JavaScript

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
);
});
});
});