346 lines
12 KiB
Dart
346 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:drift/drift.dart' as drift;
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_user.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_state.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_service.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_providers.dart';
|
|
|
|
/// In-memory Mock AuthService for unit testing without live network / Firebase credentials
|
|
class FakeAuthService 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 {}
|
|
@override Future<void> ensureBackendIdentity() async {}
|
|
final _stateController = StreamController<AuthState>.broadcast();
|
|
AuthState _currentState = const AuthState.unauthenticated(isFirebaseConfigured: true);
|
|
bool _isConfigured = true;
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => _stateController.stream;
|
|
|
|
@override
|
|
AuthState get currentState => _currentState;
|
|
|
|
@override
|
|
AuthUser? get currentUser => _currentState.user;
|
|
|
|
@override
|
|
bool get isConfigured => _isConfigured;
|
|
|
|
void setConfigured(bool val) {
|
|
_isConfigured = val;
|
|
if (!val) {
|
|
_emit(const AuthState.unauthenticated(isFirebaseConfigured: false));
|
|
}
|
|
}
|
|
|
|
void _emit(AuthState state) {
|
|
_currentState = state;
|
|
_stateController.add(state);
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {
|
|
if (!_isConfigured) {
|
|
_emit(const AuthState.error(
|
|
'Cloud authentication unavailable in offline mode.',
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
_emit(const AuthState.loading(isFirebaseConfigured: true));
|
|
if (email == 'rider@motorsignal.app' && password == 'superSecret123') {
|
|
final user = AuthUser(
|
|
uid: 'user_12345',
|
|
email: email,
|
|
displayName: 'Track Rider',
|
|
providerId: 'password',
|
|
createdAt: DateTime(2026, 1, 1),
|
|
);
|
|
_emit(AuthState.authenticated(user, isFirebaseConfigured: true));
|
|
} else {
|
|
_emit(const AuthState.error(
|
|
'Invalid credentials. Please verify your email and password.',
|
|
isFirebaseConfigured: true,
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(
|
|
String email,
|
|
String password, {
|
|
String? displayName,
|
|
}) async {
|
|
if (!_isConfigured) {
|
|
_emit(const AuthState.error(
|
|
'Registration unavailable offline.',
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
_emit(const AuthState.loading(isFirebaseConfigured: true));
|
|
if (email.contains('@')) {
|
|
final user = AuthUser(
|
|
uid: 'new_user_${DateTime.now().millisecondsSinceEpoch}',
|
|
email: email,
|
|
displayName: displayName ?? 'New Rider',
|
|
providerId: 'password',
|
|
createdAt: DateTime.now(),
|
|
);
|
|
_emit(AuthState.authenticated(user, isFirebaseConfigured: true));
|
|
} else {
|
|
_emit(const AuthState.error('The email address format is invalid.', isFirebaseConfigured: true));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithGoogle() async {
|
|
if (!_isConfigured) {
|
|
_emit(const AuthState.error(
|
|
'Google Sign-In unavailable without configuration.',
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
_emit(const AuthState.loading(isFirebaseConfigured: true));
|
|
final user = AuthUser(
|
|
uid: 'google_rider_99',
|
|
email: 'pilot@gmail.com',
|
|
displayName: 'Apex Pilot',
|
|
photoUrl: 'https://lh3.googleusercontent.com/a/avatar.png',
|
|
providerId: 'google.com',
|
|
createdAt: DateTime(2026, 3, 15),
|
|
);
|
|
_emit(AuthState.authenticated(user, isFirebaseConfigured: true));
|
|
}
|
|
|
|
@override
|
|
Future<void> signInAnonymously() async {
|
|
_emit(const AuthState.loading(isFirebaseConfigured: true));
|
|
final guest = AuthUser(
|
|
uid: 'anon_guest_777',
|
|
isAnonymous: true,
|
|
providerId: 'anonymous',
|
|
);
|
|
_emit(AuthState.anonymous(guest, isFirebaseConfigured: _isConfigured));
|
|
}
|
|
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {
|
|
if (!email.contains('@')) {
|
|
_emit(const AuthState.error('The email address format is invalid.'));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
|
if (_currentState.user == null) return null;
|
|
return 'fake_id_token_${_currentState.user!.uid}';
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
_emit(AuthState.unauthenticated(isFirebaseConfigured: _isConfigured));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stateController.close();
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('MotorSignal Phase 1 — Authentication & Identity Abstraction Tests', () {
|
|
test('1. AuthUser Model Serialization & Attributes', () {
|
|
final now = DateTime(2026, 8, 31, 10, 0, 0);
|
|
final user = AuthUser(
|
|
uid: 'usr_8821',
|
|
email: 'ducati_rider@motorsignal.app',
|
|
displayName: 'Desmo Pilot',
|
|
photoUrl: 'https://motorsignal.app/avatar.png',
|
|
isAnonymous: false,
|
|
providerId: 'password',
|
|
createdAt: now,
|
|
lastSignInTime: now,
|
|
);
|
|
|
|
expect(user.uid, equals('usr_8821'));
|
|
expect(user.hasIdentifiedAccount, isTrue);
|
|
expect(user.displayLabel, equals('Desmo Pilot'));
|
|
|
|
final json = user.toJson();
|
|
final reconstructed = AuthUser.fromJson(json);
|
|
|
|
expect(reconstructed.uid, equals(user.uid));
|
|
expect(reconstructed.email, equals(user.email));
|
|
expect(reconstructed.displayName, equals(user.displayName));
|
|
expect(reconstructed.isAnonymous, isFalse);
|
|
expect(reconstructed.providerId, equals('password'));
|
|
});
|
|
|
|
test('2. AuthUser Anonymous & Fallback Display Labels', () {
|
|
const anonUser = AuthUser(
|
|
uid: 'anon_3312',
|
|
isAnonymous: true,
|
|
);
|
|
expect(anonUser.hasIdentifiedAccount, isFalse);
|
|
expect(anonUser.displayLabel, equals('Anonymous Rider'));
|
|
|
|
const emailOnlyUser = AuthUser(
|
|
uid: 'uid_554',
|
|
email: 'r6@track.org',
|
|
);
|
|
expect(emailOnlyUser.displayLabel, equals('r6@track.org'));
|
|
|
|
const rawUser = AuthUser(uid: 'uid_999');
|
|
expect(rawUser.displayLabel, equals('Rider (uid_999)'));
|
|
});
|
|
|
|
test('3. AuthState Status Flags & Immutability', () {
|
|
const initialState = AuthState.initial();
|
|
expect(initialState.isUnauthenticated, isTrue);
|
|
expect(initialState.isAuthenticated, isFalse);
|
|
expect(initialState.isLoading, isFalse);
|
|
|
|
final user = AuthUser(uid: 'u1', email: 'test@motor.com');
|
|
final authedState = AuthState.authenticated(user);
|
|
expect(authedState.isAuthenticated, isTrue);
|
|
expect(authedState.isAnonymous, isFalse);
|
|
expect(authedState.user?.email, equals('test@motor.com'));
|
|
|
|
final errorState = AuthState.error('Network failure');
|
|
expect(errorState.hasError, isTrue);
|
|
expect(errorState.errorMessage, equals('Network failure'));
|
|
|
|
final clearedError = errorState.copyWith(clearError: true);
|
|
expect(clearedError.hasError, isFalse);
|
|
expect(clearedError.errorMessage, isNull);
|
|
});
|
|
|
|
test('4. Riverpod AuthNotifier & State Transitions', () async {
|
|
final fakeAuth = FakeAuthService();
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
authServiceProvider.overrideWithValue(fakeAuth),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
// Initial state
|
|
expect(container.read(isAuthenticatedProvider), isFalse);
|
|
expect(container.read(currentUserProvider), isNull);
|
|
|
|
// Successful sign in
|
|
final notifier = container.read(authNotifierProvider.notifier);
|
|
await notifier.signInWithEmailAndPassword('rider@motorsignal.app', 'superSecret123');
|
|
|
|
expect(container.read(isAuthenticatedProvider), isTrue);
|
|
expect(container.read(currentUserProvider)?.email, equals('rider@motorsignal.app'));
|
|
expect(container.read(currentUserProvider)?.displayName, equals('Track Rider'));
|
|
|
|
// Sign out
|
|
await notifier.signOut();
|
|
expect(container.read(isAuthenticatedProvider), isFalse);
|
|
expect(container.read(currentUserProvider), isNull);
|
|
|
|
// Google sign-in
|
|
await notifier.signInWithGoogle();
|
|
expect(container.read(isAuthenticatedProvider), isTrue);
|
|
expect(container.read(currentUserProvider)?.providerId, equals('google.com'));
|
|
|
|
// Guest / Anonymous mode
|
|
await notifier.signInAnonymously();
|
|
expect(container.read(isAnonymousUserProvider), isTrue);
|
|
expect(container.read(isAuthenticatedProvider), isFalse);
|
|
|
|
// Failed sign in
|
|
await notifier.signInWithEmailAndPassword('wrong@email.com', 'badpass');
|
|
expect(container.read(authNotifierProvider).hasError, isTrue);
|
|
expect(
|
|
container.read(authNotifierProvider).errorMessage,
|
|
contains('Invalid credentials'),
|
|
);
|
|
});
|
|
|
|
test('5. Unconfigured Offline Fallback Mode Handling', () async {
|
|
final fakeAuth = FakeAuthService();
|
|
fakeAuth.setConfigured(false);
|
|
|
|
final container = ProviderContainer(
|
|
overrides: [
|
|
authServiceProvider.overrideWithValue(fakeAuth),
|
|
],
|
|
);
|
|
addTearDown(container.dispose);
|
|
|
|
final state = container.read(authNotifierProvider);
|
|
expect(state.isFirebaseConfigured, isFalse);
|
|
expect(state.isUnauthenticated, isTrue);
|
|
|
|
final notifier = container.read(authNotifierProvider.notifier);
|
|
await notifier.signInWithEmailAndPassword('pilot@motorsignal.app', 'pass123');
|
|
|
|
expect(container.read(authNotifierProvider).hasError, isTrue);
|
|
expect(
|
|
container.read(authNotifierProvider).errorMessage,
|
|
contains('offline mode'),
|
|
);
|
|
});
|
|
|
|
test('6. Authoritative Telemetry & Drift SQLite Isolation Guarantee', () async {
|
|
// Confirm that local Drift database operates seamlessly with zero auth
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
addTearDown(db.close);
|
|
|
|
final startTime = DateTime(2026, 8, 31, 8, 0, 0);
|
|
final rideId = await db.into(db.rides).insert(RidesCompanion.insert(
|
|
startTime: startTime,
|
|
distanceMiles: const drift.Value(12.4),
|
|
topSpeedMph: const drift.Value(68.5),
|
|
maxLeanLeft: const drift.Value(24.2),
|
|
maxLeanRight: const drift.Value(28.7),
|
|
maxAccelG: const drift.Value(0.55),
|
|
maxBrakingG: const drift.Value(-0.85),
|
|
));
|
|
|
|
expect(rideId, isPositive);
|
|
|
|
// Verify unauthenticated ride was persisted locally to Drift Schema 5
|
|
final rides = await db.select(db.rides).get();
|
|
expect(rides.length, equals(1));
|
|
expect(rides.first.id, equals(rideId));
|
|
expect(rides.first.topSpeedMph, equals(68.5));
|
|
expect(rides.first.maxLeanRight, equals(28.7));
|
|
|
|
// Verify 20 Hz sub-second samples persist locally without any auth checks
|
|
final sampleTime = startTime.add(const Duration(milliseconds: 50));
|
|
await db.into(db.telemetrySamples).insert(TelemetrySamplesCompanion.insert(
|
|
rideId: rideId,
|
|
timestamp: sampleTime,
|
|
latitude: const drift.Value(37.7749),
|
|
longitude: const drift.Value(-122.4194),
|
|
speed: const drift.Value(68.5),
|
|
leanAngle: const drift.Value(28.7),
|
|
longitudinalG: const drift.Value(0.2),
|
|
lateralG: const drift.Value(0.4),
|
|
));
|
|
|
|
final samples = await db.select(db.telemetrySamples).get();
|
|
expect(samples.length, equals(1));
|
|
expect(samples.first.speed, equals(68.5));
|
|
});
|
|
});
|
|
}
|