diff --git a/test/offline_first_sync_contract_test.dart b/test/offline_first_sync_contract_test.dart new file mode 100644 index 0000000..bef39be --- /dev/null +++ b/test/offline_first_sync_contract_test.dart @@ -0,0 +1,129 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:motorcycle_dash/services/sync/sync_manager.dart'; +import 'package:motorcycle_dash/services/sync/sync_models.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; + +class _CountingProvider implements SyncProvider { + _CountingProvider({this.result = _Result.success}); + + static const _Result success = _Result.success; + static const _Result authRequired = _Result.authRequired; + static const _Result failure = _Result.failure; + + _Result result; + int uploadCount = 0; + + @override + String get providerId => 'firebase'; + + @override + Future isAvailable() async => true; + + @override + Future requiresAuthentication() async => result == _Result.authRequired; + + @override + Future uploadRide(SyncItem item) async { + uploadCount++; + switch (result) { + case _Result.success: + return SyncResult.success(remoteId: 'remote_${item.localRideId}'); + case _Result.authRequired: + return SyncResult.authRequired(message: 'Authentication required'); + case _Result.failure: + return SyncResult.permanentFailure('Upload rejected'); + } + } +} + +enum _Result { success, authRequired, failure } + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MotorSignal offline-first sync contract', () { + test('same ride uploaded twice is idempotent', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs, storageNamespace: 'rider_a'); + final provider = _CountingProvider(); + final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'firebase'); + + await manager.enqueueRide(42, autoProcess: false); + await manager.processQueue(); + expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.synced); + + await manager.enqueueRide(42, autoProcess: false); + await manager.processQueue(); + + expect(provider.uploadCount, 1); + expect(outbox.count, 1); + expect(outbox.getItem(42, 'firebase')?.remoteId, 'remote_42'); + }); + + test('User A and User B have isolated persisted sync state', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + final userA = SyncOutbox(prefs, storageNamespace: 'uid_user_a'); + await userA.enqueue(7, 'firebase'); + await userA.update(userA.getItem(7, 'firebase')!.copyWith(status: SyncStatus.synced)); + + final userB = SyncOutbox(prefs, storageNamespace: 'uid_user_b'); + expect(userB.count, 0); + expect(userB.getItem(7, 'firebase'), isNull); + + final userAReloaded = SyncOutbox(prefs, storageNamespace: 'uid_user_a'); + expect(userAReloaded.getItem(7, 'firebase')?.status, SyncStatus.synced); + }); + + test('signed-out upload is rejected before provider network upload', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs, storageNamespace: 'local'); + final provider = _CountingProvider(result: _Result.authRequired); + final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'firebase'); + + await manager.enqueueRide(9, autoProcess: false); + await manager.processQueue(); + + expect(outbox.getItem(9, 'firebase')?.status, SyncStatus.authRequired); + expect(provider.uploadCount, 0); + }); + + test('failed upload remains unsynced and retains failure state', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs, storageNamespace: 'uid_user_a'); + final provider = _CountingProvider(result: _Result.failure); + final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'firebase'); + + await manager.enqueueRide(11, autoProcess: false); + await manager.processQueue(); + + final item = outbox.getItem(11, 'firebase'); + expect(item?.status, SyncStatus.failed); + expect(item?.status, isNot(SyncStatus.synced)); + expect(item?.lastError, 'Upload rejected'); + }); + + test('successful sync survives app restart', () async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final firstOutbox = SyncOutbox(prefs, storageNamespace: 'uid_restart'); + final provider = _CountingProvider(); + final manager = SyncManager(firstOutbox, initialProviders: [provider], defaultProviderId: 'firebase'); + + await manager.enqueueRide(13, autoProcess: false); + await manager.processQueue(); + expect(firstOutbox.getItem(13, 'firebase')?.status, SyncStatus.synced); + + // Fresh outbox/manager instances represent a process restart. + final restartedOutbox = SyncOutbox(prefs, storageNamespace: 'uid_restart'); + expect(restartedOutbox.getItem(13, 'firebase')?.status, SyncStatus.synced); + expect(restartedOutbox.getItem(13, 'firebase')?.remoteId, 'remote_13'); + }); + }); +}