diff --git a/lib/services/sync/firebase_sync_provider.dart b/lib/services/sync/firebase_sync_provider.dart index 3a5d3d6..1bead10 100644 --- a/lib/services/sync/firebase_sync_provider.dart +++ b/lib/services/sync/firebase_sync_provider.dart @@ -41,6 +41,31 @@ class FirebaseSyncProvider implements SyncProvider { return user == null || user.isAnonymous; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async { + final user = _authService.currentUser; + if (user == null || user.isAnonymous) return false; + if (!await isAvailable()) return false; + try { + final doc = await _firestore! + .collection('users') + .doc(user.uid) + .collection('rides') + .doc(remoteRideId) + .get(); + if (!doc.exists) return false; + final data = doc.data(); + if (data == null) return false; + final bundle = data['telemetryBundle']; + if (bundle is Map && bundle['status'] == 'uploaded') { + return true; + } + return false; + } catch (_) { + return false; + } + } + @override Future uploadRide(SyncItem item) async { final user = _authService.currentUser; @@ -72,6 +97,10 @@ class FirebaseSyncProvider implements SyncProvider { // The cloud identity is generated once and then reused forever. Never // derive the remote identity from the local auto-increment database ID. final remoteRideId = await _db.ensureCloudRideId(item.localRideId); + if (await isRideConfirmedInCloud(remoteRideId)) { + return SyncResult.success(remoteId: remoteRideId); + } + final appIdentity = AppIdentity.current(); final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); if (zipBytes == null || zipBytes.isEmpty) return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); diff --git a/lib/services/sync/github_sync_provider.dart b/lib/services/sync/github_sync_provider.dart index ab66e0c..0e299db 100644 --- a/lib/services/sync/github_sync_provider.dart +++ b/lib/services/sync/github_sync_provider.dart @@ -23,6 +23,9 @@ class GitHubSyncProvider implements SyncProvider { @override Future requiresAuthentication() async => auth.getToken() == null; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { final hasAuth = auth.getToken() != null; diff --git a/lib/services/sync/sync_manager.dart b/lib/services/sync/sync_manager.dart index 786cf61..02d152d 100644 --- a/lib/services/sync/sync_manager.dart +++ b/lib/services/sync/sync_manager.dart @@ -6,6 +6,41 @@ import 'sync_provider.dart'; import '../../database/database.dart'; import '../auth/auth_service.dart'; +/// Diagnostic summary returned by [SyncManager.reconcileCompletedRides]. +class CompletedRideReconciliationReport { + final int discoveredCompletedRides; + final int alreadyTrackedCount; + final int recoveredQueuedCount; + final int recoveredAuthRequiredCount; + final int recoveredAlreadySyncedCount; + final int foreignOwnerSkippedCount; + final int errorCount; + final List errors; + + const CompletedRideReconciliationReport({ + this.discoveredCompletedRides = 0, + this.alreadyTrackedCount = 0, + this.recoveredQueuedCount = 0, + this.recoveredAuthRequiredCount = 0, + this.recoveredAlreadySyncedCount = 0, + this.foreignOwnerSkippedCount = 0, + this.errorCount = 0, + this.errors = const [], + }); + + int get totalRecovered => + recoveredQueuedCount + + recoveredAuthRequiredCount + + recoveredAlreadySyncedCount; + + @override + String toString() => + 'CompletedRideReconciliationReport(discovered: $discoveredCompletedRides, ' + 'tracked: $alreadyTrackedCount, queued: $recoveredQueuedCount, ' + 'authRequired: $recoveredAuthRequiredCount, alreadySynced: $recoveredAlreadySyncedCount, ' + 'foreignSkipped: $foreignOwnerSkippedCount, errors: $errorCount)'; +} + /// Central provider-agnostic synchronization coordinator. /// /// Sync work is persisted in [SyncOutbox]. The manager reconciles persisted @@ -16,17 +51,22 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { final Map _providers = {}; String _activeProviderId = 'fake'; bool _isProcessing = false; + Future? _activeReconciliation; + Future? _currentProcessingFuture; + bool _needsAnotherPass = false; DateTime? Function() _nowProvider; final AppDatabase? _db; final AuthService? _authService; - SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { + SyncManager(this._outbox, {List? initialProviders, String? defaultProviderId, DateTime? Function()? nowProvider, this._db, this._authService, bool autoStart = true}) : _nowProvider = nowProvider ?? (() => DateTime.now()) { if (initialProviders != null) { for (final p in initialProviders) { registerProvider(p); } } if (defaultProviderId != null) _activeProviderId = defaultProviderId; WidgetsBinding.instance.addObserver(this); - unawaited(processQueue()); + if (autoStart) { + unawaited(_startupRecoveryAndProcess()); + } } void setNowProvider(DateTime? Function() provider) => _nowProvider = provider; @@ -87,13 +127,166 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { return enqueued; } + Future _startupRecoveryAndProcess() async { + await reconcileCompletedRides(); + await processQueue(); + } + Future retryAuthRequired() async { + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + if (isAuthenticated && _db != null) { + await reconcileCompletedRides(); + } for (final item in _outbox.getAllItems().where((i) => i.status == SyncStatus.authRequired)) { + if (isAuthenticated && _db != null && item.providerId == 'firebase') { + await _db.claimRideForCloudOwner(item.localRideId, user.uid); + } await _outbox.update(item.copyWith(status: SyncStatus.queued, clearLastError: true, clearNextRetry: true)); } await processQueue(); } + /// Discovers completed rides in local SQLite that are missing from [SyncOutbox] + /// or need cloud confirmation recovery. + Future reconcileCompletedRides({ + String? targetProviderId, + }) async { + if (_activeReconciliation != null) { + return _activeReconciliation!; + } + final future = _performReconcileCompletedRides(targetProviderId: targetProviderId); + _activeReconciliation = future; + try { + return await future; + } finally { + _activeReconciliation = null; + } + } + + Future _performReconcileCompletedRides({ + String? targetProviderId, + }) async { + if (_db == null) { + return const CompletedRideReconciliationReport(); + } + + final providerId = targetProviderId ?? _activeProviderId; + final provider = _providers[providerId]; + final user = _authService?.currentUser; + final isAuthenticated = user != null && !user.isAnonymous; + + int discovered = 0; + int alreadyTracked = 0; + int recoveredQueued = 0; + int recoveredAuthRequired = 0; + int recoveredAlreadySynced = 0; + int foreignOwnerSkipped = 0; + int errorCount = 0; + final List errors = []; + + try { + final completedRides = await (_db.select(_db.rides) + ..where((r) => r.endTime.isNotNull())) + .get(); + discovered = completedRides.length; + + for (final ride in completedRides) { + try { + // Check if already tracked in outbox for this provider + final existingItem = _outbox.getItem(ride.id, providerId); + if (existingItem != null) { + alreadyTracked++; + continue; + } + + // Foreign account check: never claim or upload another user's ride + if (ride.cloudOwnerUid != null && + isAuthenticated && + ride.cloudOwnerUid != user.uid) { + foreignOwnerSkipped++; + continue; + } + if (ride.cloudOwnerUid != null && !isAuthenticated) { + foreignOwnerSkipped++; + continue; + } + + // Claim unclaimed ride if user is authenticated and target is firebase + if (ride.cloudOwnerUid == null && isAuthenticated && providerId == 'firebase') { + await _db.claimRideForCloudOwner(ride.id, user.uid); + } + + // Stable cloud identity check + final remoteRideId = await _db.ensureCloudRideId(ride.id); + + // Check if already confirmed uploaded in cloud backend + final isConfirmed = provider != null && + await provider.isRideConfirmedInCloud(remoteRideId); + + if (isConfirmed) { + final syncedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.synced, + remoteId: remoteRideId, + createdAt: ride.endTime ?? ride.startTime, + uploadedAt: _now(), + ); + await _outbox.update(syncedItem); + recoveredAlreadySynced++; + } else { + final providerRequiresAuth = provider != null && + await provider.requiresAuthentication(); + + if (providerRequiresAuth || (!isAuthenticated && providerId == 'firebase')) { + final authReqItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.authRequired, + createdAt: ride.endTime ?? ride.startTime, + lastError: 'Authentication required for provider "$providerId"', + ); + await _outbox.update(authReqItem); + recoveredAuthRequired++; + } else { + final queuedItem = SyncItem( + localRideId: ride.id, + providerId: providerId, + status: SyncStatus.queued, + createdAt: ride.endTime ?? ride.startTime, + ); + await _outbox.update(queuedItem); + recoveredQueued++; + } + } + } catch (itemErr) { + errorCount++; + errors.add('Ride #${ride.id}: $itemErr'); + } + } + } catch (e) { + errorCount++; + errors.add('Reconciliation failed: $e'); + } + + final report = CompletedRideReconciliationReport( + discoveredCompletedRides: discovered, + alreadyTrackedCount: alreadyTracked, + recoveredQueuedCount: recoveredQueued, + recoveredAuthRequiredCount: recoveredAuthRequired, + recoveredAlreadySyncedCount: recoveredAlreadySynced, + foreignOwnerSkippedCount: foreignOwnerSkipped, + errorCount: errorCount, + errors: errors, + ); + + if (report.totalRecovered > 0) { + notifyListeners(); + } + return report; + } + static Duration calculateRetryDelay(int attemptCount) { switch (attemptCount) { case 1: return const Duration(seconds: 5); @@ -107,52 +300,70 @@ class SyncManager extends ChangeNotifier with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed) unawaited(processQueue()); + if (state == AppLifecycleState.resumed) { + unawaited(_startupRecoveryAndProcess()); + } } Future processQueue() async { - if (_isProcessing) return; - _isProcessing = true; - notifyListeners(); - try { - final now = _now(); - for (final item in _outbox.getEligibleItems(now)) { - final provider = _providers[item.providerId]; - if (provider == null) { - await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); - continue; - } - if (!await provider.isAvailable()) { - await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); - continue; - } - if (await provider.requiresAuthentication()) { - await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); - continue; - } - final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); - await _outbox.update(uploadingItem); - SyncResult result; - try { - result = await provider.uploadRide(uploadingItem); - } catch (e) { - result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); - } - if (result.success) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); - } else if (result.requiresAuth) { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); - } else if (result.isRetryable) { - final attempts = uploadingItem.attemptCount + 1; - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); - } else { - await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); - } - } - } finally { - _isProcessing = false; - notifyListeners(); + if (_currentProcessingFuture != null) { + _needsAnotherPass = true; + return _currentProcessingFuture!; } + final future = _performProcessQueue(); + _currentProcessingFuture = future; + try { + await future; + } finally { + _currentProcessingFuture = null; + } + } + + Future _performProcessQueue() async { + do { + _needsAnotherPass = false; + _isProcessing = true; + notifyListeners(); + try { + final now = _now(); + for (final item in _outbox.getEligibleItems(now)) { + final provider = _providers[item.providerId]; + if (provider == null) { + await _outbox.update(item.copyWith(status: SyncStatus.failed, lastError: 'Provider "${item.providerId}" not registered', lastAttemptAt: now)); + continue; + } + if (!await provider.isAvailable()) { + await _outbox.update(item.copyWith(status: SyncStatus.waitingForNetwork, lastAttemptAt: now)); + continue; + } + if (await provider.requiresAuthentication()) { + await _outbox.update(item.copyWith(status: SyncStatus.authRequired, lastError: 'Authentication required for provider "${item.providerId}"', lastAttemptAt: now)); + continue; + } + final uploadingItem = item.copyWith(status: SyncStatus.uploading, lastAttemptAt: now); + await _outbox.update(uploadingItem); + SyncResult result; + try { + result = await provider.uploadRide(uploadingItem); + } catch (e) { + result = SyncResult.temporaryFailure('Unhandled provider exception: $e', timestamp: now); + } + if (result.success) { + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.synced, remoteId: result.remoteId, uploadedAt: result.timestamp, clearLastError: true, clearNextRetry: true)); + } else if (result.requiresAuth) { + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.authRequired, lastError: result.errorMessage)); + } else if (result.isRetryable) { + final attempts = uploadingItem.attemptCount + 1; + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.retryWait, attemptCount: attempts, nextRetryAt: now.add(calculateRetryDelay(attempts)), lastError: result.errorMessage)); + } else { + await _outbox.update(uploadingItem.copyWith(status: SyncStatus.failed, attemptCount: uploadingItem.attemptCount + 1, lastError: result.errorMessage, clearNextRetry: true)); + } + } + } finally { + _isProcessing = false; + notifyListeners(); + } + } while (_needsAnotherPass); } Future cancelSync(int localRideId, String providerId) async { diff --git a/lib/services/sync/sync_provider.dart b/lib/services/sync/sync_provider.dart index 86abe4f..18e3db8 100644 --- a/lib/services/sync/sync_provider.dart +++ b/lib/services/sync/sync_provider.dart @@ -16,6 +16,10 @@ abstract class SyncProvider { /// Performs the upload of the local ride referenced by [item]. /// Must return a deterministic [SyncResult]. Future uploadRide(SyncItem item); + + /// Checks if the ride with [remoteRideId] is already confirmed uploaded in the cloud. + /// Default implementation returns false. + Future isRideConfirmedInCloud(String remoteRideId) async => false; } /// Simulated behavior modes for testing the synchronization engine deterministically @@ -36,6 +40,7 @@ class FakeSyncProvider implements SyncProvider { int delayMs; final List uploadedItems = []; final Map uploadAttemptsPerRide = {}; + final Set confirmedRemoteRideIds = {}; FakeSyncProvider({ this.providerId = 'fake', @@ -54,6 +59,11 @@ class FakeSyncProvider implements SyncProvider { return behavior == FakeSyncBehavior.authRequired; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async { + return confirmedRemoteRideIds.contains(remoteRideId); + } + @override Future uploadRide(SyncItem item) async { if (delayMs > 0) { @@ -66,8 +76,10 @@ class FakeSyncProvider implements SyncProvider { switch (behavior) { case FakeSyncBehavior.success: uploadedItems.add(item); + final remoteId = 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}'; + confirmedRemoteRideIds.add(remoteId); return SyncResult.success( - remoteId: 'remote_${item.localRideId}_${DateTime.now().millisecondsSinceEpoch}', + remoteId: remoteId, ); case FakeSyncBehavior.temporaryFailure: @@ -95,5 +107,6 @@ class FakeSyncProvider implements SyncProvider { void reset() { uploadedItems.clear(); uploadAttemptsPerRide.clear(); + confirmedRemoteRideIds.clear(); } } diff --git a/test/completed_ride_sync_recovery_test.dart b/test/completed_ride_sync_recovery_test.dart new file mode 100644 index 0000000..0449fc0 --- /dev/null +++ b/test/completed_ride_sync_recovery_test.dart @@ -0,0 +1,326 @@ +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/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_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_manager.dart'; + +class _TestAuthService implements AuthService { + AuthUser? _currentUser; + AuthState _authState = const AuthState.unauthenticated(); + + void setAuthenticatedUser(String uid, String email) { + _currentUser = AuthUser( + uid: uid, + email: email, + displayName: 'Test Rider', + isAnonymous: false, + ); + _authState = AuthState.authenticated(_currentUser!); + } + + void setGuestUser() { + _currentUser = const AuthUser( + uid: 'guest_uid_123', + email: null, + displayName: 'Guest Rider', + isAnonymous: true, + ); + _authState = AuthState.anonymous(_currentUser!); + } + + void setLoggedOut() { + _currentUser = null; + _authState = const AuthState.unauthenticated(); + } + + @override + AuthUser? get currentUser => _currentUser; + + @override + AuthState get currentState => _authState; + + @override + Stream get authStateChanges => Stream.value(_authState); + + @override + bool get isConfigured => true; + + @override + bool get isEmailVerified => false; + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + + @override + Future deleteCurrentUser() async {} + + @override + void dispose() {} + + @override + Future ensureBackendIdentity() async {} + + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake_id_token'; + + @override + Future reauthenticate({String? password}) async {} + + @override + Future reloadCurrentUser() async {} + + @override + Future sendEmailVerification() async {} + + @override + Future sendPasswordResetEmail(String email) async {} + + @override + Future signInAnonymously() async => setGuestUser(); + + @override + Future signInWithEmailAndPassword(String email, String password) async => + setAuthenticatedUser('uid_123', email); + + @override + Future signInWithGoogle() async => + setAuthenticatedUser('uid_google', 'google@example.com'); + + @override + Future signOut() async => setLoggedOut(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('v1.1 Completed-Ride Sync Outbox Recovery Tests', () { + late SharedPreferences prefs; + late AppDatabase db; + late _TestAuthService authService; + late FakeSyncProvider fakeProvider; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + prefs = await SharedPreferences.getInstance(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + authService = _TestAuthService(); + fakeProvider = FakeSyncProvider(providerId: 'firebase'); + }); + + tearDown(() async { + await db.close(); + }); + + test('1. Crash Window Recovery: Completed ride missing from outbox is recovered and uploaded', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Simulate a completed ride committed to SQLite during a previous session + final rideId = await db.startRide( + startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), + motorcycleMake: 'Honda', + motorcycleModel: 'CBR600RR', + ); + await db.endRide(rideId, const RidesCompanion()); + + // Confirm outbox starts empty (app terminated before enqueue ran) + final outbox = SyncOutbox(prefs); + expect(outbox.count, equals(0)); + + // Startup initialization of SyncManager (testing step-by-step) + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Reconcile runs + final report = await manager.reconcileCompletedRides(); + + expect(report.discoveredCompletedRides, equals(1)); + expect(report.recoveredQueuedCount, equals(1)); + expect(report.alreadyTrackedCount, equals(0)); + expect(outbox.count, equals(1)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item, isNotNull); + expect(item?.status, equals(SyncStatus.queued)); + + // Verify ride ownership was claimed for authenticated user + final ownerUid = await db.getCloudOwnerUid(rideId); + expect(ownerUid, equals('rider_alice')); + + // Process queue to complete upload + await manager.processQueue(); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); + expect(fakeProvider.uploadedItems.length, equals(1)); + }); + + test('2. Idempotence: Repeated reconciliation does not create duplicate entries or uploads', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 11, 0, 0)); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // First run reconciles and queues the ride + final report1 = await manager.reconcileCompletedRides(); + expect(report1.recoveredQueuedCount, equals(1)); + expect(outbox.count, equals(1)); + + // Process upload + await manager.processQueue(); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); + expect(fakeProvider.uploadedItems.length, equals(1)); + + // Second run reports ride as already tracked, zero duplicates + final report2 = await manager.reconcileCompletedRides(); + expect(report2.discoveredCompletedRides, equals(1)); + expect(report2.alreadyTrackedCount, equals(1)); + expect(report2.recoveredQueuedCount, equals(0)); + expect(report2.totalRecovered, equals(0)); + expect(outbox.count, equals(1)); + + // Running processQueue again does not re-upload + await manager.processQueue(); + expect(fakeProvider.uploadedItems.length, equals(1)); + }); + + test('3. Guest State & Auth Transition: Recovers as authRequired then claims and syncs on sign-in', () async { + // User is initially a guest and provider requires auth + authService.setGuestUser(); + fakeProvider.behavior = FakeSyncBehavior.authRequired; + + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 12, 0, 0)); + await db.endRide(rideId, const RidesCompanion()); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + autoStart: false, + ); + + // Reconcile as guest -> authRequired + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAuthRequiredCount, equals(1)); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.authRequired)); + + // Ownership remains unclaimed + expect(await db.getCloudOwnerUid(rideId), isNull); + + // Guest now signs in with Google / Email + authService.setAuthenticatedUser('rider_bob', 'bob@example.com'); + fakeProvider.behavior = FakeSyncBehavior.success; + + // Invoking retryAuthRequired claims the ride and triggers queue upload + await manager.retryAuthRequired(); + + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + expect(outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced)); + expect(fakeProvider.uploadedItems.length, equals(1)); + }); + + test('4. Cloud Confirmed Skip: Confirmed ride marks synced without re-uploading bundle', () async { + authService.setAuthenticatedUser('rider_charlie', 'charlie@example.com'); + + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 13, 0, 0)); + await db.endRide(rideId, const RidesCompanion()); + final remoteRideId = await db.ensureCloudRideId(rideId); + + // Simulate that cloud backend already has this ride bundle uploaded + fakeProvider.confirmedRemoteRideIds.add(remoteRideId); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.recoveredAlreadySyncedCount, equals(1)); + expect(report.recoveredQueuedCount, equals(0)); + + final item = outbox.getItem(rideId, 'firebase'); + expect(item?.status, equals(SyncStatus.synced)); + expect(item?.remoteId, equals(remoteRideId)); + + // Zero upload attempts because it was already confirmed + expect(fakeProvider.uploadedItems.isEmpty, isTrue); + }); + + test('5. Foreign Account Isolation: Does not claim or upload rides belonging to another user', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Create ride explicitly owned by rider_bob + final rideId = await db.into(db.rides).insert(RidesCompanion.insert( + cloudRideId: const drift.Value('ride_bob_123'), + cloudOwnerUid: const drift.Value('rider_bob'), + startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), + endTime: drift.Value(DateTime.utc(2026, 10, 12, 15, 0, 0)), + )); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.foreignOwnerSkippedCount, equals(1)); + expect(report.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); + + // Ownership in db remains unchanged + expect(await db.getCloudOwnerUid(rideId), equals('rider_bob')); + }); + + test('6. In-Progress Isolation: Active rides without endTime are never recovered as completed', () async { + authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); + + // Start ride but do not end it (endTime is null) + final rideId = await db.startRide(startTime: DateTime.utc(2026, 10, 12, 16, 0, 0)); + + final outbox = SyncOutbox(prefs); + final manager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: authService, + ); + + final report = await manager.reconcileCompletedRides(); + expect(report.discoveredCompletedRides, equals(0)); + expect(report.totalRecovered, equals(0)); + expect(outbox.getItem(rideId, 'firebase'), isNull); + }); + }); +} diff --git a/test/disposable_backup_recovery_verification_test.dart b/test/disposable_backup_recovery_verification_test.dart new file mode 100644 index 0000000..620419e --- /dev/null +++ b/test/disposable_backup_recovery_verification_test.dart @@ -0,0 +1,173 @@ +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.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/sync_manager.dart'; +import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; +import 'package:motorcycle_dash/services/sync/sync_provider.dart'; + +class _FakeAuthService implements AuthService { + final AuthUser _user; + + _FakeAuthService([String uid = 'pilot_pixel_8a']) + : _user = AuthUser( + uid: uid, + email: '$uid@example.com', + displayName: 'Pixel Pilot', + isAnonymous: false, + ); + + @override + AuthUser? get currentUser => _user; + @override + AuthState get currentState => AuthState.authenticated(_user); + @override + Stream get authStateChanges => Stream.value(currentState); + @override + bool get isConfigured => true; + @override + bool get isEmailVerified => true; + + @override + Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} + @override + Future deleteCurrentUser() async {} + @override + void dispose() {} + @override + Future ensureBackendIdentity() async {} + @override + Future getIdToken([bool forceRefresh = false]) async => 'fake_token'; + @override + Future reauthenticate({String? password}) async {} + @override + Future reloadCurrentUser() async {} + @override + Future sendEmailVerification() async {} + @override + Future sendPasswordResetEmail(String email) async {} + @override + Future signInAnonymously() async {} + @override + Future signInWithEmailAndPassword(String email, String password) async {} + @override + Future signInWithGoogle() async {} + @override + Future signOut() async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Phase 6 — Disposable Backup Verification & Outbox Recovery Gate', () async { + final originalFile = File( + r'C:\Users\Bill\Desktop\flutter-app\output\master-consolidation-20261012\backup-071303\motorsignal.sqlite', + ); + + expect(originalFile.existsSync(), isTrue, reason: 'Original backup file must exist'); + + // 1. Verify original SHA-256 before copy + final originalBytes = await originalFile.readAsBytes(); + final originalHash = sha256.convert(originalBytes).toString(); + const expectedHash = '7125915195cdcf1de2d351426d8a991789904e7c3befd12af6f0d0f0b2416122'; + expect(originalHash, equals(expectedHash), reason: 'Original backup must match authoritative SHA-256'); + + // 2. Create disposable copy in scratch directory + final scratchDir = Directory( + r'C:\Users\Bill\.gemini\antigravity\brain\eb590221-346f-4525-a5fa-eda41331c6c6\scratch', + ); + if (!scratchDir.existsSync()) { + scratchDir.createSync(recursive: true); + } + final disposableFile = File('${scratchDir.path}\\disposable_motorsignal.sqlite'); + if (disposableFile.existsSync()) { + disposableFile.deleteSync(); + } + await originalFile.copy(disposableFile.path); + expect(disposableFile.existsSync(), isTrue); + + // 3. Open disposable copy via Drift / SQLite + final db = AppDatabase.forTesting(NativeDatabase(disposableFile)); + + try { + // Integrity check + final integrityRows = await db.customSelect('PRAGMA integrity_check').get(); + final integrityResult = integrityRows.first.data['integrity_check']; + expect(integrityResult, equals('ok'), reason: 'SQLite integrity must be OK'); + + // Count rides and samples + final rideCountRows = await db.customSelect('SELECT COUNT(*) as count FROM rides').get(); + final totalRides = rideCountRows.first.data['count'] as int; + expect(totalRides, equals(22), reason: 'Must contain exactly 22 rides'); + + final completedRidesRows = await db.customSelect('SELECT COUNT(*) as count FROM rides WHERE end_time IS NOT NULL').get(); + final completedRides = completedRidesRows.first.data['count'] as int; + + final samplesRows = await db.customSelect('SELECT COUNT(*) as count FROM telemetry_samples').get(); + final totalSamples = samplesRows.first.data['count'] as int; + expect(totalSamples, equals(67798), reason: 'Must contain exactly 67,798 telemetry samples'); + + // 4. Test Foreign Account Isolation against real Pixel backup + // In the real backup, rides are owned by Pixel rider '4btn4W3iOLWgfloMBYYK5hWa1hY2' + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final outbox = SyncOutbox(prefs); + + final fakeProvider = FakeSyncProvider(providerId: 'firebase'); + final foreignAuth = _FakeAuthService('foreign_pilot_123'); + + final foreignSyncManager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: foreignAuth, + autoStart: false, + ); + + final foreignReport = await foreignSyncManager.reconcileCompletedRides(); + expect(foreignReport.errorCount, equals(0)); + expect(foreignReport.discoveredCompletedRides, equals(completedRides)); + expect(foreignReport.foreignOwnerSkippedCount, equals(completedRides), reason: 'Foreign account must NEVER claim or touch real Pixel rides'); + expect(foreignReport.totalRecovered, equals(0)); + expect(outbox.count, equals(0)); + + // 5. Test Owner Recovery against disposable backup + final ownerAuth = _FakeAuthService('4btn4W3iOLWgfloMBYYK5hWa1hY2'); + final ownerSyncManager = SyncManager( + outbox, + initialProviders: [fakeProvider], + defaultProviderId: 'firebase', + db: db, + authService: ownerAuth, + autoStart: false, + ); + + final ownerReport = await ownerSyncManager.reconcileCompletedRides(); + expect(ownerReport.errorCount, equals(0)); + expect(ownerReport.discoveredCompletedRides, equals(completedRides)); + expect(ownerReport.recoveredQueuedCount, equals(completedRides)); + expect(ownerReport.totalRecovered, equals(completedRides)); + expect(outbox.count, equals(completedRides)); + + // 6. Test repeated reconciliation idempotence as owner + final report2 = await ownerSyncManager.reconcileCompletedRides(); + expect(report2.totalRecovered, equals(0)); + expect(report2.alreadyTrackedCount, equals(completedRides)); + expect(outbox.count, equals(completedRides)); + + } finally { + await db.close(); + } + + // 6. Verify original SHA-256 after operations to prove zero mutation + final postBytes = await originalFile.readAsBytes(); + final postHash = sha256.convert(postBytes).toString(); + expect(postHash, equals(expectedHash), reason: 'Original backup MUST remain 100% UNTOUCHED'); + }); +} diff --git a/test/isolated_sync_integration_fixture_test.dart b/test/isolated_sync_integration_fixture_test.dart index 760f600..0d7b38e 100644 --- a/test/isolated_sync_integration_fixture_test.dart +++ b/test/isolated_sync_integration_fixture_test.dart @@ -104,6 +104,9 @@ class _IsolatedContractSyncProvider implements SyncProvider { return user == null || user.isAnonymous; } + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { if (!networkAvailable) { diff --git a/test/offline_first_sync_contract_test.dart b/test/offline_first_sync_contract_test.dart index 3141d3d..db7fe3a 100644 --- a/test/offline_first_sync_contract_test.dart +++ b/test/offline_first_sync_contract_test.dart @@ -20,6 +20,9 @@ class _CountingProvider implements SyncProvider { @override Future requiresAuthentication() async => result == _Result.authRequired; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { uploadCount++; diff --git a/test/r2_firestore_retry_hardening_test.dart b/test/r2_firestore_retry_hardening_test.dart index 6a99cd1..245bd92 100644 --- a/test/r2_firestore_retry_hardening_test.dart +++ b/test/r2_firestore_retry_hardening_test.dart @@ -31,6 +31,9 @@ class _R2ThenFirestoreProvider implements SyncProvider { @override Future requiresAuthentication() async => false; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { attempts++; diff --git a/test/sync_persistence_validation_test.dart b/test/sync_persistence_validation_test.dart index 178f22f..4d5978c 100644 --- a/test/sync_persistence_validation_test.dart +++ b/test/sync_persistence_validation_test.dart @@ -21,6 +21,9 @@ class _ValidationProvider implements SyncProvider { @override Future requiresAuthentication() async => !authenticated; + @override + Future isRideConfirmedInCloud(String remoteRideId) async => false; + @override Future uploadRide(SyncItem item) async { uploadCount++;