Files
KeepItGoingServer/sync/tests.py
T
Keith SmithandClaude Sonnet 5 a6f227e931 Bring offline mode to full parity with the online dashboard
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>
2026-09-04 23:45:43 -06:00

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