391 lines
14 KiB
Dart
391 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 {
|
|
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));
|
|
});
|
|
});
|
|
}
|