import 'dart:convert'; import 'dart:typed_data'; 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/services/sync/r2_upload_service.dart'; import 'package:motorcycle_dash/services/sync/sync_models.dart'; import 'package:motorcycle_dash/services/sync/sync_outbox.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('Sync outbox hardening', () { test('interrupted uploading item is requeued after restart', () async { SharedPreferences.setMockInitialValues({ 'motorsignal_sync_outbox_manifest_v2_local': jsonEncode([ SyncItem( localRideId: 42, providerId: 'firebase', status: SyncStatus.uploading, createdAt: DateTime(2026, 9, 1), ).toJson(), ]), }); final prefs = await SharedPreferences.getInstance(); final outbox = SyncOutbox(prefs); expect(outbox.getItem(42, 'firebase')?.status, SyncStatus.queued); }); test('duplicate enqueue remains one logical provider/ride item', () async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); final outbox = SyncOutbox(prefs); await Future.wait([ outbox.enqueue(7, 'firebase'), outbox.enqueue(7, 'firebase'), ]); expect(outbox.count, 1); expect(outbox.getItem(7, 'firebase')?.idempotencyKey, 'firebase:7'); }); test('malformed persisted identity is ignored without killing the queue', () async { SharedPreferences.setMockInitialValues({ 'motorsignal_sync_outbox_manifest_v2_local': jsonEncode([ {'localRideId': -1, 'providerId': '', 'status': 'queued', 'createdAt': 'bad'}, SyncItem(localRideId: 8, providerId: 'firebase', createdAt: DateTime(2026, 9, 1)).toJson(), ]), }); final prefs = await SharedPreferences.getInstance(); final outbox = SyncOutbox(prefs); expect(outbox.count, 1); expect(outbox.getItem(8, 'firebase'), isNotNull); }); }); group('R2 gateway hardening', () { const validHash = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; test('valid response requires a user-scoped object key', () async { final client = MockClient((request) async => http.Response( jsonEncode({'objectKey': 'users/u1/rides/ride_1_20260901_120000/telemetry.zip', 'size': 4}), 200, )); final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test'); final result = await service.uploadTelemetryBundle( idToken: 'token', remoteRideId: 'ride_1_20260901_120000', zipBytes: Uint8List.fromList([1, 2, 3, 4]), sha256Hash: validHash, appVersion: '1.0.2', ); expect(result.success, isTrue); }); test('invalid identifiers are rejected before network I/O', () async { var requests = 0; final client = MockClient((request) async { requests++; return http.Response('{}', 200); }); final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test'); final result = await service.uploadTelemetryBundle( idToken: 'token', remoteRideId: '../escape', zipBytes: Uint8List.fromList([1]), sha256Hash: validHash, appVersion: '1.0.2', ); expect(result.success, isFalse); expect(result.isRetryable, isFalse); expect(requests, 0); }); test('rate limiting is retryable and delegated to SyncManager backoff', () async { final client = MockClient((request) async => http.Response('rate limited', 429)); final service = R2UploadService(client: client, workerBaseUrl: 'https://worker.test'); final result = await service.uploadTelemetryBundle( idToken: 'token', remoteRideId: 'ride_1_20260901_120000', zipBytes: Uint8List.fromList([1]), sha256Hash: validHash, appVersion: '1.0.2', ); expect(result.success, isFalse); expect(result.isRetryable, isTrue); }); }); }