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) 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 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() self.assertEqual(sent_count, 3) # reminder, due_soon, overdue all past remind_at by then self.assertEqual(len(mail.outbox), 3) reminder.refresh_from_db() self.assertTrue(reminder.is_sent) self.assertIsNotNone(reminder.sent_at) 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)