tududi/backend/tests/integration/recurring-tasks.test.js
2025-12-28 17:08:33 +02:00

1164 lines
44 KiB
JavaScript

const request = require('supertest');
const app = require('../../app');
const { Task, RecurringCompletion, sequelize } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
const { calculateNextDueDate } = require('../../services/recurringTaskService');
describe('Recurring Tasks', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
describe('Initial Due Date Calculation', () => {
describe('Daily Recurrence', () => {
it('should set correct due date for daily recurring task', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const taskData = {
name: 'Daily Exercise',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
expect(response.body.recurrence_type).toBe('daily');
expect(response.body.recurrence_interval).toBe(1);
// Verify the task was created with the correct due date
const createdTask = await Task.findByPk(response.body.id);
expect(createdTask.due_date).toBeDefined();
expect(
new Date(createdTask.due_date).toISOString().split('T')[0]
).toBe(today.toISOString().split('T')[0]);
});
it('should handle daily recurrence with interval of 2', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const taskData = {
name: 'Every Other Day Task',
recurrence_type: 'daily',
recurrence_interval: 2,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
expect(response.body.recurrence_type).toBe('daily');
expect(response.body.recurrence_interval).toBe(2);
// Calculate next occurrence
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
const expectedDate = new Date(today);
expectedDate.setDate(expectedDate.getDate() + 2);
expect(nextDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
});
it('should handle daily recurrence with interval of 7', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const taskData = {
name: 'Weekly via Daily',
recurrence_type: 'daily',
recurrence_interval: 7,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
const expectedDate = new Date(today);
expectedDate.setDate(expectedDate.getDate() + 7);
expect(nextDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
});
});
describe('Weekly Recurrence', () => {
it('should set correct due date for weekly recurring task', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayWeekday = today.getDay();
const taskData = {
name: 'Weekly Meeting',
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: todayWeekday,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
expect(response.body.recurrence_type).toBe('weekly');
expect(response.body.recurrence_weekday).toBe(todayWeekday);
const createdTask = await Task.findByPk(response.body.id);
expect(createdTask.due_date).toBeDefined();
});
it('should calculate next week for weekly recurrence', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayWeekday = today.getDay();
const taskData = {
name: 'Weekly Review',
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: todayWeekday,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
const expectedDate = new Date(today);
expectedDate.setDate(expectedDate.getDate() + 7);
expect(nextDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
});
it('should handle bi-weekly recurrence', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayWeekday = today.getDay();
const taskData = {
name: 'Bi-weekly Task',
recurrence_type: 'weekly',
recurrence_interval: 2,
recurrence_weekday: todayWeekday,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
const expectedDate = new Date(today);
expectedDate.setDate(expectedDate.getDate() + 14);
expect(nextDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
});
it('should handle weekly recurrence on a different weekday', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayWeekday = today.getDay();
// Target a different weekday (e.g., if today is Monday (1), target Friday (5))
const targetWeekday = (todayWeekday + 4) % 7;
const taskData = {
name: 'Weekly on Different Day',
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: targetWeekday,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
expect(nextDate.getDay()).toBe(targetWeekday);
});
});
describe('Monthly Recurrence', () => {
it('should set correct due date for monthly recurring task', async () => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const dayOfMonth = today.getUTCDate();
const taskData = {
name: 'Monthly Report',
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: dayOfMonth,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
expect(response.body.recurrence_type).toBe('monthly');
expect(response.body.recurrence_month_day).toBe(dayOfMonth);
const createdTask = await Task.findByPk(response.body.id);
expect(createdTask.due_date).toBeDefined();
});
it('should calculate next month for monthly recurrence', async () => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const dayOfMonth = 15; // Use a safe day that exists in all months
const taskData = {
name: 'Monthly Bill',
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: dayOfMonth,
due_date: new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
dayOfMonth
)
)
.toISOString()
.split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
const expectedMonth = (today.getUTCMonth() + 1) % 12;
expect(nextDate.getUTCDate()).toBe(dayOfMonth);
expect(nextDate.getUTCMonth()).toBe(expectedMonth);
});
it('should handle monthly recurrence on last day of month', async () => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const taskData = {
name: 'End of Month Task',
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
// Should be last day of next month
const expectedDate = new Date(
Date.UTC(
nextDate.getUTCFullYear(),
nextDate.getUTCMonth() + 1,
0
)
);
expect(nextDate.getUTCDate()).toBe(expectedDate.getUTCDate());
});
it('should not skip months for monthly_last_day when starting from 31st', async () => {
// Bug fix: Jan 31 -> should go to Feb 28, not March 31
const jan31 = new Date(Date.UTC(2025, 0, 31, 0, 0, 0, 0));
const taskData = {
name: 'End of Month Task',
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
due_date: jan31.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
// First occurrence: Jan 31 -> Feb 28
const nextDate1 = calculateNextDueDate(task, jan31);
expect(nextDate1.getUTCMonth()).toBe(1); // February
expect(nextDate1.getUTCDate()).toBe(28);
// Second occurrence: Feb 28 -> Mar 31
const nextDate2 = calculateNextDueDate(task, nextDate1);
expect(nextDate2.getUTCMonth()).toBe(2); // March
expect(nextDate2.getUTCDate()).toBe(31);
// Third occurrence: Mar 31 -> Apr 30
const nextDate3 = calculateNextDueDate(task, nextDate2);
expect(nextDate3.getUTCMonth()).toBe(3); // April
expect(nextDate3.getUTCDate()).toBe(30);
});
it('should handle monthly recurrence when day does not exist in target month', async () => {
// Create a task for Jan 31
const jan31 = new Date(Date.UTC(2024, 0, 31, 0, 0, 0, 0));
const taskData = {
name: 'Monthly on 31st',
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: 31,
due_date: jan31.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
// Next occurrence should be Feb 29 (if leap year) or Feb 28
const nextDate = calculateNextDueDate(task, jan31);
// February should cap at the last day of the month
expect(nextDate.getUTCMonth()).toBe(1); // February
expect(nextDate.getUTCDate()).toBeLessThanOrEqual(29);
});
});
describe('Monthly Weekday Recurrence', () => {
it('should handle first Monday of the month', async () => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const taskData = {
name: 'First Monday Meeting',
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
recurrence_week_of_month: 1, // First week
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
expect(response.status).toBe(201);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
// Should be a Monday
expect(nextDate.getUTCDay()).toBe(1);
});
it('should handle third Friday of the month', async () => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const taskData = {
name: 'Third Friday Task',
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 5, // Friday
recurrence_week_of_month: 3, // Third week
due_date: today.toISOString().split('T')[0],
};
const response = await agent.post('/api/task').send(taskData);
const task = await Task.findByPk(response.body.id);
const nextDate = calculateNextDueDate(
task,
new Date(task.due_date)
);
// Should be a Friday
expect(nextDate.getUTCDay()).toBe(5);
});
});
});
describe('Due Date Refresh on Completion', () => {
it('should advance due date when daily recurring task is completed', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Create a daily recurring task
const task = await Task.create({
name: 'Daily Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const originalDueDate = new Date(task.due_date);
// Mark the task as completed
const response = await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
expect(response.status).toBe(200);
// Reload the task
await task.reload();
// Task should be reset to NOT_STARTED
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
expect(task.completed_at).toBeNull();
// Due date should be advanced by 1 day
const newDueDate = new Date(task.due_date);
const expectedDate = new Date(originalDueDate);
expectedDate.setDate(expectedDate.getDate() + 1);
expect(newDueDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
// Verify RecurringCompletion was created
const completions = await RecurringCompletion.findAll({
where: { task_id: task.id },
});
expect(completions.length).toBe(1);
expect(completions[0].original_due_date).toBeDefined();
});
it('should advance due date when weekly recurring task is completed', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayWeekday = today.getDay();
const task = await Task.create({
name: 'Weekly Task',
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: todayWeekday,
due_date: today,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const originalDueDate = new Date(task.due_date);
// Complete the task
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Due date should be advanced by 1 week
const newDueDate = new Date(task.due_date);
const expectedDate = new Date(originalDueDate);
expectedDate.setDate(expectedDate.getDate() + 7);
expect(newDueDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
});
it('should advance due date when monthly recurring task is completed', async () => {
const today = new Date();
today.setUTCHours(0, 0, 0, 0);
const dayOfMonth = 15; // Use a safe day
const startDate = new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
dayOfMonth
)
);
const task = await Task.create({
name: 'Monthly Task',
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: dayOfMonth,
due_date: startDate,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const originalMonth = new Date(task.due_date).getUTCMonth();
// Complete the task
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Due date should be in the next month
const newDueDate = new Date(task.due_date);
const expectedMonth = (originalMonth + 1) % 12;
expect(newDueDate.getUTCMonth()).toBe(expectedMonth);
expect(newDueDate.getUTCDate()).toBe(dayOfMonth);
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
});
it('should track multiple completions in RecurringCompletion table', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Daily Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete the task three times
for (let i = 0; i < 3; i++) {
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
}
// Verify three completions were recorded
const completions = await RecurringCompletion.findAll({
where: { task_id: task.id },
order: [['completed_at', 'ASC']],
});
expect(completions.length).toBe(3);
// Verify each completion has the correct original_due_date
for (let i = 0; i < 3; i++) {
expect(completions[i].original_due_date).toBeDefined();
expect(completions[i].skipped).toBe(false);
}
});
});
describe('Completion-Based Recurrence', () => {
it('should use completion date when completion_based is true', async () => {
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
threeDaysAgo.setHours(0, 0, 0, 0);
// Create a task due 3 days ago with completion-based recurrence
const task = await Task.create({
name: 'Completion Based Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: threeDaysAgo,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete it today
const completionTime = new Date();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Next due date should be tomorrow (completion date + interval)
// not 2 days ago (original due date + interval)
const newDueDate = new Date(task.due_date);
const tomorrow = new Date(completionTime);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
const dayDiff = Math.round(
(newDueDate - completionTime) / (1000 * 60 * 60 * 24)
);
// Should be approximately 1 day from completion
expect(dayDiff).toBeGreaterThanOrEqual(0);
expect(dayDiff).toBeLessThanOrEqual(1);
});
it('should use original due date when completion_based is false', async () => {
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
threeDaysAgo.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Date Based Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: threeDaysAgo,
completion_based: false,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const originalDueDate = new Date(task.due_date);
// Complete it today
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Next due date should be 2 days ago (original due date + 1 day)
const newDueDate = new Date(task.due_date);
const expectedDate = new Date(originalDueDate);
expectedDate.setDate(expectedDate.getDate() + 1);
expect(newDueDate.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
});
it('should use completion date for early completion when completion_based is true', async () => {
const twoDaysFromNow = new Date();
twoDaysFromNow.setDate(twoDaysFromNow.getDate() + 2);
twoDaysFromNow.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Early Completion Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: twoDaysFromNow,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete it today (2 days early)
const completionTime = new Date();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Next due date should be tomorrow (today + 1 day)
// not 3 days from now (original due date + 1 day)
const newDueDate = new Date(task.due_date);
const dayDiff = Math.round(
(newDueDate - completionTime) / (1000 * 60 * 60 * 24)
);
// Should be approximately 1 day from completion (today)
expect(dayDiff).toBeGreaterThanOrEqual(0);
expect(dayDiff).toBeLessThanOrEqual(1);
// Verify it's NOT 3 days from now
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
expect(newDueDate.toISOString().split('T')[0]).not.toBe(
threeDaysFromNow.toISOString().split('T')[0]
);
});
it('should work with weekly completion_based recurrence', async () => {
const lastWeek = new Date();
lastWeek.setDate(lastWeek.getDate() - 7);
lastWeek.setHours(0, 0, 0, 0);
const lastWeekWeekday = lastWeek.getDay();
const task = await Task.create({
name: 'Weekly Completion Based',
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: lastWeekWeekday,
due_date: lastWeek,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete it today
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Next due date should be approximately 7 days from today
// not 7 days from last week (which would be today)
const newDueDate = new Date(task.due_date);
const today = new Date();
const dayDiff = Math.round(
(newDueDate - today) / (1000 * 60 * 60 * 24)
);
// Should be around 6-7 days from now
expect(dayDiff).toBeGreaterThanOrEqual(6);
expect(dayDiff).toBeLessThanOrEqual(8);
});
it('should work with monthly completion_based recurrence', async () => {
const lastMonth = new Date();
lastMonth.setMonth(lastMonth.getMonth() - 1);
lastMonth.setUTCHours(0, 0, 0, 0);
const dayOfMonth = 15;
const task = await Task.create({
name: 'Monthly Completion Based',
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: dayOfMonth,
due_date: new Date(
Date.UTC(
lastMonth.getUTCFullYear(),
lastMonth.getUTCMonth(),
dayOfMonth
)
),
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete it today
const completionMonth = new Date().getUTCMonth();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Next due date should be approximately 1 month from today
// not 1 month from last month (which would be this month)
const newDueDate = new Date(task.due_date);
const expectedMonth = (completionMonth + 1) % 12;
// Should be next month
expect(newDueDate.getUTCMonth()).toBe(expectedMonth);
});
it('should handle interval > 1 with completion_based', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Every 3 Days Completion Based',
recurrence_type: 'daily',
recurrence_interval: 3,
due_date: today,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const completionTime = new Date();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Next due date should be 3 days from completion
const newDueDate = new Date(task.due_date);
const dayDiff = Math.round(
(newDueDate - completionTime) / (1000 * 60 * 60 * 24)
);
// Should be approximately 3 days from completion
expect(dayDiff).toBeGreaterThanOrEqual(2);
expect(dayDiff).toBeLessThanOrEqual(3);
});
it('should respect updated completion_based flag', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Create task with completion_based = false
const task = await Task.create({
name: 'Toggle Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
completion_based: false,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Update to completion_based = true
await agent.patch(`/api/task/${task.uid}`).send({
completion_based: true,
});
await task.reload();
expect(task.completion_based).toBe(true);
// Now complete it - should use completion-based logic
const completionTime = new Date();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Should advance from completion time, not original due date
const newDueDate = new Date(task.due_date);
const dayDiff = Math.round(
(newDueDate - completionTime) / (1000 * 60 * 60 * 24)
);
expect(dayDiff).toBeGreaterThanOrEqual(0);
expect(dayDiff).toBeLessThanOrEqual(1);
});
it('should handle multiple rapid completions with completion_based', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Rapid Completions',
recurrence_type: 'daily',
recurrence_interval: 2,
due_date: today,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// First completion
const firstCompletionTime = new Date();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
const firstNewDueDate = new Date(task.due_date);
// Should be approximately 2 days from completion
const firstDayDiff = Math.round(
(firstNewDueDate - firstCompletionTime) / (1000 * 60 * 60 * 24)
);
expect(firstDayDiff).toBeGreaterThanOrEqual(1);
expect(firstDayDiff).toBeLessThanOrEqual(2);
// Second completion immediately after first
const secondCompletionTime = new Date();
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
const secondNewDueDate = new Date(task.due_date);
// Should be approximately 2 days from second completion
// which means it should be later than the first new due date
expect(secondNewDueDate.getTime()).toBeGreaterThan(
firstNewDueDate.getTime()
);
const secondDayDiff = Math.round(
(secondNewDueDate - secondCompletionTime) /
(1000 * 60 * 60 * 24)
);
expect(secondDayDiff).toBeGreaterThanOrEqual(1);
expect(secondDayDiff).toBeLessThanOrEqual(2);
// Verify both completions were recorded
const completions = await RecurringCompletion.findAll({
where: { task_id: task.id },
});
expect(completions.length).toBe(2);
});
});
describe('Recurrence End Date', () => {
it('should stop recurring when end date is reached', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const dayAfterTomorrow = new Date(today);
dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 2);
const threeDaysFromNow = new Date(today);
threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3);
// Create a daily task due today with end date 3 days from now
// Note: end date is exclusive (uses <, not <=)
// So this task can recur on: today (day 0), tomorrow (day 1), day 2
// But NOT on day 3 (the end date itself)
const task = await Task.create({
name: 'Limited Recurring Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
recurrence_end_date: threeDaysFromNow,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete the task (first completion) - should advance to tomorrow (day 1)
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
let newDueDate = new Date(task.due_date);
expect(newDueDate.toISOString().split('T')[0]).toBe(
tomorrow.toISOString().split('T')[0]
);
// Complete again - should advance to day after tomorrow (day 2, last valid occurrence)
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
newDueDate = new Date(task.due_date);
expect(newDueDate.toISOString().split('T')[0]).toBe(
dayAfterTomorrow.toISOString().split('T')[0]
);
// Complete one more time - this time it should stop recurring
// because the next occurrence would be day 3 (threeDaysFromNow) which is >= end date
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Task should remain completed since next occurrence would be on or past end date
expect(task.status).toBe(Task.STATUS.DONE);
expect(task.completed_at).not.toBeNull();
});
it('should allow recurring tasks without end date to continue indefinitely', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Infinite Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
recurrence_end_date: null,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete multiple times
for (let i = 0; i < 5; i++) {
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
}
// Verify all completions were recorded
const completions = await RecurringCompletion.findAll({
where: { task_id: task.id },
});
expect(completions.length).toBe(5);
});
});
describe('Edge Cases', () => {
it('should handle completing a task multiple times in quick succession', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Quick Complete Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Try to complete it twice rapidly
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
const dueDateAfterFirst = new Date(task.due_date);
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
const dueDateAfterSecond = new Date(task.due_date);
// Both completions should have been recorded
const completions = await RecurringCompletion.findAll({
where: { task_id: task.id },
});
expect(completions.length).toBe(2);
// Second due date should be one day after first
const expectedDate = new Date(dueDateAfterFirst);
expectedDate.setDate(expectedDate.getDate() + 1);
expect(dueDateAfterSecond.toISOString().split('T')[0]).toBe(
expectedDate.toISOString().split('T')[0]
);
});
it('should handle uncompleting a recurring task', async () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const task = await Task.create({
name: 'Uncomplete Task',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: today,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const originalDueDate = new Date(task.due_date);
// Complete the task
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
const advancedDueDate = new Date(task.due_date);
// Due date should have advanced
expect(advancedDueDate.getTime()).toBeGreaterThan(
originalDueDate.getTime()
);
// Change status to in_progress (not completed)
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.IN_PROGRESS });
await task.reload();
// Due date should remain the same
expect(new Date(task.due_date).getTime()).toBe(
advancedDueDate.getTime()
);
expect(task.status).toBe(Task.STATUS.IN_PROGRESS);
});
it('should not create recurring completion for non-recurring tasks', async () => {
const task = await Task.create({
name: 'Regular Task',
recurrence_type: 'none',
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete the task
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Should stay completed
expect(task.status).toBe(Task.STATUS.DONE);
expect(task.completed_at).not.toBeNull();
// No recurring completion should be created
const completions = await RecurringCompletion.findAll({
where: { task_id: task.id },
});
expect(completions.length).toBe(0);
});
it('should handle tasks without a due date', async () => {
const task = await Task.create({
name: 'No Due Date Recurring',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: null,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Complete the task
await agent
.patch(`/api/task/${task.uid}`)
.send({ status: Task.STATUS.DONE });
await task.reload();
// Should calculate next due date from completion time
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
expect(task.due_date).not.toBeNull();
const dueDate = new Date(task.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// Due date should be approximately today or tomorrow
expect(dueDate.getTime()).toBeGreaterThanOrEqual(today.getTime());
expect(dueDate.getTime()).toBeLessThan(
tomorrow.getTime() + 24 * 60 * 60 * 1000
);
});
});
describe('Service Function Tests', () => {
it('calculateNextDueDate should return null for invalid inputs', () => {
expect(calculateNextDueDate(null, new Date())).toBeNull();
expect(
calculateNextDueDate({ recurrence_type: null }, new Date())
).toBeNull();
expect(
calculateNextDueDate({ recurrence_type: 'daily' }, null)
).toBeNull();
expect(
calculateNextDueDate(
{ recurrence_type: 'daily' },
new Date('invalid')
)
).toBeNull();
});
it('calculateNextDueDate should handle unknown recurrence types', () => {
const result = calculateNextDueDate(
{ recurrence_type: 'unknown_type' },
new Date()
);
expect(result).toBeNull();
});
});
});