import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:drift/drift.dart' as drift; import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:motorcycle_dash/database/database.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/cloud_reconciliation_service.dart'; import 'package:motorcycle_dash/services/sync/cloud_restore_controller.dart'; import 'package:motorcycle_dash/services/sync/cloud_restore_service.dart'; import 'package:motorcycle_dash/services/sync/r2_download_service.dart'; import 'package:motorcycle_dash/features/records/records_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class _TestAuthService implements AuthService { @override bool get isEmailVerified => false; @override Future sendEmailVerification() async {} @override Future reloadCurrentUser() async {} @override Future reauthenticate({String? password}) async {} @override Future deleteCurrentUser() async {} @override Future ensureBackendIdentity() async {} AuthUser? _user; String? token = 'token'; bool configured = true; _TestAuthService([this._user]); void setUser(AuthUser? user) => _user = user; @override Stream get authStateChanges => Stream.value(currentState); @override AuthState get currentState { final user = _user; if (user == null) { return AuthState.unauthenticated(isFirebaseConfigured: configured); } return user.isAnonymous ? AuthState.anonymous(user, isFirebaseConfigured: configured) : AuthState.authenticated(user, isFirebaseConfigured: configured); } @override AuthUser? get currentUser => _user; @override bool get isConfigured => configured; @override Future getIdToken([bool forceRefresh = false]) async => token; @override Future createUserWithEmailAndPassword( String email, String password, { String? displayName, }) async {} @override Future sendPasswordResetEmail(String email) async {} @override Future signInAnonymously() async { _user = const AuthUser(uid: 'anon_1', isAnonymous: true); } @override Future signInWithEmailAndPassword(String email, String password) async { _user = AuthUser(uid: 'user_a', email: email); } @override Future signInWithGoogle() async { _user = const AuthUser(uid: 'user_google', email: 'rider@test.com'); } @override Future signOut() async { _user = null; } @override void dispose() {} } class _MemoryRideMetadataStore implements CloudRideMetadataStore { final Map> byUid; final List? documentsForEveryUser; _MemoryRideMetadataStore({ Map>? byUid, this.documentsForEveryUser, }) : byUid = byUid ?? {}; @override Future> listRideDocuments(String uid) async { return documentsForEveryUser ?? byUid[uid] ?? const []; } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Manual MotorCloud restore', () { late AppDatabase db; late _TestAuthService auth; late int r2Requests; setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); auth = _TestAuthService( const AuthUser(uid: 'user_a', email: 'a@test.com'), ); r2Requests = 0; }); tearDown(() async { await db.close(); }); CloudRestoreService service({ required CloudRideMetadataStore store, required http.Client client, }) { return CloudRestoreService( db: db, authService: auth, metadataStore: store, r2DownloadService: R2DownloadService( client: client, workerBaseUrl: 'https://worker.test', ), ); } test( 'clean install restores metadata, duration, telemetry, and is idempotent', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ _cloudRide( id: 'ride_clean_install', ownerId: 'user_a', movingTime: 600, stoppedTime: 45, performanceRuns: [ _performanceRun( type: '0-60', timeMs: 3210, topSpeedReached: 62, ), ], ), ], }, ); final zipBytes = _telemetryZip(samples: 2); final client = _downloadClient(zipBytes); final restore = service(store: store, client: client); final first = await restore.restore(); final second = await restore.restore(); final rides = await db.select(db.rides).get(); final samples = await db.select(db.telemetrySamples).get(); expect(first.cloudRidesDiscovered, 1); expect(first.metadataRowsAdded, 1); expect(first.telemetryRidesRestored, 1); expect(first.telemetrySamplesRestored, 2); expect(first.performanceRunsRestored, 1); expect(rides, hasLength(1)); expect(rides.single.cloudRideId, 'ride_clean_install'); expect(rides.single.movingTime, 600); expect(rides.single.stoppedTime, 45); expect(samples, hasLength(2)); final runs = await db.select(db.performanceRuns).get(); expect(runs, hasLength(1)); expect(runs.single.rideId, rides.single.id); expect(runs.single.type, '0-60'); expect(runs.single.timeMs, 3210); expect(second.metadataRowsAdded, 0); expect(second.telemetrySamplesRestored, 0); expect(second.telemetryAlreadyPresent, 1); expect(second.performanceRunsRestored, 0); expect(second.performanceRunsAlreadyPresent, 1); expect(await db.select(db.rides).get(), hasLength(1)); expect(await db.select(db.telemetrySamples).get(), hasLength(2)); expect(await db.select(db.performanceRuns).get(), hasLength(1)); }, ); test( 'signed out and anonymous users cannot mutate local restore data', () async { final store = _MemoryRideMetadataStore( documentsForEveryUser: [ _cloudRide(id: 'ride_auth_guard', ownerId: 'user_a'), ], ); final restore = service( store: store, client: _downloadClient(_telemetryZip()), ); auth.setUser(null); await expectLater(restore.restore(), throwsA(isA())); expect(await db.select(db.rides).get(), isEmpty); auth.setUser(const AuthUser(uid: 'anon_1', isAnonymous: true)); await expectLater(restore.restore(), throwsA(isA())); expect(await db.select(db.rides).get(), isEmpty); }, ); test( 'missing R2 object keeps additive metadata but restores no telemetry', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [_cloudRide(id: 'ride_missing_r2', ownerId: 'user_a')], }, ); final restore = service( store: store, client: MockClient((request) async { r2Requests++; return http.Response('missing', 404); }), ); final result = await restore.restore(); expect(result.metadataRowsAdded, 1); expect(result.telemetryFailed, 1); expect(r2Requests, 1); expect(await db.select(db.rides).get(), hasLength(1)); expect(await db.select(db.telemetrySamples).get(), isEmpty); }, ); test('SHA mismatch does not insert telemetry', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [_cloudRide(id: 'ride_bad_sha', ownerId: 'user_a')], }, ); final body = _telemetryZip(samples: 1); final restore = service( store: store, client: MockClient( (request) async => http.Response.bytes( body, 200, headers: { 'x-motorsignal-ride-id': request.url.queryParameters['rideId']!, 'x-motorsignal-sha256': '0' * 64, }, ), ), ); final result = await restore.restore(); expect(result.metadataRowsAdded, 1); expect(result.telemetryFailed, 1); expect(await db.select(db.telemetrySamples).get(), isEmpty); }); test('R2 authentication failures are reported separately', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [_cloudRide(id: 'ride_auth_r2', ownerId: 'user_a')], }, ); final restore = service( store: store, client: MockClient( (request) async => http.Response('auth required', 401), ), ); final result = await restore.restore(); expect(result.metadataRowsAdded, 1); expect(result.telemetryAuthFailures, 1); expect(result.requiresAuthentication, isTrue); expect(await db.select(db.telemetrySamples).get(), isEmpty); }); test( 'existing local telemetry is never overwritten or downloaded again', () async { final rideId = await db .into(db.rides) .insert( RidesCompanion.insert( cloudRideId: const drift.Value('ride_existing_local'), startTime: DateTime.utc(2026, 9, 3, 10), ), ); await db .into(db.telemetrySamples) .insert( TelemetrySamplesCompanion.insert( rideId: rideId, timestamp: DateTime.utc(2026, 9, 3, 10), speed: const drift.Value(31), ), ); final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ _cloudRide(id: 'ride_existing_local', ownerId: 'user_a'), ], }, ); final restore = service( store: store, client: MockClient((request) async { r2Requests++; return http.Response.bytes(_telemetryZip(), 200); }), ); final result = await restore.restore(); final samples = await db.select(db.telemetrySamples).get(); expect(result.telemetryAlreadyPresent, 1); expect(r2Requests, 0); expect(samples, hasLength(1)); expect(samples.single.speed, 31); }, ); test( 'two cloud rides restore performance runs without cross-association', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ _cloudRide( id: 'ride_child_a', ownerId: 'user_a', performanceRuns: [ _performanceRun( type: '0-60', timeMs: 3000, topSpeedReached: 61, timestamp: DateTime.utc(2026, 9, 3, 10, 1), ), ], ), _cloudRide( id: 'ride_child_b', ownerId: 'user_a', performanceRuns: [ _performanceRun( type: '0-100', timeMs: 6400, topSpeedReached: 102, timestamp: DateTime.utc(2026, 9, 3, 11, 1), ), ], ), ], }, ); final result = await service( store: store, client: _downloadClient(_telemetryZip()), ).restore(); final rides = await db.select(db.rides).get(); final rideA = rides.singleWhere( (ride) => ride.cloudRideId == 'ride_child_a', ); final rideB = rides.singleWhere( (ride) => ride.cloudRideId == 'ride_child_b', ); final rideARuns = await (db.select( db.performanceRuns, )..where((run) => run.rideId.equals(rideA.id))).get(); final rideBRuns = await (db.select( db.performanceRuns, )..where((run) => run.rideId.equals(rideB.id))).get(); expect(result.performanceRunsRestored, 2); expect(rideARuns.single.type, '0-60'); expect(rideBRuns.single.type, '0-100'); expect(rideARuns.single.rideId, isNot(rideBRuns.single.rideId)); }, ); test( 'existing local performance and ride events are not overwritten or duplicated', () async { final rideId = await db .into(db.rides) .insert( RidesCompanion.insert( cloudRideId: const drift.Value('ride_existing_children'), startTime: DateTime.utc(2026, 9, 3, 10), ), ); await db .into(db.performanceRuns) .insert( PerformanceRunsCompanion.insert( rideId: drift.Value(rideId), type: '0-60', timeMs: 3500, topSpeedReached: 63, timestamp: DateTime.utc(2026, 9, 3, 10, 1), ), ); await db .into(db.rideEvents) .insert( RideEventsCompanion.insert( rideId: rideId, timestamp: DateTime.utc(2026, 9, 3, 10, 2), eventType: 'Hard Braking', value: -0.9, ), ); final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ _cloudRide( id: 'ride_existing_children', ownerId: 'user_a', totalEventsCount: 1, performanceRuns: [ _performanceRun( type: '0-60', timeMs: 3500, topSpeedReached: 63, timestamp: DateTime.utc(2026, 9, 3, 10, 1), ), _performanceRun( type: '0-100', timeMs: 7000, topSpeedReached: 101, timestamp: DateTime.utc(2026, 9, 3, 10, 3), ), ], ), ], }, ); final result = await service( store: store, client: _downloadClient(_telemetryZip()), ).restore(); final runs = await db.select(db.performanceRuns).get(); final events = await db.select(db.rideEvents).get(); expect(result.performanceRunsRestored, 1); expect(result.performanceRunsAlreadyPresent, 1); expect(result.rideEventsAlreadyPresent, 1); expect(runs, hasLength(2)); expect(events, hasLength(1)); expect(events.single.eventType, 'Hard Braking'); }, ); test( 'malformed performance data does not destroy restored telemetry', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ _cloudRide( id: 'ride_bad_performance', ownerId: 'user_a', performanceRuns: [ {'type': '0-60', 'timeMs': 'bad'}, ], ), ], }, ); final result = await service( store: store, client: _downloadClient(_telemetryZip(samples: 2)), ).restore(); expect(result.telemetrySamplesRestored, 2); expect(result.performanceRunsSkipped, 1); expect(await db.select(db.telemetrySamples).get(), hasLength(2)); expect(await db.select(db.performanceRuns).get(), isEmpty); }, ); test( 'canonical ride events are not fabricated from aggregate cloud counts', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ _cloudRide( id: 'ride_event_gap', ownerId: 'user_a', totalEventsCount: 3, ), ], }, ); final result = await service( store: store, client: _downloadClient(_telemetryZip(samples: 1)), ).restore(); expect(result.telemetrySamplesRestored, 1); expect(result.rideEventsUnavailable, 3); expect(await db.select(db.rideEvents).get(), isEmpty); }, ); test( 'legacy cloud bundle without child data still restores ride and telemetry', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [_cloudRide(id: 'ride_legacy_bundle', ownerId: 'user_a')], }, ); final result = await service( store: store, client: _downloadClient(_telemetryZip(samples: 1)), ).restore(); expect(result.metadataRowsAdded, 1); expect(result.telemetrySamplesRestored, 1); expect(result.performanceRunsRestored, 0); expect(result.rideEventsRestored, 0); expect(await db.select(db.rides).get(), hasLength(1)); expect(await db.select(db.telemetrySamples).get(), hasLength(1)); }, ); test( 'wrong-owner cloud metadata is skipped for the current account', () async { final store = _MemoryRideMetadataStore( documentsForEveryUser: [ _cloudRide(id: 'ride_user_a_only', ownerId: 'user_a'), ], ); auth.setUser(const AuthUser(uid: 'user_b', email: 'b@test.com')); final result = await service( store: store, client: _downloadClient(_telemetryZip()), ).restore(); expect(result.cloudRidesDiscovered, 1); expect(result.metadata.skipped, 1); expect(await db.select(db.rides).get(), isEmpty); expect(await db.select(db.telemetrySamples).get(), isEmpty); }, ); test( 'controller reports authentication required without invoking restore', () async { auth.setUser(null); final controller = CloudRestoreController( authService: auth, restoreService: service( store: _MemoryRideMetadataStore( documentsForEveryUser: [ _cloudRide(id: 'ride_controller', ownerId: 'user_a'), ], ), client: _downloadClient(_telemetryZip()), ), ); final result = await controller.restoreFromMotorCloud(); expect(result, isNull); expect(controller.state.status, CloudRestoreStatus.authRequired); expect(await db.select(db.rides).get(), isEmpty); }, ); test( 'legacy cloud metadata storing uid instead of ownerId is restored', () async { final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ CloudRideMetadataDocument( id: 'ride_legacy_uid', data: { 'id': 'ride_legacy_uid', 'uid': 'user_a', // Legacy schema: uid present, ownerId null 'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(), 'endTime': DateTime.utc(2026, 9, 3, 10, 15).toIso8601String(), 'distanceMiles': 5.5, 'topSpeedMph': 75.0, 'maxLeanLeft': 28.0, 'maxLeanRight': 31.0, 'maxAccelG': 0.65, 'maxBrakingG': -0.72, 'best0to60Ms': 3450.0, 'movingTime': 900, 'stoppedTime': 0, 'elevationGain': 450.0, 'elevationLoss': 200.0, }, ), ], }, ); final zipBytes = _telemetryZip(samples: 2); final client = _downloadClient(zipBytes); final restore = service(store: store, client: client); final result = await restore.restore(); expect(result.cloudRidesDiscovered, 1); expect(result.metadataRowsAdded, 1); expect(result.telemetryRidesRestored, 1); expect(result.telemetrySamplesRestored, 2); final rides = await db.select(db.rides).get(); expect(rides, hasLength(1)); expect(rides.single.cloudRideId, 'ride_legacy_uid'); expect(rides.single.distanceMiles, 5.5); expect(rides.single.topSpeedMph, 75.0); expect(rides.single.best0to60Ms, 3450.0); }, ); test( 'local ID collision does not cause conflict; inserts cloud ride as a new local ride', () async { // Pre-insert a local ride with SQLite ID 1 and a DIFFERENT cloudRideId await db.customStatement(''' INSERT INTO rides (id, cloud_ride_id, start_time, distance_miles, top_speed_mph) VALUES (1, 'ride_existing_local', 1700000000000, 10.0, 50.0) '''); // Cloud ride also has localRideId: 1 from its original device, but cloud ID 'ride_from_cloud' final store = _MemoryRideMetadataStore( byUid: { 'user_a': [ CloudRideMetadataDocument( id: 'ride_from_cloud', data: { 'id': 'ride_from_cloud', 'localRideId': 1, 'ownerId': 'user_a', 'startTime': DateTime.utc(2026, 9, 10, 14).toIso8601String(), 'distanceMiles': 22.4, 'topSpeedMph': 88.0, }, ), ], }, ); final restore = service(store: store, client: _downloadClient(_telemetryZip())); final result = await restore.restore(); // Must NOT report conflict! Must add as a new row expect(result.metadata.conflicts, 0); expect(result.metadataRowsAdded, 1); final rides = await db.select(db.rides).get(); expect(rides, hasLength(2)); expect(rides.any((r) => r.cloudRideId == 'ride_existing_local'), isTrue); expect(rides.any((r) => r.cloudRideId == 'ride_from_cloud'), isTrue); }, ); test( 'recalculating records from database populates personal records and benchmarks', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); // Insert rides with speed, lean, Gs, best 0-60 await db.customStatement(''' INSERT INTO rides ( cloud_ride_id, start_time, distance_miles, top_speed_mph, max_lean_left, max_lean_right, max_accel_g, max_braking_g, elevation_gain, best0to60_ms ) VALUES ( 'ride_rec_1', 1700000000000, 45.2, 94.5, 38.5, 42.0, 0.85, -0.92, 1850.0, 3150.0 ) '''); // Insert a performance run for 0-100 await db.addPerformanceRun( PerformanceRunsCompanion.insert( type: '0-100', timeMs: 7200.0, topSpeedReached: 101.2, timestamp: DateTime.utc(2026, 9, 15), ), ); final recordsService = RecordsService(prefs); expect(recordsService.state.topSpeedMph, 0.0); // initially empty await recordsService.recalculateFromDatabase(db); expect(recordsService.state.topSpeedMph, 94.5); expect(recordsService.state.maxLeanLeft, 38.5); expect(recordsService.state.maxLeanRight, 42.0); expect(recordsService.state.maxAccelG, 0.85); expect(recordsService.state.maxBrakingG, 0.92); expect(recordsService.state.longestRideMiles, 45.2); expect(recordsService.state.greatestElevationGain, 1850.0); expect(recordsService.state.fastest0To60, 3.15); // 3150 ms / 1000 expect(recordsService.state.fastest0To100, 7.20); // 7200 ms / 1000 }, ); }); } CloudRideMetadataDocument _cloudRide({ required String id, required String ownerId, int movingTime = 120, int stoppedTime = 10, List>? performanceRuns, int totalEventsCount = 0, }) { return CloudRideMetadataDocument( id: id, data: { 'id': id, 'ownerId': ownerId, 'startTime': DateTime.utc(2026, 9, 3, 10).toIso8601String(), 'endTime': DateTime.utc(2026, 9, 3, 10, 12).toIso8601String(), 'distanceMiles': 4.2, 'topSpeedMph': 62.5, 'maxLeanLeft': 12.0, 'maxLeanRight': 14.0, 'maxAccelG': 0.4, 'maxBrakingG': -0.6, 'averageSpeed': 35.0, 'movingTime': movingTime, 'stoppedTime': stoppedTime, 'elevationGain': 50.0, 'elevationLoss': 20.0, 'motorcycleId': 7, 'motorcycleYear': 2024, 'motorcycleMake': 'Yamaha', 'motorcycleModel': 'MT-09', 'motorcycleVariant': 'SP', 'totalEventsCount': totalEventsCount, 'performanceRuns': ?performanceRuns, 'telemetryBundlePath': 'users/$ownerId/rides/$id/telemetry.zip', }, ); } Map _performanceRun({ required String type, required double timeMs, required double topSpeedReached, DateTime? timestamp, }) { return { 'type': type, 'timeMs': timeMs, 'topSpeedReached': topSpeedReached, 'timestamp': (timestamp ?? DateTime.utc(2026, 9, 3, 10, 1)) .toIso8601String(), }; } MockClient _downloadClient(List zipBytes) { return MockClient((request) async { final rideId = request.url.queryParameters['rideId']!; return http.Response.bytes( zipBytes, 200, headers: { 'x-motorsignal-ride-id': rideId, 'x-motorsignal-sha256': sha256.convert(zipBytes).toString(), }, ); }); } Uint8List _telemetryZip({int samples = 1}) { final csv = StringBuffer() ..writeln( 'timestamp,latitude,longitude,altitude,speed,heading,gpsAccuracy,leanAngle,pitchAngle,accelX,accelY,accelZ,longitudinalG,lateralG', ); for (var i = 0; i < samples; i++) { csv.writeln( '2026-09-03T10:00:0${i}Z,37.$i,-122.$i,10,${20 + i},90,5,10,1,0.1,0.2,0.3,0.01,0.02', ); } final bytes = utf8.encode(csv.toString()); final archive = Archive() ..addFile(ArchiveFile('telemetry.csv', bytes.length, bytes)); return Uint8List.fromList( ZipEncoder().encode(archive, modified: DateTime.utc(1980, 1, 1)), ); }