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); }, ); test( '7. Inconsistent Cloud Confirmation: Preserves local data and reports inconsistent skipped without overwriting', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 17, 0, 0), ); await db.endRide(rideId, const RidesCompanion()); final remoteRideId = await db.ensureCloudRideId(rideId); fakeProvider.confirmationResults[remoteRideId] = CloudConfirmationResult.inconsistent( remoteRideId: remoteRideId, message: 'SHA-256 mismatch detected in storage', ); final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, ); final report = await manager.reconcileCompletedRides(); expect(report.inconsistentSkippedCount, equals(1)); expect(report.recoveredAlreadySyncedCount, equals(0)); final item = outbox.getItem(rideId, 'firebase'); expect(item, isNotNull); expect(item?.status, equals(SyncStatus.failed)); expect(item?.lastError, contains('inconsistent')); // Local ride remains safe in SQLite final localRide = await (db.select( db.rides, )..where((r) => r.id.equals(rideId))).getSingle(); expect(localRide.cloudRideId, equals(remoteRideId)); }, ); test( '8. Temporarily Unverifiable State: Leaves ride in retryWait with backoff without marking permanently missing', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 18, 0, 0), ); await db.endRide(rideId, const RidesCompanion()); final remoteRideId = await db.ensureCloudRideId(rideId); fakeProvider.confirmationResults[remoteRideId] = CloudConfirmationResult.temporarilyUnverifiable( remoteRideId: remoteRideId, message: 'HTTP 503 backend gateway timeout', ); final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, ); final report = await manager.reconcileCompletedRides(); expect(report.unverifiablePendingCount, equals(1)); expect(report.recoveredAlreadySyncedCount, equals(0)); final item = outbox.getItem(rideId, 'firebase'); expect(item, isNotNull); expect(item?.status, equals(SyncStatus.retryWait)); expect(item?.nextRetryAt, isNotNull); expect(item?.lastError, contains('temporarily unverifiable')); }, ); test( '9. Pre-Upload Ownership Protection: processQueue cancels foreign-owned outbox item before calling provider', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); // Create ride owned by rider_bob final rideId = await db .into(db.rides) .insert( RidesCompanion.insert( cloudRideId: const drift.Value('ride_bob_456'), cloudOwnerUid: const drift.Value('rider_bob'), startTime: DateTime.utc(2026, 10, 12, 19, 0, 0), endTime: drift.Value(DateTime.utc(2026, 10, 12, 20, 0, 0)), ), ); // Force an outbox item into the queue (e.g., from stale shared storage) final outbox = SyncOutbox(prefs); await outbox.update( SyncItem( localRideId: rideId, providerId: 'firebase', status: SyncStatus.queued, createdAt: DateTime.now(), ), ); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); await manager.processQueue(); final item = outbox.getItem(rideId, 'firebase'); expect(item?.status, equals(SyncStatus.cancelled)); expect(item?.lastError, contains('belongs to another account')); expect( fakeProvider.uploadedItems.isEmpty, isTrue, reason: 'Must not call provider upload for foreign ride', ); }, ); test( '10. Mid-flight Auth Change Protection: Reverts upload to authRequired if user logs out mid-flight', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 21, 0, 0), ); await db.endRide(rideId, const RidesCompanion()); await db.claimRideForCloudOwner(rideId, 'rider_alice'); final outbox = SyncOutbox(prefs); await outbox.update( SyncItem( localRideId: rideId, providerId: 'firebase', status: SyncStatus.queued, createdAt: DateTime.now(), ), ); // Provider simulates delay and during delay user logs out fakeProvider.delayMs = 50; final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); // Start processQueue final uploadFuture = manager.processQueue(); // Mid-flight logout await Future.delayed(const Duration(milliseconds: 10)); authService.setLoggedOut(); await uploadFuture; final item = outbox.getItem(rideId, 'firebase'); expect(item?.status, equals(SyncStatus.authRequired)); expect( item?.lastError, contains('User changed or logged out during upload'), ); }, ); test( '11. Concurrency Protection: Simultaneous reconcileCompletedRides join the same future', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 22, 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, ); final results = await Future.wait([ manager.reconcileCompletedRides(), manager.reconcileCompletedRides(), ]); expect(results[0].discoveredCompletedRides, equals(1)); expect(results[1].discoveredCompletedRides, equals(1)); expect(outbox.count, equals(1)); }, ); test( '12. Stale Persisted Outbox on retryAllPending: Foreign-owned items are cancelled on retry', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db .into(db.rides) .insert( RidesCompanion.insert( cloudRideId: const drift.Value('ride_bob_789'), cloudOwnerUid: const drift.Value('rider_bob'), startTime: DateTime.utc(2026, 10, 12, 23, 0, 0), endTime: drift.Value(DateTime.utc(2026, 10, 13, 0, 0, 0)), ), ); final outbox = SyncOutbox(prefs); await outbox.update( SyncItem( localRideId: rideId, providerId: 'firebase', status: SyncStatus.failed, lastError: 'Previous failure', createdAt: DateTime.now(), ), ); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); await manager.retryAllPending(); final item = outbox.getItem(rideId, 'firebase'); expect(item?.status, equals(SyncStatus.cancelled)); expect(item?.lastError, contains('belongs to another account')); expect(fakeProvider.uploadedItems.isEmpty, isTrue); }, ); test( '13. In-Flight Cancellation Protection: cancelSync preserves cancelled status even after upload completes', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 10, 0, 0), ); await db.endRide(rideId, const RidesCompanion()); await db.claimRideForCloudOwner(rideId, 'rider_alice'); final outbox = SyncOutbox(prefs); await outbox.update( SyncItem( localRideId: rideId, providerId: 'firebase', status: SyncStatus.queued, createdAt: DateTime.now(), ), ); // Simulate provider with 50ms upload latency fakeProvider.delayMs = 50; final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); // Start processing queue final processFuture = manager.processQueue(); // While upload is in flight, user cancels sync await Future.delayed(const Duration(milliseconds: 10)); await manager.cancelSync(rideId, 'firebase'); await processFuture; final item = outbox.getItem(rideId, 'firebase'); expect( item?.status, equals(SyncStatus.cancelled), reason: 'Item must remain cancelled and not get overwritten with synced', ); }, ); test( '14. Default Active Provider: Selects defaultProviderId or first initial provider, defaulting to fake for bare test manager', () async { final outbox = SyncOutbox(prefs); final managerExplicit = SyncManager( outbox, defaultProviderId: 'firebase', autoStart: false, ); expect(managerExplicit.activeProviderId, equals('firebase')); final customProvider = FakeSyncProvider(providerId: 'custom_gateway'); final managerWithProviders = SyncManager( outbox, initialProviders: [customProvider], autoStart: false, ); expect(managerWithProviders.activeProviderId, equals('custom_gateway')); final managerNoProviders = SyncManager(outbox, autoStart: false); expect(managerNoProviders.activeProviderId, equals('fake')); }, ); test( '15. Immediate Enqueue Ownership Protection: enqueueRide cancels foreign-owned rides immediately', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db .into(db.rides) .insert( RidesCompanion.insert( cloudRideId: const drift.Value('ride_bob_999'), cloudOwnerUid: const drift.Value('rider_bob'), startTime: DateTime.utc(2026, 10, 12, 11, 0, 0), endTime: drift.Value(DateTime.utc(2026, 10, 12, 12, 0, 0)), ), ); final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); final item = await manager.enqueueRide(rideId, autoProcess: false); expect(item.status, equals(SyncStatus.cancelled)); expect(item.lastError, contains('belongs to another account')); expect( outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.cancelled), ); }, ); test( '16. Failed Cloud Verification followed by Successful Retry: Recovers to synced without re-upload', () async { authService.setAuthenticatedUser('rider_alice', 'alice@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); // Step 1: Initial reconciliation fails with temporary verification error (e.g. HTTP 503) fakeProvider.confirmationResults[remoteRideId] = CloudConfirmationResult.temporarilyUnverifiable( remoteRideId: remoteRideId, message: 'HTTP 503 gateway timeout', ); final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); final report1 = await manager.reconcileCompletedRides(); expect(report1.unverifiablePendingCount, equals(1)); expect( outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.retryWait), ); // Step 2: Backend recovers and verifies the bundle in R2 fakeProvider.confirmationResults[remoteRideId] = CloudConfirmationResult.confirmed(remoteRideId: remoteRideId); fakeProvider.confirmedRemoteRideIds.add(remoteRideId); // Process queue on retry await manager.retryAllPending(); expect( outbox.getItem(rideId, 'firebase')?.status, equals(SyncStatus.synced), ); expect( fakeProvider.uploadedItems.isEmpty, isTrue, reason: 'Must confirm and mark synced without re-uploading the bundle', ); }, ); test( '17. Legacy Unverified Cloud Confirmation: Recovers as synced without re-upload and tracks count', () async { authService.setAuthenticatedUser('rider_alice', 'alice@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 14, 0, 0), ); await db.endRide(rideId, const RidesCompanion()); final remoteRideId = await db.ensureCloudRideId(rideId); fakeProvider.confirmationResults[remoteRideId] = CloudConfirmationResult.legacyUnverified( remoteRideId: remoteRideId, ownerUid: 'rider_alice', message: 'Historical bundle without checksum', ); final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); final report = await manager.reconcileCompletedRides(); expect(report.recoveredLegacyUnverifiedCount, equals(1)); expect(report.recoveredAlreadySyncedCount, equals(1)); expect(report.totalRecovered, equals(1)); final item = outbox.getItem(rideId, 'firebase'); expect(item?.status, equals(SyncStatus.synced)); expect(item?.lastError, contains('without modern checksum')); expect( fakeProvider.uploadedItems.isEmpty, isTrue, reason: 'Must never re-upload existing legacy unverified bundle', ); }, ); test( '18. Reconciliation Never Claims Unauthorized Rides: cloudOwnerUid remains null when verification returns unauthorized', () async { authService.setAuthenticatedUser('rider_eve', 'eve@example.com'); final rideId = await db.startRide( startTime: DateTime.utc(2026, 10, 12, 15, 0, 0), ); await db.endRide(rideId, const RidesCompanion()); final remoteRideId = await db.ensureCloudRideId(rideId); // Verification returns unauthorized (foreign-owned or permission denied) fakeProvider.confirmationResults[remoteRideId] = CloudConfirmationResult.unauthorized( remoteRideId: remoteRideId, message: 'Foreign owner in cloud metadata', ); final outbox = SyncOutbox(prefs); final manager = SyncManager( outbox, initialProviders: [fakeProvider], defaultProviderId: 'firebase', db: db, authService: authService, autoStart: false, ); final report = await manager.reconcileCompletedRides(); // Must NOT claim ownership for rider_eve final localOwner = await db.getCloudOwnerUid(rideId); expect( localOwner, isNull, reason: 'Must NEVER claim an unauthorized ride', ); expect(report.foreignOwnerSkippedCount, equals(1)); expect(report.errors.isNotEmpty, isTrue); }, ); }); }