MotorSignal-App/test/firebase_sync_test.dart

396 lines
14 KiB
Dart

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 {
@override bool get isEmailVerified => false;
@override Future<void> sendEmailVerification() async {}
@override Future<void> reloadCurrentUser() async {}
@override Future<void> reauthenticate({String? password}) async {}
@override Future<void> deleteCurrentUser() async {}
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<AuthState> get authStateChanges => Stream.value(_state);
@override
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
@override
Future<void> sendPasswordResetEmail(String email) async {}
@override
Future<void> signInAnonymously() async {
setUser(const AuthUser(uid: 'anon_123', isAnonymous: true));
}
@override
Future<void> signInWithEmailAndPassword(String email, String password) async {
setUser(const AuthUser(uid: 'user_123', email: 'pilot@apex.com'));
}
@override
Future<void> signInWithGoogle() async {
setUser(const AuthUser(uid: 'google_123', email: 'pilot@google.com'));
}
@override
Future<String?> getIdToken([bool forceRefresh = false]) async {
if (_user == null) return null;
return 'mock_jwt_token_for_${_user!.uid}';
}
@override
Future<void> 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));
});
});
}