fix: make cloud ride bundles deterministic across retries
This commit is contained in:
parent
815ca09ab4
commit
f080410005
|
|
@ -57,7 +57,7 @@ class FieldTestExportService {
|
|||
}
|
||||
|
||||
/// Step 15: Complete JSON Forensic Session Export with Versioning Identity
|
||||
Future<String> generateFieldTestSessionJson(int rideId) async {
|
||||
Future<String> 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<String, dynamic> exportMap = {
|
||||
'exportVersion': '1.0.0',
|
||||
'generatedAt': DateTime.now().toIso8601String(),
|
||||
'generatedAt': (generatedAt ?? DateTime.now()).toIso8601String(),
|
||||
'software': appIdentity.toJson(),
|
||||
'rideMetadata': {
|
||||
'id': ride?.id,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ Uint8List compressFilesToZipIsolate(Map<String, String> 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);
|
||||
|
|
|
|||
|
|
@ -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<String> uploadedRemoteIds = [];
|
||||
|
||||
@override
|
||||
String get providerId => 'firebase';
|
||||
|
||||
@override
|
||||
Future<bool> isAvailable() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> requiresAuthentication() async => false;
|
||||
|
||||
@override
|
||||
Future<SyncResult> 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 = <String>[];
|
||||
final objectKeys = <String>[];
|
||||
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"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue