import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:drift/native.dart'; import 'package:drift/drift.dart' as drift; import 'package:motorcycle_dash/database/database.dart'; import 'package:motorcycle_dash/models/recording_health_status.dart'; import 'package:motorcycle_dash/services/sync/sync_models.dart'; import 'package:motorcycle_dash/services/sync/sync_provider.dart'; import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; import 'package:motorcycle_dash/services/sync/sync_manager.dart'; import 'package:motorcycle_dash/services/sync/sync_observer.dart'; import 'package:motorcycle_dash/features/rides/ride_state_machine.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MotorSignal Phase 2A — Local Sync Foundation & Outbox Tests', () { late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); }); // 1. Enqueue one ride test('1. Outbox: Enqueue one ride', () async { final outbox = SyncOutbox(prefs); final item = await outbox.enqueue(101, 'fake'); expect(item.localRideId, equals(101)); expect(item.providerId, equals('fake')); expect(item.status, equals(SyncStatus.queued)); expect(outbox.count, equals(1)); expect(outbox.getItem(101, 'fake'), isNotNull); }); // 2. Enqueue duplicate ride test('2. Outbox: Enqueue duplicate ride prevents duplicate entry', () async { final outbox = SyncOutbox(prefs); final item1 = await outbox.enqueue(101, 'fake'); final item2 = await outbox.enqueue(101, 'fake'); expect(outbox.count, equals(1)); expect(item1.key, equals(item2.key)); }); // 3. Enqueue multiple rides test('3. Outbox: Enqueue multiple distinct rides', () async { final outbox = SyncOutbox(prefs); await outbox.enqueue(101, 'fake'); await outbox.enqueue(102, 'fake'); await outbox.enqueue(103, 'fake'); expect(outbox.count, equals(3)); final pending = outbox.getPendingItems(); expect(pending.length, equals(3)); }); // 4. Persistence across reload test('4. Outbox: Persistence across reload', () async { final outbox1 = SyncOutbox(prefs); await outbox1.enqueue(201, 'fake'); await outbox1.enqueue(202, 'fake'); // Create new instance from same preferences final outbox2 = SyncOutbox(prefs); expect(outbox2.count, equals(2)); expect(outbox2.getItem(201, 'fake')?.localRideId, equals(201)); expect(outbox2.getItem(202, 'fake')?.localRideId, equals(202)); }); // 5. Corruption recovery test('5. Outbox: Corruption recovery from invalid manifest data', () async { await prefs.setString('motorsignal_sync_outbox_manifest_v1', '{corrupt json invalid!!}'); final outbox = SyncOutbox(prefs); expect(outbox.isLoaded, isTrue); expect(outbox.count, equals(0)); // Gracefully resets without throwing }); // 6. Removal after success or cancellation test('6. Outbox: Removal of item', () async { final outbox = SyncOutbox(prefs); await outbox.enqueue(301, 'fake'); expect(outbox.count, equals(1)); await outbox.remove(301, 'fake'); expect(outbox.count, equals(0)); expect(outbox.getItem(301, 'fake'), isNull); }); }); group('MotorSignal Phase 2A — Sync State Transitions & Provider Dispatch Tests', () { late SharedPreferences prefs; late DateTime simulatedTime; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); simulatedTime = DateTime(2026, 8, 31, 8, 0, 0); }); // 7. Queued state test('7. State Transitions: Queued state', () async { final outbox = SyncOutbox(prefs); final item = await outbox.enqueue(401, 'fake', now: simulatedTime); expect(item.status, equals(SyncStatus.queued)); expect(item.status.isPending, isTrue); }); // 8. Waiting for network test('8. State Transitions: Waiting for network when provider unavailable', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.offline); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(402, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(402, 'fake'); expect(item?.status, equals(SyncStatus.waitingForNetwork)); }); // 9. Uploading state test('9. State Transitions: Uploading state', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(delayMs: 100); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(403, autoProcess: false); final processFuture = manager.processQueue(); expect(manager.isProcessing, isTrue); await processFuture; expect(manager.isProcessing, isFalse); }); // 10. Synced state upon success test('10. State Transitions: Synced state upon success', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.success); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(404, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(404, 'fake'); expect(item?.status, equals(SyncStatus.synced)); expect(item?.remoteId, isNotNull); expect(item?.uploadedAt, isNotNull); expect(outbox.isRideSynced(404, 'fake'), isTrue); }); // 11 & 12. Temporary failure and retry wait test('11-12. State Transitions: Temporary failure enters retry wait with backoff', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(405, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(405, 'fake'); expect(item?.status, equals(SyncStatus.retryWait)); expect(item?.attemptCount, equals(1)); expect(item?.nextRetryAt, equals(simulatedTime.add(const Duration(seconds: 5)))); expect(item?.lastError, contains('HTTP 503')); }); // 13. Authentication required test('13. State Transitions: Authentication required leaves item in authRequired', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.authRequired); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(406, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(406, 'fake'); expect(item?.status, equals(SyncStatus.authRequired)); }); // 14. Permanent failure test('14. State Transitions: Permanent failure terminates retries', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.permanentFailure); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(407, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(407, 'fake'); expect(item?.status, equals(SyncStatus.failed)); expect(item?.status.isTerminal, isTrue); }); // 15. Cancelled state test('15. State Transitions: Cancelled state', () async { final outbox = SyncOutbox(prefs); final manager = SyncManager(outbox, nowProvider: () => simulatedTime); await manager.enqueueRide(408, autoProcess: false); await manager.cancelSync(408, 'fake'); final item = outbox.getItem(408, 'fake'); expect(item?.status, equals(SyncStatus.cancelled)); expect(item?.status.isTerminal, isTrue); }); }); group('MotorSignal Phase 2A — Retry & Exponential Backoff Tests', () { late SharedPreferences prefs; late DateTime simulatedTime; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); simulatedTime = DateTime(2026, 8, 31, 10, 0, 0); }); // 16. Exponential backoff step calculation test('16. Retry: Exponential backoff calculation steps', () { expect(SyncManager.calculateRetryDelay(1), equals(const Duration(seconds: 5))); expect(SyncManager.calculateRetryDelay(2), equals(const Duration(seconds: 30))); expect(SyncManager.calculateRetryDelay(3), equals(const Duration(minutes: 2))); expect(SyncManager.calculateRetryDelay(4), equals(const Duration(minutes: 5))); expect(SyncManager.calculateRetryDelay(5), equals(const Duration(minutes: 15))); expect(SyncManager.calculateRetryDelay(6), equals(const Duration(minutes: 30))); }); // 17. Maximum delay capped at 30 minutes test('17. Retry: Maximum delay is capped at 30 minutes for attempt 7+', () { expect(SyncManager.calculateRetryDelay(7), equals(const Duration(minutes: 30))); expect(SyncManager.calculateRetryDelay(100), equals(const Duration(minutes: 30))); }); // 18 & 19. Retry count persistence and restart preservation test('18-19. Retry: Attempt count and nextRetryAt persist across restart', () async { final outbox1 = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure); final manager1 = SyncManager( outbox1, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager1.enqueueRide(501, autoProcess: false); await manager1.processQueue(); // Attempt 1 -> 5s backoff simulatedTime = simulatedTime.add(const Duration(seconds: 6)); await manager1.processQueue(); // Attempt 2 -> 30s backoff final itemBefore = outbox1.getItem(501, 'fake'); expect(itemBefore?.attemptCount, equals(2)); // Simulate App Restart final outbox2 = SyncOutbox(prefs); final itemAfter = outbox2.getItem(501, 'fake'); expect(itemAfter?.attemptCount, equals(2)); expect(itemAfter?.status, equals(SyncStatus.retryWait)); expect(itemAfter?.nextRetryAt, isNotNull); }); // 20. Failed upload eventually succeeds on subsequent retry test('20. Retry: Item in retryWait succeeds once provider becomes healthy', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); await manager.enqueueRide(502, autoProcess: false); await manager.processQueue(); expect(outbox.getItem(502, 'fake')?.status, equals(SyncStatus.retryWait)); // Provider recovers provider.behavior = FakeSyncBehavior.success; // Fast forward past retry delay simulatedTime = simulatedTime.add(const Duration(seconds: 10)); await manager.processQueue(); expect(outbox.getItem(502, 'fake')?.status, equals(SyncStatus.synced)); }); }); group('MotorSignal Phase 2A — Fake Provider & Manager Dispatch Tests', () { late SharedPreferences prefs; late DateTime simulatedTime; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); simulatedTime = DateTime(2026, 8, 31, 12, 0, 0); }); // 21. Successful upload test('21. Fake Provider: Successful upload records item', () async { final provider = FakeSyncProvider(behavior: FakeSyncBehavior.success); final item = SyncItem( localRideId: 601, providerId: 'fake', createdAt: simulatedTime, ); final res = await provider.uploadRide(item); expect(res.success, isTrue); expect(provider.uploadedItems.length, equals(1)); }); // 22-25. Fake Provider behavior simulations test('22-25. Fake Provider: Error and auth behaviors', () async { final provider = FakeSyncProvider(); final item = SyncItem(localRideId: 602, providerId: 'fake', createdAt: simulatedTime); provider.behavior = FakeSyncBehavior.temporaryFailure; expect((await provider.uploadRide(item)).isRetryable, isTrue); provider.behavior = FakeSyncBehavior.permanentFailure; expect((await provider.uploadRide(item)).isRetryable, isFalse); provider.behavior = FakeSyncBehavior.authRequired; expect((await provider.uploadRide(item)).requiresAuth, isTrue); }); // 26-30. SyncManager queue processing, skipping ineligible items, and multi-ride test('26-30. SyncManager: Processes multiple rides and respects retry delays', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.success); final manager = SyncManager( outbox, initialProviders: [provider], nowProvider: () => simulatedTime, ); // Enqueue 3 rides await manager.enqueueRide(701, autoProcess: false); await manager.enqueueRide(702, autoProcess: false); await manager.enqueueRide(703, autoProcess: false); expect(manager.pendingItems.length, equals(3)); await manager.processQueue(); expect(manager.pendingItems.length, equals(0)); expect(outbox.isRideSynced(701, 'fake'), isTrue); expect(outbox.isRideSynced(702, 'fake'), isTrue); expect(outbox.isRideSynced(703, 'fake'), isTrue); final state = manager.currentState; expect(state.syncedCount, equals(3)); expect(state.pendingCount, equals(0)); expect(state.lastSuccessfulSync, isNotNull); }); }); group('MotorSignal Phase 2A — External SyncObserver & Subsystem Isolation Tests', () { late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); }); // 31-33. External observer triggers on finishRide without modifying RideStateMachine or Schema 5 test('31-33. Subsystem Isolation: External SyncObserver enqueues completed rides safely', () async { final outbox = SyncOutbox(prefs); final manager = SyncManager(outbox); final observer = SyncObserver(manager); // Verify Drift Schema 5 database is operational and untouched final db = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(db.close); final startTime = DateTime(2026, 8, 31, 14, 0, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, distanceMiles: const drift.Value(5.2), topSpeedMph: const drift.Value(52.0), )); // Simulate RideStateMachine state transition from riding -> finished final prevSession = RideSession( dbId: rideId, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, startTime: startTime, ); final nextSession = prevSession.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, ); // External observer receives state notification observer.onRideSessionChanged(prevSession, nextSession); // Confirm item was enqueued to outbox expect(outbox.count, equals(1)); expect(outbox.getItem(rideId, 'fake'), isNotNull); expect(outbox.getItem(rideId, 'fake')?.status, equals(SyncStatus.queued)); // Verify no duplicate if observer is called again with same finished session observer.onRideSessionChanged(prevSession, nextSession); expect(outbox.count, equals(1)); }); test('Stage 2B: production startup instantiates the ride sync observer', () { final mainSource = File('lib/main.dart').readAsStringSync(); expect(mainSource, contains("import 'services/sync/sync_observer.dart';")); expect(mainSource, contains('ref.read(syncObserverProvider);')); }); test('Stage 2B: completed ride transition enters durable firebase outbox', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider( providerId: 'firebase', behavior: FakeSyncBehavior.authRequired, ); final manager = SyncManager( outbox, initialProviders: [provider], defaultProviderId: 'firebase', ); final observer = SyncObserver(manager); addTearDown(manager.dispose); final previous = RideSession( dbId: 901, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, ); final completed = previous.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, ); observer.onRideSessionChanged(previous, completed); await Future.delayed(Duration.zero); final item = outbox.getItem(901, 'firebase'); expect(item, isNotNull); expect(item?.localRideId, 901); expect(item?.providerId, 'firebase'); expect( prefs.getString(outbox.storageKey), contains('"localRideId":901'), ); }); test('Stage 2B: automatic completion does not invoke legacy queue path', () { final rideStateMachineSource = File('lib/features/rides/ride_state_machine.dart').readAsStringSync(); final legacyServiceSource = File('lib/services/github/github_sync_service.dart').readAsStringSync(); expect(rideStateMachineSource, isNot(contains('queueRideForUpload'))); expect( rideStateMachineSource, isNot(contains('services/github/github_sync_service.dart')), ); expect(legacyServiceSource, contains('queueRideForUpload')); expect(legacyServiceSource, contains('uploadRideToGitHub')); }); test('Stage 2B: duplicate completed emissions keep one active outbox item', () async { final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [ FakeSyncProvider( providerId: 'firebase', behavior: FakeSyncBehavior.authRequired, ), ], defaultProviderId: 'firebase', ); final observer = SyncObserver(manager); addTearDown(manager.dispose); final riding = RideSession( dbId: 902, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, ); final finished = riding.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, ); observer.onRideSessionChanged(riding, finished); observer.onRideSessionChanged(riding, finished); observer.onRideSessionChanged(finished, finished); await Future.delayed(Duration.zero); expect(outbox.count, 1); expect( outbox.getAllItems().where((item) => item.localRideId == 902), hasLength(1), ); expect(outbox.getItem(902, 'firebase'), isNotNull); }); }); }