from datetime import date, datetime, time as dt_time, timedelta from unittest.mock import patch from django.contrib.auth import get_user_model from django.core import mail from django.test import TestCase from django.utils import timezone as django_timezone from notifications.models import ScheduledReminder from notifications.tasks import send_scheduled_reminders from tasks.models import Task User = get_user_model() class RescheduleRemindersTests(TestCase): """Tests for Task.reschedule_reminders(), triggered from Task.save().""" def setUp(self): self.user = User.objects.create_user( username='reminderuser', email='reminderuser@example.com', password='testpass123', default_reminder_minutes=30, ) def reminder_types(self, task): return set( ScheduledReminder.objects.filter(task=task, is_sent=False).values_list('reminder_type', flat=True) ) def test_due_time_task_gets_all_three_reminders(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) self.assertEqual(self.reminder_types(task), {'reminder', 'due_soon', 'overdue'}) before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue') expected_due = django_timezone.make_aware(datetime(2026, 1, 15, 17, 0, 0)) self.assertEqual(due_soon.remind_at, expected_due) self.assertEqual(overdue.remind_at, expected_due + timedelta(hours=1)) self.assertEqual(before_due.remind_at, expected_due - timedelta(minutes=30)) def test_date_only_task_only_gets_overdue_reminder_next_day(self): task = Task.objects.create(user=self.user, title='Task', due_date=date(2026, 1, 15)) self.assertEqual(self.reminder_types(task), {'overdue'}) overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue') self.assertEqual(overdue.remind_at, django_timezone.make_aware(datetime(2026, 1, 16, 0, 0, 0))) def test_no_reminder_minutes_skips_before_due_reminder(self): self.user.default_reminder_minutes = 0 self.user.save() task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) self.assertEqual(self.reminder_types(task), {'due_soon', 'overdue'}) def test_explicit_reminder_at_overrides_default_minutes(self): custom_reminder = django_timezone.make_aware(datetime(2026, 1, 15, 9, 0, 0)) task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), reminder_at=custom_reminder, ) before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') self.assertEqual(before_due.remind_at, custom_reminder) def test_no_due_date_gets_no_reminders(self): task = Task.objects.create(user=self.user, title='Task') self.assertEqual(self.reminder_types(task), set()) def test_completing_task_clears_pending_reminders(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) self.assertTrue(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) task.status = 'completed' task.save() self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) def test_deleting_task_clears_pending_reminders(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) task.is_deleted = True task.save() self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) def test_changing_due_date_replaces_reminders(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) task.due_date = date(2026, 2, 1) task.save() self.assertEqual(ScheduledReminder.objects.filter(task=task, is_sent=False).count(), 3) due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') self.assertEqual(due_soon.remind_at, django_timezone.make_aware(datetime(2026, 2, 1, 17, 0, 0))) def test_unrelated_field_save_does_not_duplicate_reminders(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) task.title = 'Renamed task' task.save() self.assertEqual(ScheduledReminder.objects.filter(task=task, is_sent=False).count(), 3) class DefaultReminderMinutesChangeTests(TestCase): """ Task.reschedule_reminders() only captures user.default_reminder_minutes at the moment a task's own due_date/due_time is set - changing the profile setting afterward doesn't reach existing tasks on its own. User.reschedule_reminder_notifications(), triggered from User.save() when the setting changes, is what makes that retroactive. """ def setUp(self): self.user = User.objects.create_user( username='reminderuser2', email='reminderuser2@example.com', password='testpass123', default_reminder_minutes=30, ) def test_changing_default_minutes_reschedules_before_due_reminder(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) due_moment = django_timezone.make_aware(datetime(2026, 1, 15, 17, 0, 0)) before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') self.assertEqual(before_due.remind_at, due_moment - timedelta(minutes=30)) self.user.default_reminder_minutes = 15 self.user.save() before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') self.assertEqual(before_due.remind_at, due_moment - timedelta(minutes=15)) def test_task_with_explicit_reminder_at_is_left_alone(self): custom_reminder = django_timezone.make_aware(datetime(2026, 1, 15, 9, 0, 0)) task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), reminder_at=custom_reminder, ) self.user.default_reminder_minutes = 15 self.user.save() before_due = ScheduledReminder.objects.get(task=task, reminder_type='reminder') self.assertEqual(before_due.remind_at, custom_reminder) def test_completed_task_is_not_touched(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), status='completed', ) self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) self.user.default_reminder_minutes = 15 self.user.save() self.assertFalse(ScheduledReminder.objects.filter(task=task, is_sent=False).exists()) def test_unrelated_profile_field_save_does_not_reschedule(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) original_id = ScheduledReminder.objects.get(task=task, reminder_type='reminder').id self.user.first_name = 'Changed' self.user.save() # Same row (not deleted and recreated by reschedule_reminders()). self.assertEqual(ScheduledReminder.objects.get(task=task, reminder_type='reminder').id, original_id) class SendScheduledRemindersTests(TestCase): """Tests for the send_scheduled_reminders Celery task.""" def setUp(self): self.user = User.objects.create_user( username='sendreminderuser', email='sendreminderuser@example.com', password='testpass123', email_notifications=True, push_notifications=False, email_verified=True, default_reminder_minutes=30, ) def test_due_reminder_sends_email_and_marks_sent(self): task = Task.objects.create( user=self.user, title='Water the plants', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) reminder = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') with patch('django.utils.timezone.now') as mock_now: mock_now.return_value = reminder.remind_at + timedelta(seconds=1) sent_count = send_scheduled_reminders() # "reminder" (30 min before) and "due_soon" have passed; "overdue" # is delayed an hour past due_soon so it hasn't fired yet. self.assertEqual(sent_count, 2) self.assertEqual(len(mail.outbox), 2) reminder.refresh_from_db() self.assertTrue(reminder.is_sent) self.assertIsNotNone(reminder.sent_at) self.assertFalse(ScheduledReminder.objects.get(task=task, reminder_type='overdue').is_sent) def test_overdue_reminder_fires_an_hour_after_due(self): task = Task.objects.create( user=self.user, title='Water the plants', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) overdue = ScheduledReminder.objects.get(task=task, reminder_type='overdue') due_soon = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') self.assertEqual(overdue.remind_at, due_soon.remind_at + timedelta(hours=1)) with patch('django.utils.timezone.now') as mock_now: mock_now.return_value = overdue.remind_at + timedelta(seconds=1) send_scheduled_reminders() overdue.refresh_from_db() self.assertTrue(overdue.is_sent) self.assertIn(f'Overdue: {task.title}', [m.subject for m in mail.outbox]) def test_future_reminder_not_sent_yet(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) with patch('django.utils.timezone.now') as mock_now: mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 8, 0, 0)) sent_count = send_scheduled_reminders() self.assertEqual(sent_count, 0) self.assertEqual(len(mail.outbox), 0) def test_completed_task_reminder_marked_sent_without_notifying(self): task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) remind_at = ScheduledReminder.objects.get(task=task, reminder_type='due_soon').remind_at task.status = 'completed' task.save() # reschedule_reminders() already clears pending reminders for the task # Simulate a stale reminder surviving anyway (e.g. a race with the poller). stale = ScheduledReminder.objects.create(task=task, reminder_type='due_soon', remind_at=remind_at) with patch('django.utils.timezone.now') as mock_now: mock_now.return_value = remind_at + timedelta(seconds=1) send_scheduled_reminders() self.assertEqual(len(mail.outbox), 0) stale.refresh_from_db() self.assertTrue(stale.is_sent) def test_channel_choice_email_only(self): self.user.push_notifications = False self.user.save() task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) reminder = ScheduledReminder.objects.get(task=task, reminder_type='reminder') with patch('django.utils.timezone.now') as mock_now: mock_now.return_value = reminder.remind_at + timedelta(seconds=1) send_scheduled_reminders() sent_subjects = [m.subject for m in mail.outbox] self.assertIn(f'Upcoming: {task.title}', sent_subjects) def test_channel_choice_none_still_marks_sent(self): self.user.email_notifications = False self.user.push_notifications = False self.user.save() task = Task.objects.create( user=self.user, title='Task', due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0), ) reminder = ScheduledReminder.objects.get(task=task, reminder_type='due_soon') with patch('django.utils.timezone.now') as mock_now: mock_now.return_value = reminder.remind_at + timedelta(seconds=1) send_scheduled_reminders() self.assertEqual(len(mail.outbox), 0) reminder.refresh_from_db() self.assertTrue(reminder.is_sent)