Skip to main content

Sync — Testing

Strategy

LayerToolingScope
UnitJestSyncService orchestration, guest hint remapping, back-off logic
Integrationpytest + SQLite test DBPull scoping, push processing order, conflict detection, FK guards
E2EJest + WatermelonDB memory adapterFull pull → merge → push → re-pull cycle on mobile side

Coverage Targets

AreaTarget
SyncService pull/push sequencing100%
Pull scoping rules100%
Conflict (409) handling100%
FK guard rejection100%
Soft-delete propagation100%
Pagination cursor90%+
Overall sync feature coverage≥ 80%

Mocking Rules

  • Server tests — use pytest fixtures with an in-memory SQLite database (swap MariaDB dialect for testing).
  • Mobile tests — use WatermelonDB @nozbe/watermelondb/adapters/memory; never write to disk.
  • Network — mock fetch / axios at the boundary; never make real HTTP calls.
  • AsyncStorage — mock with a plain in-memory object.
  • Clock — use Jest fake timers for updated_at / pulled_at comparisons.

Test Cases

Pull Endpoint — Integration (pytest)

ScenarioExpected result
Pull with last_pulled_at = 0All records owned by user returned; has_more reflects total count
Pull with last_pulled_at = TOnly records with updated_at > T returned
Pull includes soft-deleted rowsRows with is_deleted = true present in response if updated_at > last_pulled_at
Pull excludes records from other users' private conversationsResponse contains only rows in conversations the user is a member of
Pull with limit = 2 and 5 matching rowsFirst response has 2 rows, has_more: true, next_cursor set
Subsequent pull with cursorReturns next 2 rows; after final page has_more: false, next_cursor: null
Pull with invalid JWTReturns 401
Pull with expired JWTReturns 401; app must refresh token and retry

Push Endpoint — Integration (pytest)

ScenarioExpected result
Push new conversation rowRow inserted in conversations table; response 200
Push new message row (parent conversation exists)Row inserted in messages; 200
Push message_receipt where message existsRow inserted; 200
Push message_receipt where message does NOT existReturns 422; receipt discarded; other rows in batch unaffected
Push call_participant where call does NOT existReturns 422; participant row discarded
Push row with updated_at <= server.updated_at and last_pulled_at is currentNo conflict; row upserted; 200
Push row where server.updated_at > last_pulled_atReturns 409 with { conflict: rowId, table }
409 response rolls back entire pushNone of the batch rows are persisted
Push soft-deleted row (is_deleted = true)Server row updated to is_deleted = true; 200
Push with no rowsReturns 200 immediately; no DB writes

Conflict Resolution — Integration

ScenarioExpected result
App receives 409 on pushApp re-pulls with current lastPulledAt; conflicting row fetched
After re-pull, app retries pushServer updated_at now <= last_pulled_at; push succeeds; 200
Concurrent pushes from two devices for same rowSecond device gets 409; after pull it sees first device's version; no data loss

Mobile SyncService — Unit (Jest)

ScenarioExpected result
runSync() calledPull executed first; lastPulledAt updated; push executed after; no interleaving
Pull returns guest_hintsHint map applied before WatermelonDB write; local UUIDs remapped to server UUIDs
Sync fails during pull (network error)Push is skipped; error event emitted; lastPulledAt not updated
Sync fails during push (409)Re-pull triggered automatically; push retried once; success on retry
Concurrent runSync() callsSecond call is a no-op while first is in progress
Exponential back-off on repeated failureRetry delays are 1 s, 2 s, 4 s, … capped at 60 s

WatermelonDB Local Availability — E2E (Jest + memory adapter)

ScenarioExpected result
Complete sync cycle with 3 new messagesAll 3 messages readable from WatermelonDB after runSync() completes
Soft-deleted message pulledWatermelonDB record has is_deleted = true; query with where('is_deleted', false) excludes it
Push new local messageMessage row appears in push payload; server mock receives correct body
Pull returns updated conversation nameWatermelonDB conversation.name updated to new value
lastPulledAt persisted across app restartNext runSync() uses stored timestamp; does not re-fetch old records

Test File Locations

server/
tests/
test_sync_pull.py
test_sync_push.py
test_sync_conflicts.py

mobile-app/sapot-mobile-app/
src/
features/sync/
__tests__/
SyncService.test.ts
syncOrchestration.integration.test.ts
guestHints.test.ts