Files
KeepItGoingServer/tasks/tests.py
T
Keith SmithandClaude Sonnet 5 a27fcc4c69 Fix task edit crashing after due_date/due_time save (regression from reminders)
The web edit/create/quick-add views and the sync endpoint assigned
due_date/due_time/reminder_at straight from request data as raw strings.
Django tolerates this at save() (types get coerced deep in the SQL
layer), but the in-memory instance keeps the raw strings afterward.
Task.reschedule_reminders(), added for per-task reminders and run from
save() on that same instance, was the first code to actually operate on
those fields before a page reload and crashed with a TypeError - which
the service worker's 5xx-treated-as-offline fallback then masked as a
misleading "you're offline" screen instead of a real error.

Fixed by parsing with Django's parse_date/parse_time/parse_datetime at
the point these values are read from the request/payload in
tasks/views.py and sync/views.py, rather than trusting raw strings.

5 new regression tests exercise the actual views/endpoint with raw
date/time strings and assert proper types land on the model.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:00:45 -06:00

180 lines
7.7 KiB
Python

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))