Files
KeepItGoingServer/sync/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

226 lines
8.1 KiB
Python

import uuid
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from tasks.models import Task, TimeEntry
from sync.models import SyncLog, SyncConflict
User = get_user_model()
class SyncEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='synctestuser',
email='synctest@example.com',
password='testpass123',
)
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_new_sync_id_creates_task(self):
new_sync_id = str(uuid.uuid4())
response = self.client.post('/api/sync/', {
'device_id': 'web-test-device',
'last_sync_token': None,
'changes': {
'tasks': [{
'sync_id': new_sync_id,
'title': 'Offline-created task',
'status': 'pending',
'priority': 'medium',
}],
'tags': [],
'time_entries': [],
},
}, format='json')
self.assertEqual(response.status_code, 200)
task = Task.objects.get(sync_id=new_sync_id)
self.assertEqual(task.title, 'Offline-created task')
self.assertEqual(task.user, self.user)
def test_is_deleted_soft_deletes_regardless_of_conflict(self):
task = Task.objects.create(user=self.user, title='To be deleted')
# Force a conflict window: server row touched after "last sync".
last_sync_at = timezone.now() - timedelta(minutes=5)
SyncLog.objects.create(
user=self.user, device_id='web-test-device',
sync_token='old-token', last_sync_at=last_sync_at,
)
response = self.client.post('/api/sync/', {
'device_id': 'web-test-device',
'last_sync_token': 'old-token',
'changes': {
'tasks': [{'sync_id': str(task.sync_id), 'is_deleted': True}],
'tags': [],
'time_entries': [],
},
}, format='json')
self.assertEqual(response.status_code, 200)
task.refresh_from_db()
self.assertTrue(task.is_deleted)
self.assertEqual(SyncConflict.objects.count(), 0)
def test_conflict_created_and_client_change_discarded(self):
task = Task.objects.create(user=self.user, title='Original title')
last_sync_at = timezone.now() - timedelta(minutes=5)
SyncLog.objects.create(
user=self.user, device_id='web-test-device',
sync_token='old-token', last_sync_at=last_sync_at,
)
# Server row modified after last_sync_at (updated_at auto-bumps on save).
task.title = 'Changed on server'
task.save()
response = self.client.post('/api/sync/', {
'device_id': 'web-test-device',
'last_sync_token': 'old-token',
'changes': {
'tasks': [{
'sync_id': str(task.sync_id),
'title': 'Changed offline',
'status': 'pending',
}],
'tags': [],
'time_entries': [],
},
}, format='json')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data['conflicts']), 1)
self.assertEqual(data['conflicts'][0]['local_data']['sync_id'], str(task.sync_id))
task.refresh_from_db()
self.assertEqual(task.title, 'Changed on server')
def test_resolve_conflict_local_applies_local_data(self):
task = Task.objects.create(user=self.user, title='Server title')
conflict = SyncConflict.objects.create(
user=self.user,
entity_type='task',
entity_id=task.id,
local_data={'sync_id': str(task.sync_id), 'title': 'Local title', 'status': 'pending'},
server_data={'title': 'Server title'},
)
response = self.client.post(
f'/api/sync/conflicts/{conflict.id}/resolve/',
{'resolution': 'local'},
format='json',
)
self.assertEqual(response.status_code, 200)
task.refresh_from_db()
self.assertEqual(task.title, 'Local title')
conflict.refresh_from_db()
self.assertEqual(conflict.status, 'resolved_local')
def test_resolve_conflict_server_is_noop(self):
task = Task.objects.create(user=self.user, title='Server title')
conflict = SyncConflict.objects.create(
user=self.user,
entity_type='task',
entity_id=task.id,
local_data={'sync_id': str(task.sync_id), 'title': 'Local title'},
server_data={'title': 'Server title'},
)
response = self.client.post(
f'/api/sync/conflicts/{conflict.id}/resolve/',
{'resolution': 'server'},
format='json',
)
self.assertEqual(response.status_code, 200)
task.refresh_from_db()
self.assertEqual(task.title, 'Server title')
conflict.refresh_from_db()
self.assertEqual(conflict.status, 'resolved_server')
def test_resolve_conflict_local_applies_time_entry_data(self):
task = Task.objects.create(user=self.user, title='Timed task')
start = timezone.now() - timedelta(hours=1)
entry = TimeEntry.objects.create(user=self.user, task=task, started_at=start, notes='server notes')
conflict = SyncConflict.objects.create(
user=self.user,
entity_type='time_entry',
entity_id=entry.id,
local_data={'sync_id': str(entry.sync_id), 'notes': 'local notes'},
server_data={'notes': 'server notes'},
)
response = self.client.post(
f'/api/sync/conflicts/{conflict.id}/resolve/',
{'resolution': 'local'},
format='json',
)
self.assertEqual(response.status_code, 200)
entry.refresh_from_db()
self.assertEqual(entry.notes, 'local notes')
conflict.refresh_from_db()
self.assertEqual(conflict.status, 'resolved_local')
def test_creating_task_with_due_date_and_time_does_not_crash(self):
"""
Sync payloads carry due_date/due_time as JSON strings. Regression
test: these must end up as real date/time objects on the created
Task, not raw strings left for Task.reschedule_reminders() (called
from save()) to choke on.
"""
new_sync_id = str(uuid.uuid4())
response = self.client.post('/api/sync/', {
'device_id': 'web-test-device',
'last_sync_token': None,
'changes': {
'tasks': [{
'sync_id': new_sync_id,
'title': 'Due-dated task',
'status': 'pending',
'priority': 'medium',
'due_date': '2026-09-10',
'due_time': '17:00:00',
}],
'tags': [],
'time_entries': [],
},
}, format='json')
self.assertEqual(response.status_code, 200)
task = Task.objects.get(sync_id=new_sync_id)
self.assertEqual(task.due_date.isoformat(), '2026-09-10')
self.assertEqual(task.due_time.isoformat(), '17:00:00')
def test_updating_task_due_date_and_time_does_not_crash(self):
task = Task.objects.create(user=self.user, title='Task to update')
response = self.client.post('/api/sync/', {
'device_id': 'web-test-device',
'last_sync_token': None,
'changes': {
'tasks': [{
'sync_id': str(task.sync_id),
'due_date': '2026-09-10',
'due_time': '17:00:00',
}],
'tags': [],
'time_entries': [],
},
}, format='json')
self.assertEqual(response.status_code, 200)
task.refresh_from_db()
self.assertEqual(task.due_date.isoformat(), '2026-09-10')
self.assertEqual(task.due_time.isoformat(), '17:00:00')