* Fix recurring task initial due date calculation to match recurrence pattern Resolves #963 When creating a recurring task without an explicit due date, the system was incorrectly setting it to "today" regardless of the recurrence pattern. This caused issues where: - Monthly tasks set to recur on a specific day (e.g., 28th) would show the wrong due date (today/yesterday instead of the 28th) - Tasks wouldn't appear correctly in the Upcoming view - The base due_date didn't match the recurrence_month_day setting Changes: - Add calculateInitialDueDate() helper to compute correct first occurrence - For monthly recurrence with specific day: calculate next occurrence of that day - For weekly recurrence with specific weekday: calculate next occurrence of that weekday - For other types (daily, etc.): maintain current behavior (use today) - Apply same logic to both task creation and updates Tests: - Add comprehensive test suite (9 new tests) covering: - Monthly recurrence with future day in current month - Monthly recurrence with past day (should use next month) - Weekly recurrence with specific weekday - Daily recurrence (should still default to today) - Edge cases (31st of month, explicitly provided dates) - Task updates adding recurrence All 54 recurring task tests pass. * Fix UTC timezone bug in recurring task expansion and add comprehensive tests - Fix expandRecurringTasks() to use setUTCHours instead of setHours - Add 42 unit tests for recurringTaskService UTC consistency - Add 24 DST transition tests (spring forward/fall back) - Verify no occurrence skips or duplicates during DST - Test month-end handling, leap years, and timezone boundaries
1672 lines
65 KiB
JavaScript
1672 lines
65 KiB
JavaScript
const request = require('supertest');
|
|
const app = require('../../app');
|
|
const { Task, RecurringCompletion, sequelize } = require('../../models');
|
|
const { createTestUser } = require('../helpers/testUtils');
|
|
const {
|
|
calculateNextDueDate,
|
|
} = require('../../modules/tasks/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.setUTCHours(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.setUTCHours(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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(0, 0, 0, 0);
|
|
const todayWeekday = today.getUTCDay();
|
|
|
|
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.setUTCHours(0, 0, 0, 0);
|
|
const todayWeekday = today.getUTCDay();
|
|
|
|
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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(0, 0, 0, 0);
|
|
const todayWeekday = today.getUTCDay();
|
|
|
|
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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(0, 0, 0, 0);
|
|
const todayWeekday = today.getUTCDay();
|
|
// 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.getUTCDay()).toBe(targetWeekday);
|
|
});
|
|
|
|
it('should advance to next selected weekday with multiple weekdays (Issue #715)', async () => {
|
|
// Use a fixed Tuesday: 2026-02-10
|
|
const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0));
|
|
expect(tuesday.getUTCDay()).toBe(2); // Sanity: Tuesday
|
|
|
|
const task = await Task.create({
|
|
name: 'Tue/Thu Task',
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekdays: [2, 4], // Tuesday, Thursday
|
|
due_date: tuesday,
|
|
user_id: user.id,
|
|
status: Task.STATUS.NOT_STARTED,
|
|
});
|
|
|
|
// Completing on Tuesday should advance to Thursday (2 days), not next Tuesday (7 days)
|
|
const nextDate = calculateNextDueDate(task, tuesday);
|
|
expect(nextDate.getUTCDay()).toBe(4); // Thursday
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2026-02-12');
|
|
});
|
|
|
|
it('should wrap around to next week when completing on last selected weekday', async () => {
|
|
// Use a fixed Thursday: 2026-02-12
|
|
const thursday = new Date(Date.UTC(2026, 1, 12, 0, 0, 0, 0));
|
|
expect(thursday.getUTCDay()).toBe(4); // Sanity: Thursday
|
|
|
|
const task = await Task.create({
|
|
name: 'Tue/Thu Task',
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekdays: [2, 4], // Tuesday, Thursday
|
|
due_date: thursday,
|
|
user_id: user.id,
|
|
status: Task.STATUS.NOT_STARTED,
|
|
});
|
|
|
|
// Completing on Thursday should wrap to next Tuesday (5 days)
|
|
const nextDate = calculateNextDueDate(task, thursday);
|
|
expect(nextDate.getUTCDay()).toBe(2); // Tuesday
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2026-02-17');
|
|
});
|
|
|
|
it('should advance due date correctly on completion with multiple weekdays via API', async () => {
|
|
// Use a fixed Tuesday: 2026-02-10
|
|
const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0));
|
|
|
|
const task = await Task.create({
|
|
name: 'Tue/Thu Via API',
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekdays: [2, 4], // Tuesday, Thursday
|
|
due_date: tuesday,
|
|
user_id: user.id,
|
|
status: Task.STATUS.NOT_STARTED,
|
|
});
|
|
|
|
// Complete the task on Tuesday
|
|
await agent
|
|
.patch(`/api/task/${task.uid}`)
|
|
.send({ status: Task.STATUS.DONE });
|
|
await task.reload();
|
|
|
|
// Should advance to Thursday, not next Tuesday
|
|
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
|
|
const newDueDate = new Date(task.due_date);
|
|
expect(newDueDate.getUTCDay()).toBe(4); // Thursday
|
|
|
|
// Complete again on Thursday
|
|
await agent
|
|
.patch(`/api/task/${task.uid}`)
|
|
.send({ status: Task.STATUS.DONE });
|
|
await task.reload();
|
|
|
|
// Should wrap to next Tuesday
|
|
expect(task.status).toBe(Task.STATUS.NOT_STARTED);
|
|
const nextDueDate = new Date(task.due_date);
|
|
expect(nextDueDate.getUTCDay()).toBe(2); // Tuesday
|
|
});
|
|
|
|
it('should respect bi-weekly interval with multiple weekdays (Issue #844)', async () => {
|
|
// Use a fixed Tuesday: 2026-02-10
|
|
const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0));
|
|
expect(tuesday.getUTCDay()).toBe(2); // Sanity: Tuesday
|
|
|
|
const task = await Task.create({
|
|
name: 'Bi-weekly Tue/Thu Task',
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 2,
|
|
recurrence_weekdays: [2, 4], // Tuesday, Thursday
|
|
due_date: tuesday,
|
|
user_id: user.id,
|
|
status: Task.STATUS.NOT_STARTED,
|
|
});
|
|
|
|
// Tuesday -> Thursday (same cycle week)
|
|
const next1 = calculateNextDueDate(task, tuesday);
|
|
expect(next1.getUTCDay()).toBe(4); // Thursday
|
|
expect(next1.toISOString().split('T')[0]).toBe('2026-02-12');
|
|
|
|
// Thursday -> next cycle Tuesday (skip 1 week, land on week 3's Tuesday)
|
|
const next2 = calculateNextDueDate(task, next1);
|
|
expect(next2.getUTCDay()).toBe(2); // Tuesday
|
|
expect(next2.toISOString().split('T')[0]).toBe('2026-02-24');
|
|
|
|
// Tuesday (week 3) -> Thursday (week 3, same cycle)
|
|
const next3 = calculateNextDueDate(task, next2);
|
|
expect(next3.getUTCDay()).toBe(4); // Thursday
|
|
expect(next3.toISOString().split('T')[0]).toBe('2026-02-26');
|
|
|
|
// Thursday (week 3) -> Tuesday (week 5)
|
|
const next4 = calculateNextDueDate(task, next3);
|
|
expect(next4.getUTCDay()).toBe(2); // Tuesday
|
|
expect(next4.toISOString().split('T')[0]).toBe('2026-03-10');
|
|
});
|
|
|
|
it('should respect tri-weekly interval with single weekday via weekdays array (Issue #844)', async () => {
|
|
// Use a fixed Monday: 2026-02-09
|
|
const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0));
|
|
expect(monday.getUTCDay()).toBe(1);
|
|
|
|
const task = await Task.create({
|
|
name: 'Every 3 weeks on Monday',
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 3,
|
|
recurrence_weekdays: [1], // Monday only
|
|
due_date: monday,
|
|
user_id: user.id,
|
|
status: Task.STATUS.NOT_STARTED,
|
|
});
|
|
|
|
// Monday Feb 9 -> Monday Mar 2 (3 weeks later)
|
|
const next1 = calculateNextDueDate(task, monday);
|
|
expect(next1.getUTCDay()).toBe(1);
|
|
expect(next1.toISOString().split('T')[0]).toBe('2026-03-02');
|
|
|
|
// Monday Mar 2 -> Monday Mar 23 (3 weeks later)
|
|
const next2 = calculateNextDueDate(task, next1);
|
|
expect(next2.getUTCDay()).toBe(1);
|
|
expect(next2.toISOString().split('T')[0]).toBe('2026-03-23');
|
|
});
|
|
|
|
it('should handle three weekdays (Mon/Wed/Fri)', async () => {
|
|
// Use a fixed Monday: 2026-02-09
|
|
const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0));
|
|
expect(monday.getUTCDay()).toBe(1); // Sanity: Monday
|
|
|
|
const task = await Task.create({
|
|
name: 'MWF Task',
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekdays: [1, 3, 5], // Mon, Wed, Fri
|
|
due_date: monday,
|
|
user_id: user.id,
|
|
status: Task.STATUS.NOT_STARTED,
|
|
});
|
|
|
|
// Monday -> Wednesday
|
|
const next1 = calculateNextDueDate(task, monday);
|
|
expect(next1.getUTCDay()).toBe(3);
|
|
expect(next1.toISOString().split('T')[0]).toBe('2026-02-11');
|
|
|
|
// Wednesday -> Friday
|
|
const next2 = calculateNextDueDate(task, next1);
|
|
expect(next2.getUTCDay()).toBe(5);
|
|
expect(next2.toISOString().split('T')[0]).toBe('2026-02-13');
|
|
|
|
// Friday -> next Monday
|
|
const next3 = calculateNextDueDate(task, next2);
|
|
expect(next3.getUTCDay()).toBe(1);
|
|
expect(next3.toISOString().split('T')[0]).toBe('2026-02-16');
|
|
});
|
|
});
|
|
|
|
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.setUTCHours(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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(0, 0, 0, 0);
|
|
const todayWeekday = today.getUTCDay();
|
|
|
|
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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(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.setUTCHours(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.setUTCHours(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.setUTCHours(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.setUTCHours(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.setUTCHours(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.setUTCDate(expectedDate.getUTCDate() + 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.setUTCHours(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.setUTCHours(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();
|
|
});
|
|
});
|
|
|
|
describe('Recurring Task Defer Until Validation', () => {
|
|
it('should allow defer_until after due_date for recurring instance with end date', async () => {
|
|
// Create parent recurring task with end date
|
|
const parentTask = await Task.create({
|
|
name: 'Recurring Parent',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
recurrence_end_date: new Date('2026-12-31'),
|
|
due_date: new Date('2026-03-01'),
|
|
user_id: user.id,
|
|
status: 0,
|
|
});
|
|
|
|
// Create instance with recurring_parent_id
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Instance Task',
|
|
due_date: '2026-03-10',
|
|
defer_until: '2026-03-15', // After due_date but before end_date
|
|
recurring_parent_id: parentTask.id,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.defer_until).toBeTruthy();
|
|
});
|
|
|
|
it('should reject defer_until after recurrence_end_date', async () => {
|
|
const parentTask = await Task.create({
|
|
name: 'Recurring Parent',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
recurrence_end_date: new Date('2026-03-20'),
|
|
due_date: new Date('2026-03-01'),
|
|
user_id: user.id,
|
|
status: 0,
|
|
});
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Instance Task',
|
|
due_date: '2026-03-10',
|
|
defer_until: '2026-03-25', // After end_date
|
|
recurring_parent_id: parentTask.id,
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain('recurring task end date');
|
|
});
|
|
|
|
it('should allow any defer_until for recurring instance with no end date', async () => {
|
|
const parentTask = await Task.create({
|
|
name: 'Recurring Parent',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
recurrence_end_date: null, // No end date
|
|
due_date: new Date('2026-03-01'),
|
|
user_id: user.id,
|
|
status: 0,
|
|
});
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Instance Task',
|
|
due_date: '2026-03-10',
|
|
defer_until: '2027-12-31', // Far in future
|
|
recurring_parent_id: parentTask.id,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.defer_until).toBeTruthy();
|
|
});
|
|
|
|
it('should update defer_until on recurring instance via PATCH', async () => {
|
|
const parentTask = await Task.create({
|
|
name: 'Recurring Parent',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
recurrence_end_date: new Date('2026-12-31'),
|
|
due_date: new Date('2026-03-01'),
|
|
user_id: user.id,
|
|
status: 0,
|
|
});
|
|
|
|
const instanceTask = await Task.create({
|
|
name: 'Instance Task',
|
|
due_date: new Date('2026-03-10'),
|
|
recurring_parent_id: parentTask.id,
|
|
user_id: user.id,
|
|
status: 0,
|
|
});
|
|
|
|
const response = await agent
|
|
.patch(`/api/task/${instanceTask.uid}`)
|
|
.send({
|
|
defer_until: '2026-03-15', // After due_date but before end_date
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.defer_until).toBeTruthy();
|
|
});
|
|
|
|
it('should still enforce strict validation for non-recurring tasks', async () => {
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Regular Task',
|
|
due_date: '2026-03-10',
|
|
defer_until: '2026-03-15', // After due_date
|
|
recurrence_type: 'none',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain(
|
|
'cannot be after the due date'
|
|
);
|
|
});
|
|
|
|
it('should handle invalid recurring_parent_id gracefully', async () => {
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Instance Task',
|
|
due_date: '2026-03-10',
|
|
defer_until: '2026-03-15',
|
|
recurring_parent_id: 999999, // Non-existent parent
|
|
});
|
|
|
|
// Should fall back to strict validation
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.error).toContain(
|
|
'cannot be after the due date'
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Auto-calculation of initial due date (Issue #963)', () => {
|
|
it('should calculate correct first occurrence for monthly task on specific day in future', async () => {
|
|
// Always use day 28 as target, which works for all months
|
|
const targetDay = 28;
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Monthly Recurring Task',
|
|
recurrence_type: 'monthly',
|
|
recurrence_month_day: targetDay,
|
|
recurrence_interval: 1,
|
|
// Intentionally NOT providing due_date
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.recurrence_type).toBe('monthly');
|
|
expect(response.body.recurrence_month_day).toBe(targetDay);
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
// Verify due_date matches the target day (not today or yesterday)
|
|
const dueDate = new Date(response.body.due_date);
|
|
const dueDateDay = dueDate.getUTCDate();
|
|
|
|
// Should be set to the 28th (or closest valid day if month doesn't have 28 days)
|
|
expect(dueDateDay).toBeLessThanOrEqual(targetDay);
|
|
expect(dueDateDay).toBeGreaterThanOrEqual(28);
|
|
|
|
// Should be today or in the future
|
|
const now = new Date();
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
expect(dueDate.getTime()).toBeGreaterThanOrEqual(
|
|
now.getTime() - 24 * 60 * 60 * 1000
|
|
);
|
|
});
|
|
|
|
it('should calculate correct first occurrence for monthly task when target day already passed', async () => {
|
|
// Use day 1 as target, which should always result in next month if we're past the 1st
|
|
const targetDay = 1;
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Monthly Task Past Due Day',
|
|
recurrence_type: 'monthly',
|
|
recurrence_month_day: targetDay,
|
|
recurrence_interval: 1,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
const dueDate = new Date(response.body.due_date);
|
|
const dueDateDay = dueDate.getUTCDate();
|
|
|
|
// Should be set to the 1st of the month
|
|
expect(dueDateDay).toBe(targetDay);
|
|
|
|
// Should be today or in the future
|
|
const now = new Date();
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
expect(dueDate.getTime()).toBeGreaterThanOrEqual(
|
|
now.getTime() - 24 * 60 * 60 * 1000
|
|
);
|
|
});
|
|
|
|
it('should calculate correct first occurrence for weekly recurring task', async () => {
|
|
const now = new Date();
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
const currentWeekday = now.getUTCDay();
|
|
|
|
// Pick a weekday different from today
|
|
const targetWeekday = (currentWeekday + 2) % 7;
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Weekly Task',
|
|
recurrence_type: 'weekly',
|
|
recurrence_weekday: targetWeekday,
|
|
recurrence_interval: 1,
|
|
// Intentionally NOT providing due_date
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.recurrence_type).toBe('weekly');
|
|
expect(response.body.recurrence_weekday).toBe(targetWeekday);
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
// Verify due_date is on the correct weekday
|
|
const dueDate = new Date(response.body.due_date);
|
|
expect(dueDate.getUTCDay()).toBe(targetWeekday);
|
|
|
|
// Verify due_date is in the future (or today if target is today)
|
|
expect(dueDate.getTime()).toBeGreaterThanOrEqual(
|
|
now.getTime() - 24 * 60 * 60 * 1000
|
|
); // Allow for today
|
|
});
|
|
|
|
it('should use today for weekly task when today is the target weekday', async () => {
|
|
const now = new Date();
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
const currentWeekday = now.getUTCDay();
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Weekly Task Today',
|
|
recurrence_type: 'weekly',
|
|
recurrence_weekday: currentWeekday,
|
|
recurrence_interval: 1,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
const dueDate = new Date(response.body.due_date);
|
|
expect(dueDate.getUTCDay()).toBe(currentWeekday);
|
|
|
|
// Should be today
|
|
const dueDateStr = dueDate.toISOString().split('T')[0];
|
|
const nowStr = now.toISOString().split('T')[0];
|
|
expect(dueDateStr).toBe(nowStr);
|
|
});
|
|
|
|
it('should default to today for daily recurring task without due date', async () => {
|
|
const now = new Date();
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Daily Task',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
// Intentionally NOT providing due_date
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.recurrence_type).toBe('daily');
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
// For daily tasks, today is a reasonable default
|
|
const dueDate = new Date(response.body.due_date);
|
|
const dueDateStr = dueDate.toISOString().split('T')[0];
|
|
const nowStr = now.toISOString().split('T')[0];
|
|
expect(dueDateStr).toBe(nowStr);
|
|
});
|
|
|
|
it('should update existing task to add monthly recurrence and calculate correct due date', async () => {
|
|
// Create a task without recurrence
|
|
const task = await Task.create({
|
|
name: 'Regular Task',
|
|
user_id: user.id,
|
|
status: 0,
|
|
});
|
|
|
|
const targetDay = 15; // Use middle of month for consistency
|
|
|
|
// Update to add recurrence without providing due_date
|
|
const response = await agent.patch(`/api/task/${task.uid}`).send({
|
|
recurrence_type: 'monthly',
|
|
recurrence_month_day: targetDay,
|
|
recurrence_interval: 1,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.recurrence_type).toBe('monthly');
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
const dueDate = new Date(response.body.due_date);
|
|
const dueDateDay = dueDate.getUTCDate();
|
|
|
|
// Should be set to the 15th
|
|
expect(dueDateDay).toBe(targetDay);
|
|
|
|
// Should be today or in the future
|
|
const now = new Date();
|
|
now.setUTCHours(0, 0, 0, 0);
|
|
expect(dueDate.getTime()).toBeGreaterThanOrEqual(
|
|
now.getTime() - 24 * 60 * 60 * 1000
|
|
);
|
|
});
|
|
|
|
it('should respect explicitly provided due_date over calculated date', async () => {
|
|
const explicitDate = '2026-12-25';
|
|
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Monthly Task with Explicit Date',
|
|
recurrence_type: 'monthly',
|
|
recurrence_month_day: 15,
|
|
recurrence_interval: 1,
|
|
due_date: explicitDate, // Explicitly provided
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.due_date).toBe(explicitDate);
|
|
});
|
|
|
|
it('should handle month edge case (31st of month in February)', async () => {
|
|
const response = await agent.post('/api/task').send({
|
|
name: 'Monthly Task 31st',
|
|
recurrence_type: 'monthly',
|
|
recurrence_month_day: 31,
|
|
recurrence_interval: 1,
|
|
});
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.due_date).toBeDefined();
|
|
|
|
// Should not crash and should provide a valid date
|
|
const dueDate = new Date(response.body.due_date);
|
|
expect(dueDate.getTime()).not.toBeNaN();
|
|
});
|
|
});
|
|
});
|