217 lines
7.0 KiB
Dart
217 lines
7.0 KiB
Dart
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<bool> isRideConfirmedInCloud(String remoteRideId) 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"));
|
|
},
|
|
);
|
|
});
|
|
}
|