import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:archive/archive.dart'; import 'package:drift/native.dart'; import 'package:drift/drift.dart' as drift; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:motorcycle_dash/database/database.dart'; import 'package:motorcycle_dash/services/auth/auth_service.dart'; import 'package:motorcycle_dash/services/auth/auth_state.dart'; import 'package:motorcycle_dash/services/auth/auth_user.dart'; import 'package:motorcycle_dash/services/github/github_auth_service.dart'; import 'package:motorcycle_dash/services/github/github_sync_service.dart'; import 'package:motorcycle_dash/services/sync/firebase_sync_provider.dart'; import 'package:motorcycle_dash/services/sync/github_sync_provider.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 MockAuthService implements AuthService { AuthUser? _user; AuthState _state = const AuthState.unauthenticated(); void setUser(AuthUser? user) { _user = user; if (user != null) { _state = user.isAnonymous ? AuthState.anonymous(user) : AuthState.authenticated(user); } else { _state = const AuthState.unauthenticated(); } } @override bool get isConfigured => true; @override AuthUser? get currentUser => _user; @override AuthState get currentState => _state; @override Stream get authStateChanges => Stream.value(_state); @override Future createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {} @override Future sendPasswordResetEmail(String email) async {} @override Future signInAnonymously() async { setUser(const AuthUser(uid: 'anon_123', isAnonymous: true)); } @override Future signInWithEmailAndPassword(String email, String password) async { setUser(const AuthUser(uid: 'user_123', email: 'pilot@apex.com')); } @override Future signInWithGoogle() async { setUser(const AuthUser(uid: 'google_123', email: 'pilot@google.com')); } @override Future getIdToken([bool forceRefresh = false]) async { if (_user == null) return null; return 'mock_jwt_token_for_${_user!.uid}'; } @override Future signOut() async { _user = null; _state = const AuthState.unauthenticated(); } @override void dispose() {} } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('MotorSignal Phase 2B/2D — Background Bundle Generation Tests', () { late AppDatabase db; setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); }); tearDown(() async { await db.close(); }); test('Bundle Generation: Generates valid ZIP bundle with all 5 forensic files', () async { final startTime = DateTime(2026, 8, 31, 10, 0, 0); final endTime = startTime.add(const Duration(minutes: 25)); // Seed ride final rideId = await db.into(db.rides).insert(RidesCompanion.insert( startTime: startTime, endTime: drift.Value(endTime), distanceMiles: const drift.Value(18.4), topSpeedMph: const drift.Value(78.5), averageSpeed: const drift.Value(44.2), movingTime: const drift.Value(1400), stoppedTime: const drift.Value(100), maxLeanLeft: const drift.Value(34.2), maxLeanRight: const drift.Value(31.8), maxAccelG: const drift.Value(0.62), maxBrakingG: const drift.Value(-0.85), motorcycleMake: const drift.Value('Yamaha'), motorcycleModel: const drift.Value('MT-09'), motorcycleYear: const drift.Value(2024), )); // Seed summary await db.into(db.fieldTestSummaries).insert(FieldTestSummariesCompanion.insert( rideId: rideId, startTime: startTime, endTime: drift.Value(endTime), durationSeconds: const drift.Value(1500), rideQualityScore: const drift.Value(96), rideQualityRating: const drift.Value('EXCELLENT'), )); // Seed 20 telemetry samples for (int i = 0; i < 20; i++) { await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert( rideId: rideId, timestamp: startTime.add(Duration(milliseconds: i * 50)), latitude: drift.Value(37.7749 + (i * 0.0001)), longitude: drift.Value(-122.4194 + (i * 0.0001)), speed: const drift.Value(45.0), leanAngle: const drift.Value(15.0), accelX: const drift.Value(0.1), accelY: const drift.Value(0.2), accelZ: const drift.Value(0.98), )); } // Seed forensic event await db.into(db.fieldTestEvents).insert(FieldTestEventsCompanion.insert( timestamp: startTime.add(const Duration(seconds: 10)), eventType: 'Hard Braking', severity: 'MEDIUM', message: 'Aggressive front brake application detected', rideId: drift.Value(rideId), )); final worker = SyncBundleWorker(db); final zipBytes = await worker.generateCompressedBundle(rideId); expect(zipBytes, isNotNull); expect(zipBytes!.isNotEmpty, isTrue); // Verify ZIP contents using Archive decoder final archive = ZipDecoder().decodeBytes(zipBytes); final fileNames = archive.files.map((f) => f.name).toList(); expect(fileNames, contains('README.txt')); expect(fileNames, contains('ride_summary.json')); expect(fileNames, contains('field_test_session.json')); expect(fileNames, contains('field_test_events.csv')); expect(fileNames, contains('telemetry.csv')); }); test('Bundle Generation: Handles non-existent ride safely by returning null', () async { final worker = SyncBundleWorker(db); final zipBytes = await worker.generateCompressedBundle(99999); expect(zipBytes, isNull); }); }); group('MotorSignal Phase 2D — Cloudflare R2 Upload Service Tests', () { test('R2 Upload: Success HTTP 200 returns valid R2UploadResult.success', () async { final mockClient = http_testing.MockClient((request) async { expect(request.method, equals('PUT')); expect(request.url.path, equals('/r2/upload')); expect(request.headers['Authorization'], equals('Bearer token_123')); expect(request.headers['X-Remote-Ride-Id'], equals('ride_1_20260831_100000')); return http.Response( jsonEncode({ 'success': true, 'objectKey': 'users/user_123/rides/ride_1_20260831_100000/telemetry.zip', 'size': 1024, }), 200, ); }); final service = R2UploadService(client: mockClient); final result = await service.uploadTelemetryBundle( idToken: 'token_123', remoteRideId: 'ride_1_20260831_100000', zipBytes: Uint8List.fromList([1, 2, 3, 4]), sha256Hash: 'dummy_sha256', appVersion: '1.0.2', ); expect(result.success, isTrue); expect(result.objectKey, equals('users/user_123/rides/ride_1_20260831_100000/telemetry.zip')); expect(result.bytesWritten, equals(1024)); }); test('R2 Upload: HTTP 401/403 authorization error returns authRequired', () async { final mockClient = http_testing.MockClient((request) async { return http.Response(jsonEncode({'error': 'Token expired'}), 401); }); final service = R2UploadService(client: mockClient); final result = await service.uploadTelemetryBundle( idToken: 'expired_token', remoteRideId: 'ride_2_20260831_110000', zipBytes: Uint8List.fromList([1, 2]), sha256Hash: 'dummy_hash', appVersion: '1.0.2', ); expect(result.success, isFalse); expect(result.requiresAuth, isTrue); }); test('R2 Upload: HTTP 500 server error returns retryable temporary failure', () async { final mockClient = http_testing.MockClient((request) async { return http.Response('Worker internal error', 500); }); final service = R2UploadService(client: mockClient); final result = await service.uploadTelemetryBundle( idToken: 'valid_token', remoteRideId: 'ride_3_20260831_120000', zipBytes: Uint8List.fromList([1, 2]), sha256Hash: 'dummy_hash', appVersion: '1.0.2', ); expect(result.success, isFalse); expect(result.isRetryable, isTrue); expect(result.requiresAuth, isFalse); }); test('R2 Upload: HTTP 400 bad request returns non-retryable permanent failure', () async { final mockClient = http_testing.MockClient((request) async { return http.Response(jsonEncode({'error': 'Invalid ride ID'}), 400); }); final service = R2UploadService(client: mockClient); final result = await service.uploadTelemetryBundle( idToken: 'valid_token', remoteRideId: 'invalid..path', zipBytes: Uint8List.fromList([1]), sha256Hash: 'dummy_hash', appVersion: '1.0.2', ); expect(result.success, isFalse); expect(result.isRetryable, isFalse); }); }); group('MotorSignal Phase 2B/2D — Firebase & GitHub Sync Provider Tests', () { late AppDatabase db; late MockAuthService authService; late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = AppDatabase.forTesting(NativeDatabase.memory()); authService = MockAuthService(); }); tearDown(() async { await db.close(); }); test('Firebase Provider: Reports unavailable when uninitialized', () async { final provider = FirebaseSyncProvider( db: db, authService: authService, ); final isAvail = await provider.isAvailable(); expect(isAvail, isFalse); }); test('Firebase Provider: Requires authentication when signed out', () async { final provider = FirebaseSyncProvider( db: db, authService: authService, ); final reqAuth = await provider.requiresAuthentication(); expect(reqAuth, isTrue); // When signed in, requiresAuthentication is false authService.setUser(const AuthUser(uid: 'user_123', email: 'rider@test.com')); expect(await provider.requiresAuthentication(), isFalse); }); test('Firebase Provider: Safe fallback when uploading without Firebase configuration', () async { authService.setUser(const AuthUser(uid: 'user_123', email: 'rider@test.com')); final provider = FirebaseSyncProvider( db: db, authService: authService, ); final item = SyncItem( localRideId: 1, providerId: 'firebase', createdAt: DateTime.now(), ); final result = await provider.uploadRide(item); expect(result.success, isFalse); expect(result.isRetryable, isTrue); expect(result.errorMessage, contains('unavailable')); }); test('GitHub Provider: Wraps GitHubSyncService and conforms to SyncProvider contract', () async { final gitHubAuth = GitHubAuthService(prefs); final gitHubSync = GitHubSyncService(db, gitHubAuth, prefs); final provider = GitHubSyncProvider(service: gitHubSync, auth: gitHubAuth); expect(provider.providerId, equals('github')); expect(await provider.isAvailable(), isFalse); // No PAT token set expect(await provider.requiresAuthentication(), isTrue); final item = SyncItem( localRideId: 1, providerId: 'github', createdAt: DateTime.now(), ); final result = await provider.uploadRide(item); expect(result.success, isFalse); expect(result.requiresAuth, isTrue); }); }); group('MotorSignal Phase 2B/2D — Multi-Provider SyncManager Integration Tests', () { late AppDatabase db; late SharedPreferences prefs; setUp(() async { SharedPreferences.setMockInitialValues({}); prefs = await SharedPreferences.getInstance(); db = AppDatabase.forTesting(NativeDatabase.memory()); }); tearDown(() async { await db.close(); }); test('SyncManager: Dispatches between multiple registered providers seamlessly', () async { final outbox = SyncOutbox(prefs); final fakeFirebase = FakeSyncProvider(providerId: 'firebase', behavior: FakeSyncBehavior.success); final fakeGitHub = FakeSyncProvider(providerId: 'github', behavior: FakeSyncBehavior.success); final manager = SyncManager( outbox, initialProviders: [fakeFirebase, fakeGitHub], defaultProviderId: 'firebase', ); // Enqueue for Firebase await manager.enqueueRide(801, targetProviderId: 'firebase', autoProcess: false); // Enqueue for GitHub await manager.enqueueRide(801, targetProviderId: 'github', autoProcess: false); expect(manager.pendingItems.length, equals(2)); await manager.processQueue(); expect(manager.pendingItems.length, equals(0)); expect(outbox.isRideSynced(801, 'firebase'), isTrue); expect(outbox.isRideSynced(801, 'github'), isTrue); expect(fakeFirebase.uploadedItems.length, equals(1)); expect(fakeGitHub.uploadedItems.length, equals(1)); }); }); }