MotorSignal-App/test/r2_firestore_retry_hardenin...

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"));
},
);
});
}