* 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
426 lines
15 KiB
JavaScript
426 lines
15 KiB
JavaScript
const request = require('supertest');
|
|
const app = require('../../app');
|
|
const { Task, sequelize } = require('../../models');
|
|
const { createTestUser } = require('../helpers/testUtils');
|
|
const {
|
|
calculateNextDueDate,
|
|
} = require('../../modules/tasks/recurringTaskService');
|
|
|
|
describe('Recurring Tasks - DST Transition Handling', () => {
|
|
let user, agent;
|
|
|
|
beforeEach(async () => {
|
|
user = await createTestUser({
|
|
email: 'test@example.com',
|
|
});
|
|
|
|
agent = request.agent(app);
|
|
await agent.post('/api/login').send({
|
|
email: 'test@example.com',
|
|
password: 'password123',
|
|
});
|
|
});
|
|
|
|
describe('DST Spring Forward (March 10, 2024 - America/New_York)', () => {
|
|
const dstSpringDate = new Date(Date.UTC(2024, 2, 10, 7, 0, 0, 0));
|
|
|
|
it('should create daily recurring task on DST transition day', async () => {
|
|
const taskData = {
|
|
name: 'DST Spring Daily Task',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
due_date: dstSpringDate.toISOString().split('T')[0],
|
|
};
|
|
|
|
const response = await agent.post('/api/task').send(taskData);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.due_date).toBe('2024-03-10');
|
|
});
|
|
|
|
it('should advance daily task correctly from before DST to after DST', async () => {
|
|
const beforeDST = new Date(Date.UTC(2024, 2, 9, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, beforeDST);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-10');
|
|
expect(nextDate.getUTCDate()).toBe(10);
|
|
});
|
|
|
|
it('should advance daily task correctly from DST day to next day', async () => {
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, dstSpringDate);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-11');
|
|
expect(nextDate.getUTCDate()).toBe(11);
|
|
});
|
|
|
|
it('should handle weekly recurring task spanning DST transition', async () => {
|
|
const sunday = new Date(Date.UTC(2024, 2, 3, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekday: 0,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, sunday);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-10');
|
|
expect(nextDate.getUTCDay()).toBe(0);
|
|
});
|
|
|
|
it('should not skip occurrences during DST spring forward', async () => {
|
|
const march8 = new Date(Date.UTC(2024, 2, 8, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
let currentDate = march8;
|
|
const dates = [];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
dates.push(currentDate.toISOString().split('T')[0]);
|
|
currentDate = calculateNextDueDate(task, currentDate);
|
|
}
|
|
|
|
expect(dates).toEqual([
|
|
'2024-03-08',
|
|
'2024-03-09',
|
|
'2024-03-10',
|
|
'2024-03-11',
|
|
'2024-03-12',
|
|
]);
|
|
});
|
|
|
|
it('should handle monthly task due on DST transition day', async () => {
|
|
const feb10 = new Date(Date.UTC(2024, 1, 10, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'monthly',
|
|
recurrence_interval: 1,
|
|
recurrence_month_day: 10,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, feb10);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-10');
|
|
expect(nextDate.getUTCDate()).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('DST Fall Back (November 3, 2024 - America/New_York)', () => {
|
|
const dstFallDate = new Date(Date.UTC(2024, 10, 3, 6, 0, 0, 0));
|
|
|
|
it('should create daily recurring task on DST end day', async () => {
|
|
const taskData = {
|
|
name: 'DST Fall Daily Task',
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
due_date: dstFallDate.toISOString().split('T')[0],
|
|
};
|
|
|
|
const response = await agent.post('/api/task').send(taskData);
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.due_date).toBe('2024-11-03');
|
|
});
|
|
|
|
it('should advance daily task correctly from before DST end to after', async () => {
|
|
const beforeDSTEnd = new Date(Date.UTC(2024, 10, 2, 4, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, beforeDSTEnd);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-03');
|
|
expect(nextDate.getUTCDate()).toBe(3);
|
|
});
|
|
|
|
it('should advance daily task correctly from DST end day to next day', async () => {
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, dstFallDate);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-04');
|
|
expect(nextDate.getUTCDate()).toBe(4);
|
|
});
|
|
|
|
it('should not duplicate occurrences during DST fall back', async () => {
|
|
const nov1 = new Date(Date.UTC(2024, 10, 1, 4, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
let currentDate = nov1;
|
|
const dates = [];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
dates.push(currentDate.toISOString().split('T')[0]);
|
|
currentDate = calculateNextDueDate(task, currentDate);
|
|
}
|
|
|
|
expect(dates).toEqual([
|
|
'2024-11-01',
|
|
'2024-11-02',
|
|
'2024-11-03',
|
|
'2024-11-04',
|
|
'2024-11-05',
|
|
]);
|
|
});
|
|
|
|
it('should handle weekly recurring task spanning DST end', async () => {
|
|
const sunday = new Date(Date.UTC(2024, 9, 27, 4, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekday: 0,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, sunday);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-03');
|
|
expect(nextDate.getUTCDay()).toBe(0);
|
|
});
|
|
|
|
it('should maintain date consistency through DST end', async () => {
|
|
const oct15 = new Date(Date.UTC(2024, 9, 15, 4, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'monthly',
|
|
recurrence_interval: 1,
|
|
recurrence_month_day: 15,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, oct15);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-15');
|
|
expect(nextDate.getUTCDate()).toBe(15);
|
|
});
|
|
});
|
|
|
|
describe('DST Across Multiple Timezones', () => {
|
|
it('should handle Europe/London DST (different dates than US)', async () => {
|
|
const march28 = new Date(Date.UTC(2024, 2, 28, 1, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekday: 4,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, march28);
|
|
|
|
expect(nextDate.getUTCDay()).toBe(4);
|
|
expect(nextDate.getUTCDate()).toBe(4);
|
|
});
|
|
|
|
it('should handle timezones without DST correctly', async () => {
|
|
const arizona = new Date(Date.UTC(2024, 2, 10, 7, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, arizona);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-11');
|
|
});
|
|
|
|
it('should handle Australia/Sydney DST (opposite hemisphere)', async () => {
|
|
const aprilSydney = new Date(Date.UTC(2024, 3, 7, 0, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekday: 0,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, aprilSydney);
|
|
|
|
expect(nextDate.getUTCDay()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Virtual Occurrences Spanning DST', () => {
|
|
it('should generate virtual occurrences correctly across DST spring forward', async () => {
|
|
const march7 = new Date(Date.UTC(2024, 2, 7, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
let currentDate = march7;
|
|
const occurrences = [];
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
occurrences.push({
|
|
due_date: currentDate.toISOString().split('T')[0],
|
|
});
|
|
currentDate = calculateNextDueDate(task, currentDate);
|
|
}
|
|
|
|
expect(occurrences.map((o) => o.due_date)).toEqual([
|
|
'2024-03-07',
|
|
'2024-03-08',
|
|
'2024-03-09',
|
|
'2024-03-10',
|
|
'2024-03-11',
|
|
'2024-03-12',
|
|
'2024-03-13',
|
|
]);
|
|
});
|
|
|
|
it('should generate virtual occurrences correctly across DST fall back', async () => {
|
|
const oct31 = new Date(Date.UTC(2024, 9, 31, 4, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
let currentDate = oct31;
|
|
const occurrences = [];
|
|
|
|
for (let i = 0; i < 7; i++) {
|
|
occurrences.push({
|
|
due_date: currentDate.toISOString().split('T')[0],
|
|
});
|
|
currentDate = calculateNextDueDate(task, currentDate);
|
|
}
|
|
|
|
expect(occurrences.map((o) => o.due_date)).toEqual([
|
|
'2024-10-31',
|
|
'2024-11-01',
|
|
'2024-11-02',
|
|
'2024-11-03',
|
|
'2024-11-04',
|
|
'2024-11-05',
|
|
'2024-11-06',
|
|
]);
|
|
});
|
|
|
|
it('should handle bi-weekly recurrence across DST transition', async () => {
|
|
const feb25 = new Date(Date.UTC(2024, 1, 25, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 2,
|
|
recurrence_weekday: 0,
|
|
};
|
|
|
|
let currentDate = feb25;
|
|
const occurrences = [];
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
occurrences.push({
|
|
due_date: currentDate.toISOString().split('T')[0],
|
|
});
|
|
currentDate = calculateNextDueDate(task, currentDate);
|
|
}
|
|
|
|
expect(occurrences.map((o) => o.due_date)).toEqual([
|
|
'2024-02-25',
|
|
'2024-03-10',
|
|
'2024-03-24',
|
|
'2024-04-07',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Monthly Recurrence During DST Months', () => {
|
|
it('should handle monthly recurrence during DST start month', async () => {
|
|
const feb15 = new Date(Date.UTC(2024, 1, 15, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'monthly',
|
|
recurrence_interval: 1,
|
|
recurrence_month_day: 15,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, feb15);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-15');
|
|
expect(nextDate.getUTCDate()).toBe(15);
|
|
});
|
|
|
|
it('should handle monthly recurrence during DST end month', async () => {
|
|
const oct20 = new Date(Date.UTC(2024, 9, 20, 4, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'monthly',
|
|
recurrence_interval: 1,
|
|
recurrence_month_day: 20,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, oct20);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-11-20');
|
|
expect(nextDate.getUTCDate()).toBe(20);
|
|
});
|
|
|
|
it('should handle monthly weekday recurrence across DST', async () => {
|
|
const feb1st = new Date(Date.UTC(2024, 1, 5, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'monthly_weekday',
|
|
recurrence_interval: 1,
|
|
recurrence_weekday: 1,
|
|
recurrence_week_of_month: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, feb1st);
|
|
|
|
expect(nextDate.getUTCDay()).toBe(1);
|
|
expect(nextDate.getUTCMonth()).toBe(2);
|
|
});
|
|
|
|
it('should handle monthly last day across DST', async () => {
|
|
const feb29 = new Date(Date.UTC(2024, 1, 29, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'monthly_last_day',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, feb29);
|
|
|
|
expect(nextDate.getUTCMonth()).toBe(2);
|
|
expect(nextDate.getUTCDate()).toBe(31);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases During DST Transition Hour', () => {
|
|
it('should handle task created exactly at DST transition (2 AM)', async () => {
|
|
const dstTransitionMoment = new Date(
|
|
Date.UTC(2024, 2, 10, 7, 0, 0, 0)
|
|
);
|
|
const task = {
|
|
recurrence_type: 'daily',
|
|
recurrence_interval: 1,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, dstTransitionMoment);
|
|
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-11');
|
|
});
|
|
|
|
it('should handle weekly task due Sunday when DST transitions Sunday', async () => {
|
|
const sundayDST = new Date(Date.UTC(2024, 2, 10, 5, 0, 0, 0));
|
|
const task = {
|
|
recurrence_type: 'weekly',
|
|
recurrence_interval: 1,
|
|
recurrence_weekday: 0,
|
|
};
|
|
|
|
const nextDate = calculateNextDueDate(task, sundayDST);
|
|
|
|
expect(nextDate.getUTCDay()).toBe(0);
|
|
expect(nextDate.toISOString().split('T')[0]).toBe('2024-03-17');
|
|
});
|
|
});
|
|
});
|