* 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
182 lines
6.8 KiB
JavaScript
182 lines
6.8 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|