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
This commit is contained in:
parent
471d29e495
commit
11cd77bedd
8 changed files with 739 additions and 2 deletions
|
|
@ -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'
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
182
backend/tests/unit/models/notification.test.js
Normal file
182
backend/tests/unit/models/notification.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue