Internal
Public Access
Fixes subtasks appearing flattened into the main task list (missing parent filter), and adds everything else needed for full offline functionality: tag chips/assignment/creation, full field editing (description, due time, recurrence presets), time tracking with a live-updating timer, and properly nested subtasks -- all queued locally and synced via the existing /api/sync/ protocol. The offline page now reuses the real dashboard's app-layout/header/ sidebar/detail-pane structure instead of a bespoke layout, with an amber-tinted header and banner so it's unmistakable which mode you're in. The detail panel mirrors _task_detail.html's fields and actions. Also fixes a real backend gap this feature depends on: apply_conflict_data() in sync/views.py silently no-op'd on time_entry conflicts. And computes duration_seconds client-side on merge, since TimeEntrySyncSerializer doesn't include it in the wire format at all. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
174 lines
6.2 KiB
Python
174 lines
6.2 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')
|