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/auth/auth_service.dart'; import 'package:motorcycle_dash/services/auth/auth_state.dart'; import 'package:motorcycle_dash/services/auth/auth_user.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_bundle_worker.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'; class TestMockAuthService implements AuthService { AuthUser? _user; AuthState _state = const AuthState.unauthenticated(); void setUser(AuthUser? user) { _user = user; if (user != null) { _state = user.isAnonymous ? AuthState.anonymous(user) : AuthState.authenticated(user); } else { _state = const AuthState.unauthenticated(); } } @override bool get isConfigured => true; @override AuthUser? get currentUser => _user; @override AuthState get currentState => _state; @override Stream get authStateChanges => Stream.value(_state); @override Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} @override Future sendPasswordResetEmail(String email) async {} @override Future signInAnonymously() async { setUser(const AuthUser(uid: 'anon_999', isAnonymous: true)); } @override Future signInWithEmailAndPassword(String email, String password) async { setUser(AuthUser(uid: 'user_${email.hashCode}', email: email)); } @override Future signInWithGoogle() async { setUser(const AuthUser(uid: 'google_123', email: 'google_rider@test.com')); } @override Future getIdToken([bool forceRefresh = false]) async { if (_user == null) return null; return 'mock_token_for_${_user!.uid}'; } @override Future signOut() async { _user = null; _state = const AuthState.unauthenticated(); } @override void dispose() {} } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MotorSignal Phase 2C — Cloud Sync Hardening & Production Matrix Tests', () { late SharedPreferences prefs; late AppDatabase db; late TestMockAuthService authService; late DateTime simulatedTime; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = AppDatabase.forTesting(NativeDatabase.memory()); authService = TestMockAuthService(); simulatedTime = DateTime(2026, 8, 31, 12, 0, 0); }); tearDown(() async { await db.close(); }); // 1. Offline ride -> Ride records normally test('Matrix 1: Offline ride records locally in SQLite without cloud requirement', () async { final startTime = DateTime(2026, 8, 31, 8, 0, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, distanceMiles: const drift.Value(12.5), topSpeedMph: const drift.Value(65.0), )); final fetched = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle(); expect(fetched.id, equals(rideId)); expect(fetched.distanceMiles, equals(12.5)); }); // 2. Signed-out ride -> Ride records normally test('Matrix 2: Signed-out rider records normally and sync transitions safely to AUTH_REQUIRED', () async { authService.setUser(null); final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.authRequired); final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake'); final item = await manager.enqueueRide(101, autoProcess: false); expect(item.status, equals(SyncStatus.queued)); await manager.processQueue(); final itemAfter = outbox.getItem(101, 'fake'); expect(itemAfter?.status, equals(SyncStatus.authRequired)); }); // 3. Anonymous ride -> Ride records normally test('Matrix 3: Anonymous rider records normally', () async { await authService.signInAnonymously(); expect(authService.currentState.isAnonymous, isTrue); final startTime = DateTime(2026, 8, 31, 9, 0, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, distanceMiles: const drift.Value(4.2), topSpeedMph: const drift.Value(45.0), )); final ride = await (db.select(db.rides)..where((r) => r.id.equals(rideId))).getSingle(); expect(ride.id, equals(rideId)); }); // 4. Authenticated ride -> Ride records normally test('Matrix 4: Authenticated rider records normally', () async { await authService.signInWithEmailAndPassword('pilot@apex.com', 'secret'); expect(authService.currentState.isAuthenticated, isTrue); final startTime = DateTime(2026, 8, 31, 10, 0, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, distanceMiles: const drift.Value(22.1), topSpeedMph: const drift.Value(85.0), )); expect(rideId, isPositive); }); // 5. Firebase unavailable -> Local ride unaffected test('Matrix 5: Firebase unavailable leaves local ride intact and marks sync waiting', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.offline); final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake'); await manager.enqueueRide(105, autoProcess: false); await manager.processQueue(); expect(outbox.getItem(105, 'fake')?.status, equals(SyncStatus.waitingForNetwork)); }); // 6. Network lost during upload -> Retry scheduled test('Matrix 6: Network lost during upload transitions to retryWait with exponential backoff', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure); final manager = SyncManager( outbox, initialProviders: [provider], defaultProviderId: 'fake', nowProvider: () => simulatedTime, ); await manager.enqueueRide(106, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(106, 'fake'); expect(item?.status, equals(SyncStatus.retryWait)); expect(item?.attemptCount, equals(1)); expect(item?.nextRetryAt, equals(simulatedTime.add(const Duration(seconds: 5)))); }); // 7. Permanent server failure -> FAILED terminal state test('Matrix 7: Permanent unrecoverable failure terminates retries', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.permanentFailure); final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake'); await manager.enqueueRide(107, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(107, 'fake'); expect(item?.status, equals(SyncStatus.failed)); expect(item?.status.isTerminal, isTrue); }); // 8. Auth expiration -> AUTH_REQUIRED test('Matrix 8: Auth expiration transitions to AUTH_REQUIRED without deleting local data', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.authRequired); final manager = SyncManager(outbox, initialProviders: [provider], defaultProviderId: 'fake'); await manager.enqueueRide(108, autoProcess: false); await manager.processQueue(); final item = outbox.getItem(108, 'fake'); expect(item?.status, equals(SyncStatus.authRequired)); }); // 9. App restart -> Outbox survives with retry metadata test('Matrix 9: App restart restores full outbox state and retry counts', () async { final outbox1 = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure); final manager1 = SyncManager( outbox1, initialProviders: [provider], defaultProviderId: 'fake', nowProvider: () => simulatedTime, ); await manager1.enqueueRide(109, autoProcess: false); await manager1.processQueue(); // Simulate App Restart with fresh instances over same SharedPreferences final outbox2 = SyncOutbox(prefs); expect(outbox2.count, equals(1)); final reloaded = outbox2.getItem(109, 'fake'); expect(reloaded?.status, equals(SyncStatus.retryWait)); expect(reloaded?.attemptCount, equals(1)); expect(reloaded?.nextRetryAt, equals(simulatedTime.add(const Duration(seconds: 5)))); }); // 10. Duplicate enqueue -> One item test('Matrix 10: Duplicate enqueue calls result in exactly one outbox entry', () async { final outbox = SyncOutbox(prefs); await outbox.enqueue(110, 'fake'); await outbox.enqueue(110, 'fake'); await outbox.enqueue(110, 'fake'); expect(outbox.count, equals(1)); }); // 11. Duplicate upload -> Deterministic cloud identifier test('Matrix 11: Duplicate upload attempts generate identical deterministic remote ID', () async { final startTime = DateTime(2026, 8, 31, 14, 30, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, distanceMiles: const drift.Value(10.0), topSpeedMph: const drift.Value(60.0), )); final dateStr = '20260831_143000'; final expectedRemoteId = 'ride_${rideId}_$dateStr'; expect(expectedRemoteId, equals('ride_${rideId}_20260831_143000')); }); // 12. Multiple providers -> Independent execution test('Matrix 12: Multiple providers operate independently for the same ride', () async { final outbox = SyncOutbox(prefs); final p1 = FakeSyncProvider(providerId: 'firebase', behavior: FakeSyncBehavior.success); final p2 = FakeSyncProvider(providerId: 'github', behavior: FakeSyncBehavior.temporaryFailure); final manager = SyncManager(outbox, initialProviders: [p1, p2]); await manager.enqueueRide(112, targetProviderId: 'firebase', autoProcess: false); await manager.enqueueRide(112, targetProviderId: 'github', autoProcess: false); await manager.processQueue(); expect(outbox.getItem(112, 'firebase')?.status, equals(SyncStatus.synced)); expect(outbox.getItem(112, 'github')?.status, equals(SyncStatus.retryWait)); }); // 13. Large bundle -> Background worker compression test('Matrix 13: Large telemetry dataset compresses without throwing', () async { final startTime = DateTime(2026, 8, 31, 16, 0, 0); final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, distanceMiles: const drift.Value(45.0), topSpeedMph: const drift.Value(92.0), )); // Seed 200 samples for (int i = 0; i < 200; i++) { await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert( rideId: rideId, timestamp: startTime.add(Duration(milliseconds: i * 50)), latitude: drift.Value(37.7749 + i * 0.0001), longitude: drift.Value(-122.4194 + i * 0.0001), speed: const drift.Value(55.0), leanAngle: const drift.Value(24.0), accelX: const drift.Value(0.12), accelY: const drift.Value(0.18), accelZ: const drift.Value(0.96), )); } final worker = SyncBundleWorker(db); final zipBytes = await worker.generateCompressedBundle(rideId); expect(zipBytes, isNotNull); expect(zipBytes!.length, isPositive); }); // 14. Historical backup -> Safe, repeatable batch enqueue test('Matrix 14: Historical backup safely enqueues all local rides', () async { final outbox = SyncOutbox(prefs); final manager = SyncManager(outbox); // Create 3 local rides for (int i = 1; i <= 3; i++) { await db.into(db.rides).insert(RidesCompanion.insert( startTime: DateTime(2026, 8, i, 10, 0, 0), distanceMiles: drift.Value(10.0 * i), topSpeedMph: drift.Value(50.0 + i), )); } final allRides = await db.select(db.rides).get(); for (final r in allRides) { await manager.enqueueRide(r.id, autoProcess: false); } expect(outbox.count, equals(3)); expect(outbox.getPendingItems().length, equals(3)); }); // 15. User A vs User B -> Path isolation test('Matrix 15: User A and User B cloud paths are strictly isolated by UID', () { const userAUid = 'rider_alpha_123'; const userBUid = 'rider_bravo_456'; const remoteRideId = 'ride_14_20260831_120000'; final userAPath = 'users/$userAUid/rides/$remoteRideId/telemetry.zip'; final userBPath = 'users/$userBUid/rides/$remoteRideId/telemetry.zip'; expect(userAPath, isNot(equals(userBPath))); expect(userAPath, contains(userAUid)); expect(userBPath, contains(userBUid)); }); // 16. Sync Now -> Non-blocking processing test('Matrix 16: processQueue executes asynchronously without blocking caller', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(delayMs: 50); final manager = SyncManager(outbox, initialProviders: [provider]); await manager.enqueueRide(116, autoProcess: false); final future = manager.processQueue(); expect(manager.isProcessing, isTrue); await future; expect(manager.isProcessing, isFalse); }); // 17. Retry Failed -> Correct items retried test('Matrix 17: retryAllPending re-arms failed and waiting items into queued status', () async { final outbox = SyncOutbox(prefs); final provider = FakeSyncProvider(behavior: FakeSyncBehavior.temporaryFailure); final manager = SyncManager(outbox, initialProviders: [provider]); await manager.enqueueRide(117, autoProcess: false); await manager.processQueue(); expect(outbox.getItem(117, 'fake')?.status, equals(SyncStatus.retryWait)); // Provider recovers provider.behavior = FakeSyncBehavior.success; await manager.retryAllPending(); expect(outbox.getItem(117, 'fake')?.status, equals(SyncStatus.synced)); }); // SyncObserver verification: riding -> finished transitions test('SyncObserver: non-finished to finished transition triggers single enqueue', () { final outbox = SyncOutbox(prefs); final manager = SyncManager(outbox); final observer = SyncObserver(manager); final prev = RideSession( dbId: 200, state: RideState.riding, healthStatus: RecordingHealthStatus.recordingActive, ); final next = prev.copyWith( state: RideState.finished, healthStatus: RecordingHealthStatus.recordingComplete, ); observer.onRideSessionChanged(prev, next); expect(outbox.count, equals(1)); // Repeated notification does not duplicate observer.onRideSessionChanged(prev, next); expect(outbox.count, equals(1)); }); }); }