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: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_user.dart'; import 'package:motorcycle_dash/services/sync/r2_upload_service.dart'; import 'package:motorcycle_dash/services/sync/sync_bundle_worker.dart'; import 'package:motorcycle_dash/services/sync/sync_manager.dart'; import 'package:motorcycle_dash/services/sync/sync_models.dart'; import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; import 'package:motorcycle_dash/services/sync/sync_provider.dart'; /// Architectural Boundaries & Verification Level for Offline Sync Fixture: /// /// 1. REAL COMPONENTS EXERCISED: /// - Drift SQLite Schema 7 [AppDatabase] with in-memory SQLite engine: /// Executes real relational schema operations, queries, and immutable identity /// contracts (`ensureCloudRideId()`, tables `rides`, `telemetry_samples`, /// `field_test_events`, `field_test_summaries`). /// - [SyncBundleWorker]: Generates authentic ZIP archives with DEFLATE/gzip compression, /// serializing 20 Hz telemetry CSV, forensic events CSV, and session JSON. /// - [SyncManager]: Full queue state machine, retry backoff calculation, app lifecycle /// transitions, and multi-provider orchestration. /// - [SyncOutbox]: Durable queue manifest serialization and deserialization via /// [SharedPreferences] storage. /// - [R2UploadService]: Real HTTP PUT request formatting, custom headers, SHA-256 /// checksum generation, and response parsing. /// /// 2. REPLACED / MOCKED COMPONENTS: /// - [_IsolatedContractSyncProvider]: Replaces production `FirebaseSyncProvider` to isolate /// test execution from live Google Play Services / Firebase C++ native bindings, while /// strictly adhering to the identical `SyncProvider` contract. /// - [MockClient]: Replaces the live Cloudflare R2 Worker HTTP endpoint, intercepting /// requests and verifying byte-level payload checksums and headers. /// - Simulated `networkAvailable` flag: Software-level mock on the test provider. /// Note: Distinguished from true OS-level network interface routing isolation /// (e.g., netfilter/iptables rules or taking down eth0/wlan0 interfaces). /// /// 3. UPLOAD SEQUENCING & CONSISTENCY CONTRACT: /// - Sequential Execution: In the authoritative MotorCloud pipeline, the R2 binary bundle /// upload executes first. Cloud Firestore write only executes after R2 returns HTTP 200/success. /// This prevents orphan documents in Firestore if the R2 upload fails. /// - NOT A DISTRIBUTED 2-PHASE COMMIT (2PC): Sequential execution ensures Firestore is /// only updated upon R2 success, but is NOT a distributed two-phase commit across R2 /// and Firestore. If Firestore write fails after R2 succeeds, R2 holds the bundle while /// Firestore remains unpopulated until the next retry cycle reconciles the state. class _FakeAuthService implements AuthService { AuthUser? _user; @override AuthUser? get currentUser => _user; void setUser(AuthUser? user) { _user = user; } @override Future getIdToken([bool forceRefresh = false]) async { return _user != null ? 'fake_jwt_token_${_user!.uid}' : null; } @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } /// Test implementation of [SyncProvider] honoring the MotorCloud upload contract. /// /// Replaces production `FirebaseSyncProvider` to run in headless CI/test environments /// without initializing Firebase native plugins. Replaces live R2 worker network endpoints /// with [MockClient]. class _IsolatedContractSyncProvider implements SyncProvider { final AppDatabase _db; final AuthService _authService; final R2UploadService _r2UploadService; final SyncBundleWorker _bundleWorker; final List> recordedUploads = []; bool networkAvailable = true; _IsolatedContractSyncProvider({ required this._db, required this._authService, required this._r2UploadService, required this._bundleWorker, }); @override String get providerId => 'firebase'; @override Future isAvailable() async => true; @override Future requiresAuthentication() async { final user = _authService.currentUser; return user == null || user.isAnonymous; } @override Future isRideConfirmedInCloud(String remoteRideId) async => false; @override Future verifyCloudConfirmation(String remoteRideId) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId); @override Future uploadRide(SyncItem item) async { if (!networkAvailable) { return SyncResult.temporaryFailure('Network isolated (all interfaces down). Cannot reach R2 gateway.'); } final user = _authService.currentUser; if (user == null || user.isAnonymous) { return SyncResult.authRequired(message: 'Sign in to MotorCloud is required for cloud backup.'); } final remoteRideId = await _db.ensureCloudRideId(item.localRideId); final idToken = await _authService.getIdToken(); if (idToken == null || idToken.isEmpty) { return SyncResult.authRequired(message: 'Valid Firebase authentication token required for cloud sync.'); } final zipBytes = await _bundleWorker.generateCompressedBundle(item.localRideId); if (zipBytes == null || zipBytes.isEmpty) { return SyncResult.permanentFailure('Failed generating compressed telemetry bundle for ride #${item.localRideId}.'); } final sha256Hash = sha256.convert(zipBytes).toString(); final r2Result = await _r2UploadService.uploadTelemetryBundle( idToken: idToken, remoteRideId: remoteRideId, zipBytes: zipBytes, sha256Hash: sha256Hash, appVersion: '1.0.2', ); if (!r2Result.success) { if (r2Result.requiresAuth) return SyncResult.authRequired(message: r2Result.errorMessage ?? 'R2 auth required'); if (r2Result.isRetryable) return SyncResult.temporaryFailure(r2Result.errorMessage ?? 'Temporary R2 upload error'); return SyncResult.permanentFailure(r2Result.errorMessage ?? 'Permanent R2 upload rejection'); } // UPLOAD SEQUENCING CONTRACT: // Sequential execution: In the authoritative MotorCloud pipeline, R2 bundle upload // precedes Firestore document creation. This ordering ensures Firestore is only updated // upon verified R2 success, avoiding orphan cloud records. // // Distributed Transaction Boundary: Sequential execution enforces error-gated ordering, // but is NOT a distributed two-phase commit (2PC) across Cloudflare R2 and Firestore. recordedUploads.add({ 'localRideId': item.localRideId, 'remoteId': remoteRideId, 'objectKey': r2Result.objectKey, 'sha256': sha256Hash, 'bytesWritten': r2Result.bytesWritten, 'timestamp': DateTime.now(), }); return SyncResult.success(remoteId: remoteRideId); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); late AppDatabase db; late _FakeAuthService authService; late SharedPreferences prefs; setUp(() async { db = AppDatabase.forTesting(NativeDatabase.memory()); authService = _FakeAuthService(); SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); }); tearDown(() async { await db.close(); }); test('Isolated Offline Sync Contract: Network Isolation, R2 Upload, Reconciliation & Idempotency', () async { // 1. Stage an offline recorded ride with 2,788 telemetry samples and 8 events final startTime = DateTime.utc(2026, 10, 3, 14, 0, 0); final endTime = startTime.add(const Duration(minutes: 40)); final localRideId = await db.into(db.rides).insert( RidesCompanion.insert( startTime: startTime, endTime: drift.Value(endTime), distanceMiles: const drift.Value(42.5), topSpeedMph: const drift.Value(85.0), movingTime: const drift.Value(2400), stoppedTime: const drift.Value(0), averageSpeed: const drift.Value(63.75), maxLeanLeft: const drift.Value(44.2), maxLeanRight: const drift.Value(46.8), maxAccelG: const drift.Value(0.78), maxBrakingG: const drift.Value(-0.92), elevationGain: const drift.Value(1450), elevationLoss: const drift.Value(1450), motorcycleMake: const drift.Value('Ducati'), motorcycleModel: const drift.Value('Streetfighter V4'), motorcycleYear: const drift.Value(2024), ), ); // Insert 2,788 telemetry samples at 20 Hz final samples = List.generate(2788, (i) { final t = startTime.add(Duration(milliseconds: i * 50)); return TelemetrySamplesCompanion.insert( rideId: localRideId, timestamp: t, latitude: drift.Value(37.3861 + (i * 0.0001)), longitude: drift.Value(-122.2641 - (i * 0.0001)), speed: drift.Value(45.0 + (i % 20)), leanAngle: drift.Value(15.0 + (i % 30)), accelX: const drift.Value(0.05), accelY: const drift.Value(0.12), accelZ: const drift.Value(0.98), ); }); await db.batch((b) { b.insertAll(db.telemetrySamples, samples); }); // Insert 8 forensic events final events = List.generate(8, (i) { return FieldTestEventsCompanion.insert( rideId: drift.Value(localRideId), timestamp: startTime.add(Duration(minutes: (i + 1) * 4)), eventType: i.isEven ? 'Hard Braking' : 'Extreme Lean Corner', severity: 'MEDIUM', message: 'Forensic event #$i detected at 20Hz', ); }); await db.batch((b) { b.insertAll(db.fieldTestEvents, events); }); // Insert 1 field test summary await db.into(db.fieldTestSummaries).insert( FieldTestSummariesCompanion.insert( rideId: localRideId, startTime: startTime, endTime: drift.Value(endTime), durationSeconds: const drift.Value(2400), rideQualityScore: const drift.Value(96), rideQualityRating: const drift.Value('EXCELLENT'), ), ); // 2. Claim ride for verified test rider const testUid = 'pilot_qa_verified_123'; authService.setUser(const AuthUser( uid: testUid, email: 'pilot_qa@motorsignal.test', isAnonymous: false, )); await (db.update(db.rides)..where((r) => r.id.equals(localRideId))).write( const RidesCompanion(cloudOwnerUid: drift.Value(testUid)), ); final claimedRide = await (db.select(db.rides)..where((r) => r.id.equals(localRideId))).getSingle(); expect(claimedRide.cloudOwnerUid, testUid); // 3. Set up R2 Worker mock implementing the authoritative upload contract final uploadedR2Bundles = {}; final r2Calls = >[]; final r2MockClient = MockClient((request) async { r2Calls.add({ 'method': request.method, 'path': request.url.path, 'headers': request.headers, 'bodyLength': request.bodyBytes.length, }); if (request.method == 'PUT' && request.url.path == '/r2/upload') { final remoteRideId = request.headers['X-Remote-Ride-Id']; final clientSha256 = request.headers['X-Bundle-Sha256']; final authHeader = request.headers['Authorization']; if (authHeader == null || !authHeader.startsWith('Bearer ')) { return http.Response(jsonEncode({'error': 'Unauthorized'}), 401); } // Verify SHA-256 against body final computedSha = sha256.convert(request.bodyBytes).toString(); if (clientSha256 != null && clientSha256 != computedSha) { return http.Response(jsonEncode({'error': 'SHA mismatch'}), 422); } final objectKey = 'users/$testUid/rides/$remoteRideId/telemetry.zip'; uploadedR2Bundles[objectKey] = Uint8List.fromList(request.bodyBytes); return http.Response( jsonEncode({ 'success': true, 'objectKey': objectKey, 'size': request.bodyBytes.length, 'etag': 'test-etag-verified', 'sha256': computedSha, 'uploadedAt': DateTime.now().toUtc().toIso8601String(), }), 200, headers: {'content-type': 'application/json'}, ); } return http.Response('Not found', 404); }); final r2Service = R2UploadService( client: r2MockClient, workerBaseUrl: 'https://r2-fixture.local', ); final bundleWorker = SyncBundleWorker(db); final contractProvider = _IsolatedContractSyncProvider( db: db, authService: authService, r2UploadService: r2Service, bundleWorker: bundleWorker, ); final outbox = SyncOutbox(prefs); final syncManager = SyncManager(outbox, initialProviders: [contractProvider]); // 4. Enqueue ride for sync (without autoProcess so we control step-by-step) await syncManager.enqueueRide(localRideId, autoProcess: false); expect(syncManager.pendingItems.isNotEmpty, isTrue); expect(syncManager.currentState.pendingCount, 1); // 5. PHASE A: Network Isolated State (Simulating off-grid ride / offline interface) contractProvider.networkAvailable = false; await syncManager.processQueue(); // Verification: Outbox queue did NOT drain, ride remains pending with backoff expect(syncManager.currentState.pendingCount, 1); expect(uploadedR2Bundles, isEmpty); expect(contractProvider.recordedUploads, isEmpty); // 6. PHASE B: Network Connectivity Restored contractProvider.networkAvailable = true; // Advance simulated time past the 5-second initial retry backoff syncManager.setNowProvider(() => DateTime.now().add(const Duration(seconds: 10))); await syncManager.processQueue(); // Verification: Queue drains completely to 0 pending and 1 synced expect(syncManager.currentState.pendingCount, 0); expect(syncManager.currentState.syncedCount, 1); expect(syncManager.pendingItems, isEmpty); // Verification: R2 contains the authoritative telemetry bundle expect(r2Calls, hasLength(1)); final expectedRemoteId = await db.ensureCloudRideId(localRideId); final expectedObjectKey = 'users/$testUid/rides/$expectedRemoteId/telemetry.zip'; expect(uploadedR2Bundles.containsKey(expectedObjectKey), isTrue); // Verification: Uploaded bundle size and checksum reconcile final uploadedBytes = uploadedR2Bundles[expectedObjectKey]!; expect(uploadedBytes.length, greaterThan(100)); // Real zip archive with dense telemetry final uploadedSha = sha256.convert(uploadedBytes).toString(); expect(r2Calls.first['headers']['X-Bundle-Sha256'], uploadedSha); // Unzip and verify telemetry.csv and forensic events inside the uploaded bundle final archive = ZipDecoder().decodeBytes(uploadedBytes); final fileNames = archive.files.map((f) => f.name).toSet(); expect(fileNames, contains('README.txt')); expect(fileNames, contains('ride_summary.json')); expect(fileNames, contains('field_test_session.json')); expect(fileNames, contains('field_test_events.csv')); expect(fileNames, contains('telemetry.csv')); // Verification: Provider recorded single successful sync expect(contractProvider.recordedUploads, hasLength(1)); expect(contractProvider.recordedUploads.first['remoteId'], expectedRemoteId); // 7. PHASE C: Genuine Service Reconstruction & Restart Idempotency (Zero duplicate uploads) // First, dispose the original in-memory SyncManager to simulate application termination / cold shutdown. syncManager.dispose(); // Reconstruct brand-new SyncOutbox and SyncManager instances from the persisted SharedPreferences // state and Drift SQLite AppDatabase. // This genuinely tests that persistent storage (not in-memory cache) faithfully retains the // authoritative queue state across an application restart. final reconstructedOutbox = SyncOutbox(prefs); expect(reconstructedOutbox.isLoaded, isTrue); expect(reconstructedOutbox.count, 1, reason: 'Reconstructed outbox must restore exactly 1 item from persistent SharedPreferences storage.'); final persistedItem = reconstructedOutbox.getItem(localRideId, 'firebase'); expect(persistedItem, isNotNull, reason: 'Persisted item for ride #$localRideId must exist in reconstructed outbox.'); expect(persistedItem!.status, SyncStatus.synced, reason: 'Persisted item status must be preserved as SyncStatus.synced.'); expect(persistedItem.remoteId, expectedRemoteId, reason: 'Persisted remoteId must match authoritative cloudRideId.'); expect(persistedItem.uploadedAt, isNotNull, reason: 'Persisted uploadedAt timestamp must be preserved.'); expect(reconstructedOutbox.getPendingItems(), isEmpty, reason: 'Reconstructed outbox must have 0 pending items.'); final reconstructedContractProvider = _IsolatedContractSyncProvider( db: db, authService: authService, r2UploadService: r2Service, bundleWorker: bundleWorker, ); final reconstructedSyncManager = SyncManager( reconstructedOutbox, initialProviders: [reconstructedContractProvider], db: db, authService: authService, ); // Verify queue state on the reconstructed service immediately after initialization expect(reconstructedSyncManager.currentState.pendingCount, 0); expect(reconstructedSyncManager.currentState.syncedCount, 1); expect(reconstructedSyncManager.currentState.failedCount, 0); expect(reconstructedSyncManager.pendingItems, isEmpty); // Trigger processQueue() on the reconstructed service. // The service must recognize that the item is already synced and bypass upload. await reconstructedSyncManager.processQueue(); expect(reconstructedContractProvider.recordedUploads, isEmpty, reason: 'Reconstructed service must NOT perform any uploads for already-synced items.'); expect(r2Calls, hasLength(1), reason: 'Total R2 HTTP PUT calls must remain strictly 1 across entire fixture run.'); // Attempt re-enqueuing the same ride into the reconstructed manager. // SyncOutbox.enqueue() must safely detect the existing 'synced' record and return it without re-queueing. await reconstructedSyncManager.enqueueRide(localRideId, autoProcess: false); expect(reconstructedOutbox.getItem(localRideId, 'firebase')!.status, SyncStatus.synced); expect(reconstructedSyncManager.currentState.pendingCount, 0); expect(reconstructedSyncManager.currentState.syncedCount, 1); // Trigger processQueue() again to verify post-enqueue idempotency await reconstructedSyncManager.processQueue(); expect(reconstructedContractProvider.recordedUploads, isEmpty, reason: 'No uploads may occur after re-enqueuing an already synced ride on reconstructed service.'); expect(r2Calls, hasLength(1), reason: 'Total R2 HTTP PUT calls must remain strictly 1 (zero duplicate uploads).'); // The remote ID in SQLite AppDatabase must remain completely stable and immutable across re-queries final secondRemoteId = await db.ensureCloudRideId(localRideId); expect(secondRemoteId, expectedRemoteId, reason: 'cloudRideId contract requires immutable identity across restarts and re-queries.'); expect(reconstructedSyncManager.currentState.pendingCount, 0); expect(reconstructedSyncManager.currentState.syncedCount, 1); reconstructedSyncManager.dispose(); }); }