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