from datetime import date, datetime, time as dt_time from unittest.mock import patch from django.contrib.auth import get_user_model from django.test import TestCase from django.utils import timezone as django_timezone from tasks.models import Task User = get_user_model() class CustomRecurrenceTests(TestCase): """Tests for Task.calculate_next_due_date() with custom RRULE patterns.""" def setUp(self): self.user = User.objects.create_user( username='testuser', email='testuser@example.com', password='testpass123', ) def make_task(self, **kwargs): defaults = { 'user': self.user, 'title': 'Test task', 'recurrence': 'custom', } defaults.update(kwargs) return Task.objects.create(**defaults) def test_every_wednesday(self): # 2026-01-07 is a Wednesday task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;BYDAY=WE') self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 14)) def test_every_other_wednesday(self): task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE') self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 21)) def test_every_second_tuesday(self): # 2026-01-13 is the second Tuesday of January 2026 task = self.make_task(due_date=date(2026, 1, 13), recurrence_rule='FREQ=MONTHLY;BYDAY=2TU') self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 10)) def test_every_15th(self): task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15') self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 15)) def test_respects_recurrence_end_date(self): task = self.make_task( due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;BYDAY=WE', recurrence_end_date=date(2026, 1, 10), ) self.assertIsNone(task.calculate_next_due_date()) def test_malformed_rule_returns_none(self): task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='not a valid rrule') self.assertIsNone(task.calculate_next_due_date()) def test_create_next_recurrence(self): task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15') new_task = task.create_next_recurrence() self.assertIsNotNone(new_task) self.assertEqual(new_task.due_date, date(2026, 2, 15)) self.assertEqual(new_task.recurrence_rule, 'FREQ=MONTHLY;BYMONTHDAY=15') self.assertEqual(new_task.status, 'pending') def test_parsed_custom_recurrence_weekly(self): task = self.make_task(recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE') parsed = task.parsed_custom_recurrence self.assertEqual(parsed['freq'], 'weekly') self.assertEqual(parsed['interval'], 2) self.assertEqual(parsed['byweekday'], ['WE']) def test_parsed_custom_recurrence_monthly_nth(self): task = self.make_task(recurrence_rule='FREQ=MONTHLY;BYDAY=2TU') parsed = task.parsed_custom_recurrence self.assertEqual(parsed['freq'], 'monthly') self.assertEqual(parsed['monthly_mode'], 'nth') self.assertEqual(parsed['nth_ordinal'], 2) self.assertEqual(parsed['nth_weekday'], 'TU') def test_parsed_custom_recurrence_blank(self): task = self.make_task(recurrence_rule='') parsed = task.parsed_custom_recurrence self.assertIsNone(parsed['freq']) self.assertEqual(parsed['byweekday'], []) class IsOverdueTests(TestCase): """Tests for Task.is_overdue, which must account for due_time, not just due_date (Gitea #7).""" def setUp(self): self.user = User.objects.create_user( username='overdueuser', email='overdueuser@example.com', password='testpass123', ) def make_task(self, **kwargs): defaults = {'user': self.user, 'title': 'Test task', 'status': 'pending'} defaults.update(kwargs) return Task.objects.create(**defaults) @patch('django.utils.timezone.now') def test_not_overdue_before_due_time_same_day(self, mock_now): mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 8, 0, 0)) task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0)) self.assertFalse(task.is_overdue) @patch('django.utils.timezone.now') def test_overdue_after_due_time_same_day(self, mock_now): mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 18, 0, 0)) task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(17, 0, 0)) self.assertTrue(task.is_overdue) @patch('django.utils.timezone.now') def test_not_overdue_same_day_without_due_time(self, mock_now): mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 59, 0)) task = self.make_task(due_date=date(2026, 1, 15), due_time=None) self.assertFalse(task.is_overdue) @patch('django.utils.timezone.now') def test_overdue_once_date_has_passed(self, mock_now): mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 16, 0, 1, 0)) task = self.make_task(due_date=date(2026, 1, 15), due_time=dt_time(23, 0, 0)) self.assertTrue(task.is_overdue) @patch('django.utils.timezone.now') def test_completed_task_never_overdue(self, mock_now): mock_now.return_value = django_timezone.make_aware(datetime(2026, 1, 15, 23, 0, 0)) task = self.make_task(due_date=date(2026, 1, 1), due_time=dt_time(9, 0, 0), status='completed') self.assertFalse(task.is_overdue) class WebFormDueDateTypeTests(TestCase): """ The web views assign due_date/due_time straight from request.POST (raw strings), relying on Django to coerce them at the DB layer - which leaves the in-memory instance holding strings after save(). Anything that touches those fields on that same instance afterward (e.g. Task.reschedule_reminders(), called from save() itself) must not choke on that. Regression test for a real production crash on task edit. """ def setUp(self): self.user = User.objects.create_user( username='webformuser', email='webformuser@example.com', password='testpass123', ) self.client.force_login(self.user) def test_editing_due_date_and_time_does_not_crash(self): task = Task.objects.create(user=self.user, title='Task') response = self.client.post(f'/tasks/{task.id}/', { 'title': 'Task', 'status': 'pending', 'priority': 'medium', 'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none', }) self.assertEqual(response.status_code, 302) task.refresh_from_db() self.assertEqual(task.due_date, date(2026, 9, 10)) self.assertEqual(task.due_time, dt_time(17, 0)) def test_creating_task_with_due_date_does_not_crash(self): response = self.client.post('/tasks/new/', { 'title': 'New task', 'status': 'pending', 'priority': 'medium', 'due_date': '2026-09-10', 'due_time': '17:00', 'recurrence': 'none', }) self.assertEqual(response.status_code, 302) task = Task.objects.get(title='New task') self.assertEqual(task.due_date, date(2026, 9, 10)) self.assertEqual(task.due_time, dt_time(17, 0)) def test_quick_add_task_with_due_date_does_not_crash(self): response = self.client.post('/tasks/quick-add/', {'title': 'Quick task', 'due_date': '2026-09-10'}) self.assertEqual(response.status_code, 302) task = Task.objects.get(title='Quick task') self.assertEqual(task.due_date, date(2026, 9, 10))