diff --git a/lib/services/field_test/field_test_export_service.dart b/lib/services/field_test/field_test_export_service.dart index eea9ca6..0763262 100644 --- a/lib/services/field_test/field_test_export_service.dart +++ b/lib/services/field_test/field_test_export_service.dart @@ -57,7 +57,7 @@ class FieldTestExportService { } /// Step 15: Complete JSON Forensic Session Export with Versioning Identity - Future generateFieldTestSessionJson(int rideId) async { + Future generateFieldTestSessionJson(int rideId, {DateTime? generatedAt}) async { final ride = await (_db.select(_db.rides)..where((r) => r.id.equals(rideId))).getSingleOrNull(); final summaryRecord = await (_db.select(_db.fieldTestSummaries)..where((s) => s.rideId.equals(rideId))).getSingleOrNull(); final events = await (_db.select(_db.fieldTestEvents) @@ -70,7 +70,7 @@ class FieldTestExportService { final Map exportMap = { 'exportVersion': '1.0.0', - 'generatedAt': DateTime.now().toIso8601String(), + 'generatedAt': (generatedAt ?? DateTime.now()).toIso8601String(), 'software': appIdentity.toJson(), 'rideMetadata': { 'id': ride?.id, diff --git a/lib/services/sync/sync_bundle_worker.dart b/lib/services/sync/sync_bundle_worker.dart index 9a9633c..0f63fa5 100644 --- a/lib/services/sync/sync_bundle_worker.dart +++ b/lib/services/sync/sync_bundle_worker.dart @@ -19,7 +19,8 @@ Uint8List compressFilesToZipIsolate(Map filesMap) { archive.addFile(ArchiveFile(entry.key, bytes.length, bytes)); } final zipEncoder = ZipEncoder(); - final compressed = zipEncoder.encode(archive); + // Keep retry bytes and the recorded SHA-256 stable for unchanged ride data. + final compressed = zipEncoder.encode(archive, modified: DateTime.utc(1980, 1, 1)); return Uint8List.fromList(compressed); } @@ -41,7 +42,10 @@ class SyncBundleWorker { final exportService = ExportService(_db); // 1. Query local authoritative data on the main isolate - final sessionJson = await fieldTestExport.generateFieldTestSessionJson(rideId); + final sessionJson = await fieldTestExport.generateFieldTestSessionJson( + rideId, + generatedAt: ride.endTime ?? ride.startTime, + ); final eventsCsv = await fieldTestExport.generateFieldTestEventsCsv(rideId); final telemetryCsv = await exportService.exportRideCsv(rideId); final gpxContent = await exportService.exportRideGpx(rideId); diff --git a/test/r2_firestore_retry_hardening_test.dart b/test/r2_firestore_retry_hardening_test.dart new file mode 100644 index 0000000..6a99cd1 --- /dev/null +++ b/test/r2_firestore_retry_hardening_test.dart @@ -0,0 +1,213 @@ +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 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")); + }, + ); + }); +}