import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; 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/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'; class _R2ThenFirestoreProvider implements SyncProvider { int attempts = 0; bool firestoreAvailable = false; final List uploadedRemoteIds = []; @override String get providerId => 'firebase'; @override Future isAvailable() async => true; @override Future requiresAuthentication() async => false; @override Future isRideConfirmedInCloud(String remoteRideId) async => false; @override Future verifyCloudConfirmation(String remoteRideId) async => CloudConfirmationResult.missing(remoteRideId: remoteRideId); @override Future uploadRide(SyncItem item) async { attempts++; uploadedRemoteIds.add('ride_stable_123'); if (!firestoreAvailable) { return SyncResult.temporaryFailure( 'Firestore unavailable after R2 upload.', ); } return SyncResult.success(remoteId: 'ride_stable_123'); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('R2 and Firestore retry hardening', () { test( 'retries target one deterministic R2 object, including changed bundle bytes', () async { final remoteIds = []; final objectKeys = []; final client = MockClient((request) async { remoteIds.add(request.headers['X-Remote-Ride-Id']!); final remoteRideId = request.headers['X-Remote-Ride-Id']!; final objectKey = 'users/pilot_123/rides/$remoteRideId/telemetry.zip'; objectKeys.add(objectKey); return http.Response( jsonEncode({'objectKey': objectKey, 'size': 4}), 200, ); }); final service = R2UploadService( client: client, workerBaseUrl: 'https://worker.test', ); final first = await service.uploadTelemetryBundle( idToken: 'token', remoteRideId: 'ride_stable_123', zipBytes: Uint8List.fromList([1, 2, 3, 4]), sha256Hash: 'a' * 64, appVersion: '1.0.2', ); final retry = await service.uploadTelemetryBundle( idToken: 'token', remoteRideId: 'ride_stable_123', zipBytes: Uint8List.fromList([4, 3, 2, 1]), sha256Hash: 'b' * 64, appVersion: '1.0.2', ); expect(first.success, isTrue); expect(retry.success, isTrue); expect(remoteIds, ['ride_stable_123', 'ride_stable_123']); expect(objectKeys.toSet(), hasLength(1)); expect(first.objectKey, retry.objectKey); }, ); test( 'R2 success then Firestore failure remains durable and retries one cloud ride', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final outbox = SyncOutbox(prefs); final provider = _R2ThenFirestoreProvider(); var now = DateTime(2026, 9, 3, 12); final manager = SyncManager( outbox, initialProviders: [provider], defaultProviderId: 'firebase', nowProvider: () => now, ); await manager.enqueueRide(42, autoProcess: false); await manager.processQueue(); expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.retryWait); expect(outbox.isRideSynced(42, 'firebase'), isFalse); provider.firestoreAvailable = true; now = now.add(const Duration(seconds: 5)); await manager.processQueue(); expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.synced); expect(provider.attempts, 2); expect(provider.uploadedRemoteIds.toSet(), {'ride_stable_123'}); manager.dispose(); }, ); test( 'interrupted uploading state reloads as retryable queued work', () async { final item = SyncItem( localRideId: 42, providerId: 'firebase', status: SyncStatus.uploading, createdAt: DateTime(2026, 9, 3), ); SharedPreferences.setMockInitialValues({ 'motorsignal_sync_outbox_manifest_v2_local': jsonEncode([ item.toJson(), ]), }); final prefs = await SharedPreferences.getInstance(); final reloaded = SyncOutbox(prefs); expect(reloaded.getItem(42, 'firebase')?.status, SyncStatus.queued); }, ); test( 'completed ride bundle is byte-stable when regenerated for a retry', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); addTearDown(db.close); final rideId = await db .into(db.rides) .insert( RidesCompanion.insert( startTime: DateTime(2026, 9, 3, 10), endTime: drift.Value(DateTime(2026, 9, 3, 11)), distanceMiles: const drift.Value(12.4), ), ); final worker = SyncBundleWorker(db); final first = await worker.generateCompressedBundle(rideId); final retry = await worker.generateCompressedBundle(rideId); expect(first, isNotNull); expect(retry, isNotNull); expect( sha256.convert(first!).toString(), sha256.convert(retry!).toString(), ); }, ); test( 'production path upserts one Firestore document after R2 and records its hash', () { final providerSource = File( 'lib/services/sync/firebase_sync_provider.dart', ).readAsStringSync(); final workerSource = File( 'cloudflare/motorsignal-r2-worker/src/index.ts', ).readAsStringSync(); expect( workerSource, contains( r'const objectKey = `users/${authenticatedUid}/rides/${remoteRideId}/telemetry.zip`;', ), ); expect( workerSource, contains('await env.R2_BUCKET.put(objectKey, payloadBuffer,'), ); expect( providerSource.indexOf('uploadTelemetryBundle('), lessThan( providerSource.indexOf( ".collection('users').doc(user.uid).collection('rides').doc(remoteRideId).set(", ), ), ); expect(providerSource, contains("'sha256': sha256Hash")); expect( providerSource, contains( ".doc(remoteRideId).set(firestorePayload, SetOptions(merge: true))", ), ); expect(providerSource, contains("'telemetryBundlePath': objectKey")); }, ); }); }