tududi/backend/tests/unit/models/notification.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

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