* 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
354 lines
13 KiB
JavaScript
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
|
|
);
|
|
});
|
|
});
|
|
});
|