tududi/backend/tests/unit/modules/tasks/dueTaskService.test.js
Chris 105a913a8d
Fix notification deduplication to prevent pile-up in navbar (#945)
* Fix notification deduplication to prevent pile-up in navbar (#944)

When tasks/projects remained pending for multiple days, duplicate
notifications accumulated in the navbar instead of showing only the
most recent one.

Updated notification services to properly handle existing notifications:
- Delete unread notifications before creating new ones
- Respect dismissed notifications (don't recreate)
- Respect read notifications (don't duplicate)

Changes:
- Updated dueTaskService, deferredTaskService, and dueProjectService
- Added comprehensive unit tests with 8 test cases

Fixes #944

* Fix lint errors and add GitHub bug template reminder to docs
2026-03-14 19:45:24 +02:00

305 lines
11 KiB
JavaScript

const { Task, Notification, User } = require('../../../../models');
const { checkDueTasks } = require('../../../../modules/tasks/dueTaskService');
const bcrypt = require('bcrypt');
describe('dueTaskService', () => {
let user;
beforeEach(async () => {
user = await User.create({
email: 'test@example.com',
password_digest: await bcrypt.hash('password123', 10),
notification_preferences: {
inApp: {
task_due_soon: true,
task_overdue: true,
},
},
});
});
describe('checkDueTasks', () => {
describe('notification deduplication', () => {
it('should delete existing unread notification before creating a new one', async () => {
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,
});
// Create initial notification
const firstNotification = await Notification.createNotification(
{
userId: user.id,
type: 'task_due_soon',
title: 'Task due soon',
message: 'Your task "Test Task" is due tomorrow',
data: {
taskUid: task.uid,
taskName: task.name,
dueDate: task.due_date,
isOverdue: false,
},
}
);
expect(firstNotification.read_at).toBeFalsy();
// Run the check again (simulating the next day's cron job)
await checkDueTasks();
// First notification should be deleted
const deletedNotification = await Notification.findByPk(
firstNotification.id
);
expect(deletedNotification).toBeNull();
// New notification should exist
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_due_soon',
},
});
expect(notifications.length).toBe(1);
expect(notifications[0].id).not.toBe(firstNotification.id);
expect(notifications[0].data.taskUid).toBe(task.uid);
});
it('should not create duplicate if previous notification was read', async () => {
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,
});
// Create and mark as read
const firstNotification = await Notification.createNotification(
{
userId: user.id,
type: 'task_due_soon',
title: 'Task due soon',
message: 'Your task "Test Task" is due tomorrow',
data: {
taskUid: task.uid,
taskName: task.name,
dueDate: task.due_date,
isOverdue: false,
},
}
);
await firstNotification.markAsRead();
// Run the check again
const result = await checkDueTasks();
// Should not create a new notification
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_due_soon',
},
});
expect(notifications.length).toBe(1);
expect(notifications[0].id).toBe(firstNotification.id);
expect(result.notificationsCreated).toBe(0);
});
it('should not create duplicate if previous notification was dismissed', async () => {
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,
});
// Create and dismiss
const firstNotification = await Notification.createNotification(
{
userId: user.id,
type: 'task_due_soon',
title: 'Task due soon',
message: 'Your task "Test Task" is due tomorrow',
data: {
taskUid: task.uid,
taskName: task.name,
dueDate: task.due_date,
isOverdue: false,
},
}
);
await firstNotification.dismiss();
// Run the check again
const result = await checkDueTasks();
// Should not create a new notification
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_due_soon',
},
});
expect(notifications.length).toBe(1);
expect(notifications[0].id).toBe(firstNotification.id);
expect(result.notificationsCreated).toBe(0);
});
it('should handle transition from task_due_soon to task_overdue', async () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
due_date: yesterday,
status: Task.STATUS.NOT_STARTED,
});
// Create task_due_soon notification (from when it was due tomorrow)
await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Task due soon',
message: 'Your task "Test Task" is due tomorrow',
data: {
taskUid: task.uid,
taskName: task.name,
dueDate: task.due_date,
isOverdue: false,
},
});
// Run the check (task is now overdue)
await checkDueTasks();
// Should create task_overdue notification
const notifications = await Notification.findAll({
where: {
user_id: user.id,
},
order: [['created_at', 'DESC']],
});
// Both notifications should exist (different types)
expect(notifications.length).toBe(2);
expect(notifications[0].type).toBe('task_overdue');
expect(notifications[1].type).toBe('task_due_soon');
});
it('should only keep one notification for the same overdue task over multiple days', async () => {
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
const task = await Task.create({
name: 'Test Task',
user_id: user.id,
due_date: threeDaysAgo,
status: Task.STATUS.NOT_STARTED,
});
// Simulate 3 days of cron jobs
for (let i = 0; i < 3; i++) {
await checkDueTasks();
}
// Should only have 1 notification
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_overdue',
},
});
expect(notifications.length).toBe(1);
expect(notifications[0].data.taskUid).toBe(task.uid);
});
});
describe('basic functionality', () => {
it('should create notification for task due tomorrow', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
await Task.create({
name: 'Test Task',
user_id: user.id,
due_date: tomorrow,
status: Task.STATUS.NOT_STARTED,
});
const result = await checkDueTasks();
expect(result.success).toBe(true);
expect(result.notificationsCreated).toBe(1);
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_due_soon',
},
});
expect(notifications.length).toBe(1);
});
it('should create notification for overdue task', async () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
await Task.create({
name: 'Overdue Task',
user_id: user.id,
due_date: yesterday,
status: Task.STATUS.NOT_STARTED,
});
const result = await checkDueTasks();
expect(result.success).toBe(true);
expect(result.notificationsCreated).toBe(1);
const notifications = await Notification.findAll({
where: {
user_id: user.id,
type: 'task_overdue',
},
});
expect(notifications.length).toBe(1);
});
it('should not create notification for completed tasks', async () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
await Task.create({
name: 'Completed Task',
user_id: user.id,
due_date: tomorrow,
status: Task.STATUS.DONE,
});
const result = await checkDueTasks();
expect(result.notificationsCreated).toBe(0);
});
});
});
});