221 lines
7.4 KiB
Dart
221 lines
7.4 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:motorcycle_dash/database/database.dart';
|
|
import 'package:motorcycle_dash/features/settings/account_screen.dart';
|
|
import 'package:motorcycle_dash/services/auth/auth_providers.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/auth/account_lifecycle_service.dart';
|
|
import 'package:motorcycle_dash/services/cloud/account_cloud_cleanup_service.dart';
|
|
import 'package:motorcycle_dash/services/sync/sync_providers.dart';
|
|
import 'package:motorcycle_dash/features/records/records_service.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class _FakeAuth implements AuthService {
|
|
@override Future<void> ensureBackendIdentity() async {}
|
|
|
|
final _states = StreamController<AuthState>.broadcast();
|
|
AuthUser? user = const AuthUser(uid: 'delete-test', email: 'delete@test.com', providerId: 'password', emailVerified: true);
|
|
bool reauthenticated = false;
|
|
bool deleted = false;
|
|
Object? reauthError;
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => _states.stream;
|
|
@override
|
|
AuthState get currentState => user == null
|
|
? const AuthState.unauthenticated(isFirebaseConfigured: true)
|
|
: AuthState.authenticated(user!, isFirebaseConfigured: true);
|
|
@override
|
|
AuthUser? get currentUser => user;
|
|
@override
|
|
bool get isConfigured => true;
|
|
@override
|
|
bool get isEmailVerified => true;
|
|
@override
|
|
Future<void> reauthenticate({String? password}) async {
|
|
if (reauthError != null) throw reauthError!;
|
|
reauthenticated = true;
|
|
}
|
|
@override
|
|
Future<void> deleteCurrentUser() async {
|
|
deleted = true;
|
|
user = null;
|
|
_states.add(currentState);
|
|
}
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async => 'test-token';
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {}
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName}) async {}
|
|
@override
|
|
Future<void> signInWithGoogle() async {}
|
|
@override
|
|
Future<void> signInAnonymously() async {}
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {}
|
|
@override
|
|
Future<void> sendEmailVerification() async {}
|
|
@override
|
|
Future<void> reloadCurrentUser() async {}
|
|
@override
|
|
Future<void> signOut() async {}
|
|
@override
|
|
void dispose() => _states.close();
|
|
}
|
|
|
|
class _FakeCloud implements CloudAccountCleanup {
|
|
bool called = false;
|
|
Object? error;
|
|
@override
|
|
Future<void> deleteCloudData() async {
|
|
called = true;
|
|
if (error != null) throw error!;
|
|
}
|
|
}
|
|
|
|
Future<AppDatabase> _pumpAccountScreen(
|
|
WidgetTester tester,
|
|
_FakeAuth auth,
|
|
_FakeCloud cloud,
|
|
) async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
|
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [
|
|
sharedPreferencesProvider.overrideWithValue(prefs),
|
|
databaseProvider.overrideWithValue(db),
|
|
authServiceProvider.overrideWithValue(auth),
|
|
accountLifecycleServiceProvider.overrideWithValue(
|
|
AccountLifecycleService(auth: auth, cloud: cloud, db: db),
|
|
),
|
|
],
|
|
child: const MaterialApp(home: AccountScreen()),
|
|
),
|
|
);
|
|
await tester.pumpAndSettle();
|
|
return db;
|
|
}
|
|
|
|
void main() {
|
|
testWidgets(
|
|
'delete account uses one password dialog without lifecycle assertion',
|
|
(tester) async {
|
|
final auth = _FakeAuth();
|
|
final cloud = _FakeCloud();
|
|
final db = await _pumpAccountScreen(tester, auth, cloud);
|
|
addTearDown(() async {
|
|
auth.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
expect(find.text('DELETE ACCOUNT'), findsOneWidget);
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pump();
|
|
expect(find.text('Delete Account'), findsOneWidget);
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
await tester.enterText(find.byType(TextField), 'test-password');
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pump();
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(auth.reauthenticated, isTrue);
|
|
expect(cloud.called, isTrue);
|
|
expect(auth.deleted, isTrue);
|
|
expect(find.text('Account & Cloud Identity'), findsOneWidget);
|
|
expect(find.text('SIGN IN TO MOTORCLOUD'), findsOneWidget);
|
|
expect(find.text('Account deleted. Local rides remain on this device.'), findsNothing);
|
|
expect(find.text('Confirm Your Password'), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
},
|
|
);
|
|
|
|
testWidgets('canceling account deletion performs no lifecycle work', (tester) async {
|
|
final auth = _FakeAuth();
|
|
final cloud = _FakeCloud();
|
|
final db = await _pumpAccountScreen(tester, auth, cloud);
|
|
addTearDown(() async {
|
|
auth.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pump();
|
|
await tester.tap(find.text('CANCEL').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(auth.reauthenticated, isFalse);
|
|
expect(auth.deleted, isFalse);
|
|
expect(cloud.called, isFalse);
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('failed password reauthentication stays lifecycle safe', (tester) async {
|
|
final auth = _FakeAuth()..reauthError = StateError('invalid password');
|
|
final cloud = _FakeCloud();
|
|
final db = await _pumpAccountScreen(tester, auth, cloud);
|
|
addTearDown(() async {
|
|
auth.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(TextField), 'wrong-password');
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(auth.reauthenticated, isFalse);
|
|
expect(auth.deleted, isFalse);
|
|
expect(cloud.called, isFalse);
|
|
expect(
|
|
find.text('Account deletion could not be completed. Your account and local rides are unchanged.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('cloud cleanup failure stays controlled and preserves session', (tester) async {
|
|
final auth = _FakeAuth();
|
|
final cloud = _FakeCloud()
|
|
..error = FirebaseException(
|
|
plugin: 'cloud_firestore',
|
|
code: 'permission-denied',
|
|
message: 'cleanup unavailable',
|
|
);
|
|
final db = await _pumpAccountScreen(tester, auth, cloud);
|
|
addTearDown(() async {
|
|
auth.dispose();
|
|
await db.close();
|
|
});
|
|
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pump();
|
|
await tester.enterText(find.byType(TextField), 'test-password');
|
|
await tester.tap(find.text('DELETE ACCOUNT').last);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(auth.reauthenticated, isTrue);
|
|
expect(auth.deleted, isFalse);
|
|
expect(cloud.called, isTrue);
|
|
expect(auth.currentUser, isNotNull);
|
|
expect(
|
|
find.text('Account deletion could not be completed. Your account and local rides are unchanged.'),
|
|
findsOneWidget,
|
|
);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
}
|