MotorSignal-App/test/auth_test.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));
});
});
}