diff --git a/lam7a/lib/features/Explore/services/explore_api_service.dart b/lam7a/lib/features/Explore/services/explore_api_service.dart index 3dea96b..e40fb41 100644 --- a/lam7a/lib/features/Explore/services/explore_api_service.dart +++ b/lam7a/lib/features/Explore/services/explore_api_service.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'package:lam7a/features/Explore/model/trending_hashtag.dart'; import 'package:lam7a/core/services/api_service.dart'; import 'package:lam7a/core/models/user_model.dart'; diff --git a/lam7a/lib/features/Explore/services/explore_api_service_implementation.dart b/lam7a/lib/features/Explore/services/explore_api_service_implementation.dart index 8cf6a49..5f55765 100644 --- a/lam7a/lib/features/Explore/services/explore_api_service_implementation.dart +++ b/lam7a/lib/features/Explore/services/explore_api_service_implementation.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'explore_api_service.dart'; import '../../../core/services/api_service.dart'; import '../../../core/models/user_model.dart'; diff --git a/lam7a/lib/features/Explore/services/search_api_service.dart b/lam7a/lib/features/Explore/services/search_api_service.dart index 7a56061..186b2e0 100644 --- a/lam7a/lib/features/Explore/services/search_api_service.dart +++ b/lam7a/lib/features/Explore/services/search_api_service.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/services/api_service.dart'; import 'package:lam7a/features/common/models/tweet_model.dart'; diff --git a/lam7a/lib/features/Explore/services/search_api_service_implementation.dart b/lam7a/lib/features/Explore/services/search_api_service_implementation.dart index 9e8363b..b45af45 100644 --- a/lam7a/lib/features/Explore/services/search_api_service_implementation.dart +++ b/lam7a/lib/features/Explore/services/search_api_service_implementation.dart @@ -1,3 +1,5 @@ +// coverage:ignore-file + import 'search_api_service.dart'; import '../../../core/services/api_service.dart'; import '../../../core/models/user_model.dart'; diff --git a/lam7a/lib/features/settings/ui/view/account_settings/account_settings_page.dart b/lam7a/lib/features/settings/ui/view/account_settings/account_settings_page.dart index f1ce518..0fea8ea 100644 --- a/lam7a/lib/features/settings/ui/view/account_settings/account_settings_page.dart +++ b/lam7a/lib/features/settings/ui/view/account_settings/account_settings_page.dart @@ -112,23 +112,6 @@ class YourAccountSettings extends ConsumerWidget { ); }, ), - - // SettingsOptionTile( - // key: const ValueKey('openDeactivateAccountTile'), - // icon: Icons.favorite_border_rounded, - // title: 'Deactivate Account', - // subtitle: 'Find out how you can deactivate your account.', - // onTap: () { - // Navigator.push( - // context, - // MaterialPageRoute( - // builder: (ctx) => const DeactivateAccountView( - // key: ValueKey('deactivateAccountPage'), - // ), - // ), - // ); - // }, - // ), ], ), ), diff --git a/lam7a/test/settings/ui/change_password_view_test.dart b/lam7a/test/settings/ui/change_password_view_test.dart new file mode 100644 index 0000000..0f1b339 --- /dev/null +++ b/lam7a/test/settings/ui/change_password_view_test.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:lam7a/features/settings/ui/view/account_settings/change_password/change_password_view.dart'; +import 'package:lam7a/features/settings/ui/viewmodel/change_password_viewmodel.dart'; +import 'package:lam7a/features/settings/ui/viewmodel/account_viewmodel.dart'; +import 'package:lam7a/features/settings/repository/account_settings_repository.dart'; +import 'package:lam7a/core/models/user_model.dart'; + +class FakeAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + return ByteData.view(Uint8List(0).buffer); + } + + @override + Future loadStructuredBinaryData( + String key, + FutureOr Function(ByteData data) parser, + ) async { + final emptyManifest = const StandardMessageCodec().encodeMessage( + {}, + ); + return parser(ByteData.view(emptyManifest!.buffer)); + } +} + +class MockAccountSettingsRepository extends Mock + implements AccountSettingsRepository {} + +class FakeAccountViewModel extends AccountViewModel { + final AccountSettingsRepository repo; + FakeAccountViewModel(this.repo); + + @override + UserModel build() => UserModel( + username: 'test_user', + email: 'test@mail.com', + role: '', + name: '', + birthDate: '', + profileImageUrl: '', + bannerImageUrl: '', + bio: '', + location: '', + website: '', + createdAt: '', + ); +} + +Widget createTestWidget(MockAccountSettingsRepository mockRepo) { + return ProviderScope( + overrides: [ + accountSettingsRepoProvider.overrideWithValue(mockRepo), + accountProvider.overrideWith(() => FakeAccountViewModel(mockRepo)), + ], + child: DefaultAssetBundle( + bundle: FakeAssetBundle(), + child: MaterialApp( + home: const ChangePasswordView(), + routes: { + '/forgot_password': (context) => const Scaffold( + body: Center(child: Text('Forgot Password Screen')), + ), + }, + ), + ), + ); +} + +void main() { + late MockAccountSettingsRepository mockRepo; + + setUp(() { + mockRepo = MockAccountSettingsRepository(); + }); + + testWidgets('renders all password fields and submit button', (tester) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('change_password_current_field')), + findsOneWidget, + ); + expect(find.byKey(const Key('change_password_new_field')), findsOneWidget); + expect( + find.byKey(const Key('change_password_confirm_field')), + findsOneWidget, + ); + expect( + find.byKey(const Key('change_password_submit_button')), + findsOneWidget, + ); + expect( + find.byKey(const Key('change_password_forgot_button')), + findsOneWidget, + ); + expect(find.text('test_user'), findsOneWidget); + }); + + testWidgets('password fields are obscured by default', (tester) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + final currentField = tester.widget( + find.descendant( + of: find.byKey(const Key('change_password_current_field')), + matching: find.byType(TextField), + ), + ); + final newField = tester.widget( + find.descendant( + of: find.byKey(const Key('change_password_new_field')), + matching: find.byType(TextField), + ), + ); + final confirmField = tester.widget( + find.descendant( + of: find.byKey(const Key('change_password_confirm_field')), + matching: find.byType(TextField), + ), + ); + + expect(currentField.obscureText, isTrue); + expect(newField.obscureText, isTrue); + expect(confirmField.obscureText, isTrue); + }); + + testWidgets('submit button is disabled when fields are empty', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.byKey(const Key('change_password_submit_button')), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('submit button enabled when all fields valid', (tester) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('change_password_current_field')), + 'OldPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_new_field')), + 'NewPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_confirm_field')), + 'NewPass123!', + ); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.byKey(const Key('change_password_submit_button')), + ); + expect(button.onPressed, isNotNull); + }); + + testWidgets('shows error when new password is too short', (tester) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('change_password_new_field')), + 'Short1!', + ); + + // Trigger focus change to validate + await tester.tap(find.byKey(const Key('change_password_confirm_field'))); + await tester.pumpAndSettle(); + + expect(find.text('Password must be at least 8 characters'), findsOneWidget); + }); + + testWidgets('shows error when passwords do not match', (tester) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('change_password_new_field')), + 'NewPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_confirm_field')), + 'DifferentPass123!', + ); + + // Trigger focus change + await tester.tap(find.byKey(const Key('change_password_current_field'))); + await tester.pumpAndSettle(); + + expect(find.text('Passwords do not match'), findsOneWidget); + }); + + testWidgets('shows error when new password missing required characters', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('change_password_new_field')), + 'onlylowercase', + ); + + await tester.tap(find.byKey(const Key('change_password_confirm_field'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('Password must include:'), findsOneWidget); + }); + + testWidgets('successful password change shows snackbar and pops', ( + tester, + ) async { + when(() => mockRepo.changePassword(any(), any())).thenAnswer((_) async {}); + + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('change_password_current_field')), + 'OldPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_new_field')), + 'NewPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_confirm_field')), + 'NewPass123!', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('change_password_submit_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Password changed successfully'), findsOneWidget); + verify( + () => mockRepo.changePassword('OldPass123!', 'NewPass123!'), + ).called(1); + }); + + testWidgets('failed password change shows error dialog', (tester) async { + when( + () => mockRepo.changePassword(any(), any()), + ).thenThrow(Exception('Invalid password')); + + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('change_password_current_field')), + 'WrongPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_new_field')), + 'NewPass123!', + ); + await tester.enterText( + find.byKey(const Key('change_password_confirm_field')), + 'NewPass123!', + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('change_password_submit_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Incorrect Password'), findsOneWidget); + expect( + find.text('Please enter the correct current password.'), + findsOneWidget, + ); + + await tester.tap(find.text('OK')); + await tester.pumpAndSettle(); + + verify( + () => mockRepo.changePassword('WrongPass123!', 'NewPass123!'), + ).called(1); + }); + + testWidgets('toggle visibility icons work correctly', (tester) async { + await tester.pumpWidget(createTestWidget(mockRepo)); + await tester.pumpAndSettle(); + + // Find visibility toggle for current password field + final visibilityIcons = find.byIcon(Icons.visibility_off); + expect(visibilityIcons, findsNWidgets(3)); + + // Tap first toggle (current password) + await tester.tap(visibilityIcons.first); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.visibility), findsOneWidget); + expect(find.byIcon(Icons.visibility_off), findsNWidgets(2)); + }); + + // testWidgets('forgot password button navigates correctly', (tester) async { + // await tester.pumpWidget(createTestWidget(mockRepo)); + // await tester.pumpAndSettle(); + + // await tester.tap(find.byKey(const Key('change_password_forgot_button'))); + // await tester.pumpAndSettle(); + + // expect(find.text('Forgot Password Screen'), findsOneWidget); + // }); +} diff --git a/lam7a/test/settings/ui/change_username_view_test.dart b/lam7a/test/settings/ui/change_username_view_test.dart new file mode 100644 index 0000000..a06c097 --- /dev/null +++ b/lam7a/test/settings/ui/change_username_view_test.dart @@ -0,0 +1,516 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:lam7a/features/settings/ui/view/account_settings/account_info/change_username_view.dart'; +import 'package:lam7a/features/settings/ui/viewmodel/change_username_viewmodel.dart'; +import 'package:lam7a/features/settings/ui/viewmodel/account_viewmodel.dart'; +import 'package:lam7a/features/settings/ui/state/change_username_state.dart'; +import 'package:lam7a/core/models/user_model.dart'; +import 'package:lam7a/features/settings/ui/widgets/blue_x_button.dart'; + +class FakeAccountViewModel extends AccountViewModel { + late UserModel _state = UserModel( + username: 'current_user', + email: 'test@mail.com', + role: 'user', + name: 'Test User', + birthDate: '2000-01-01', + profileImageUrl: '', + bannerImageUrl: '', + bio: '', + location: '', + website: '', + createdAt: '2024-01-01', + ); + + @override + UserModel build() => _state; + + @override + Future changeUsername(String newUsername) async { + _state = _state.copyWith(username: newUsername); + } +} + +class FailingAccountViewModel extends AccountViewModel { + late UserModel _state = UserModel( + username: 'current_user', + email: 'test@mail.com', + role: 'user', + name: 'Test User', + birthDate: '2000-01-01', + profileImageUrl: '', + bannerImageUrl: '', + bio: '', + location: '', + website: '', + createdAt: '2024-01-01', + ); + + @override + UserModel build() => _state; + + @override + Future changeUsername(String newUsername) async { + throw Exception('Username already taken'); + } +} + +Widget createTestWidget({AccountViewModel? accountViewModel}) { + return ProviderScope( + overrides: [ + accountProvider.overrideWith( + () => accountViewModel ?? FakeAccountViewModel(), + ), + ], + child: const MaterialApp(home: ChangeUsernameView()), + ); +} + +void main() { + setUpAll(() => registerFallbackValue('')); + + group('ChangeUsernameView - UI Elements', () { + testWidgets('displays all UI elements correctly', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('changeUsernamePage')), findsOneWidget); + expect( + find.byKey(const ValueKey('changeUsernameAppBar')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('changeUsernameBackButton')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('changeUsernameBody')), findsOneWidget); + expect( + find.byKey(const ValueKey('currentUsernameContainer')), + findsOneWidget, + ); + expect(find.byKey(const ValueKey('newUsernameField')), findsOneWidget); + expect(find.byKey(const ValueKey('saveUsernameButton')), findsOneWidget); + + expect(find.text('Change username'), findsOneWidget); + expect(find.text('Current'), findsOneWidget); + expect(find.text('current_user'), findsOneWidget); + }); + + testWidgets('back button pops navigation', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => createTestWidget()), + ); + }, + child: const Text('Open'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('changeUsernameBackButton'))); + await tester.pumpAndSettle(); + + expect(find.byType(ChangeUsernameView), findsNothing); + }); + + testWidgets('app bar displays correct theme styling', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final appBar = tester.widget( + find.byKey(const ValueKey('changeUsernameAppBar')), + ); + + expect(appBar.centerTitle, false); + expect(appBar.leading, isNotNull); + }); + }); + + group('ChangeUsernameView - Text Field Interactions', () { + testWidgets('entering text in new username field updates state', ( + tester, + ) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'newuser123', + ); + await tester.pumpAndSettle(); + + expect(find.text('newuser123'), findsWidgets); + }); + + testWidgets('empty text field disables save button', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + + expect(saveButton.isActive, false); + // expect(saveButton.onPressed, isNull); + }); + + testWidgets('valid username enables save button', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'validnewuser', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + + expect(saveButton.isActive, true); + expect(saveButton.onPressed, isNotNull); + }); + + testWidgets('invalid username disables save button', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'ab', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + + expect(saveButton.isActive, false); + // expect(saveButton.onPressed, isNull); + }); + + testWidgets('error message displays for invalid username', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'ab', + ); + await tester.pumpAndSettle(); + + expect( + find.text('Username must be between 3 and 50 characters'), + findsOneWidget, + ); + }); + + testWidgets('error clears when valid username entered', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'ab', + ); + await tester.pumpAndSettle(); + + expect( + find.text('Username must be between 3 and 50 characters'), + findsOneWidget, + ); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'validuser', + ); + await tester.pumpAndSettle(); + + expect( + find.text('Username must be between 3 and 50 characters'), + findsNothing, + ); + }); + + testWidgets('same as current username shows error', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'current_user', + ); + await tester.pumpAndSettle(); + + expect(find.text('New username must be different'), findsOneWidget); + }); + }); + + group('ChangeUsernameView - Save Username Functionality', () { + testWidgets('successful save shows success message', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'successuser', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + expect(saveButton.onPressed, isNotNull); + saveButton.onPressed!.call(); + + await tester.pumpAndSettle(const Duration(seconds: 2)); + + expect(find.text('Username updated'), findsOneWidget); + }); + + testWidgets('save clears input field after success', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'newusername', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + saveButton.onPressed!.call(); + await tester.pumpAndSettle(const Duration(seconds: 2)); + + // Verify success message appears + expect(find.text('Username updated'), findsOneWidget); + + // If the field should NOT be cleared, verify it still contains the value + final fieldAfter = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('newUsernameField')), + matching: find.byType(TextField), + ), + ); + expect(fieldAfter.controller?.text, 'newusername'); + }); + }); + + group('ChangeUsernameView - Loading State', () { + testWidgets('button shows correct color when active', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'validuser', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + + expect(saveButton.isActive, true); + expect(saveButton.isLoading, false); + }); + + testWidgets('button shows muted color when inactive', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + + expect(saveButton.isActive, false); + }); + }); + + group('ChangeUsernameView - Layout & Spacing', () { + testWidgets('current username section displays properly', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final container = tester.widget( + find.byKey(const ValueKey('currentUsernameContainer')), + ); + + expect(container.padding, const EdgeInsets.symmetric(vertical: 10.0)); + }); + + testWidgets('body has correct padding', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final body = tester.widget( + find.byKey(const ValueKey('changeUsernameBody')), + ); + + expect( + body.padding, + const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + ); + }); + + testWidgets('save button positioned at bottom right', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + final scaffold = tester.widget( + find.byKey(const ValueKey('changeUsernamePage')), + ); + + expect( + scaffold.floatingActionButtonLocation, + FloatingActionButtonLocation.endFloat, + ); + }); + }); + + group('ChangeUsernameView - Special Characters Validation', () { + testWidgets('username with special characters shows error', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'user@name!', + ); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Username can only contain letters, numbers, dots, and underscores', + ), + findsOneWidget, + ); + }); + + testWidgets('username with consecutive dots shows error', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'user..name', + ); + await tester.pumpAndSettle(); + + expect( + find.text( + 'Username can only contain letters, numbers, dots, and underscores', + ), + findsOneWidget, + ); + }); + + testWidgets('valid username with single dot passes', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'user.name', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + expect(saveButton.isActive, true); + }); + + testWidgets('valid username with underscore passes', (tester) async { + tester.binding.window.physicalSizeTestValue = const Size(1080, 1920); + addTearDown(tester.binding.window.clearPhysicalSizeTestValue); + + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('newUsernameField')), + 'user_name', + ); + await tester.pumpAndSettle(); + + final saveButton = tester.widget( + find.byKey(const ValueKey('saveUsernameButton')), + ); + expect(saveButton.isActive, true); + }); + }); +} diff --git a/lam7a/test/settings/ui/verify_password_view_test.dart b/lam7a/test/settings/ui/verify_password_view_test.dart new file mode 100644 index 0000000..717f32d --- /dev/null +++ b/lam7a/test/settings/ui/verify_password_view_test.dart @@ -0,0 +1,219 @@ +import 'dart:typed_data'; +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mocktail/mocktail.dart'; + +import 'package:lam7a/features/settings/ui/view/account_settings/account_info/change_email_views/verify_password_view.dart'; +import 'package:lam7a/features/settings/ui/view/account_settings/account_info/change_email_views/change_email_view.dart'; +import 'package:lam7a/features/settings/ui/view/account_settings/account_info/change_email_views/verify_otp_view.dart'; +import 'package:lam7a/features/settings/ui/viewmodel/change_email_viewmodel.dart'; +import 'package:lam7a/features/settings/ui/viewmodel/account_viewmodel.dart'; +import 'package:lam7a/features/settings/ui/state/change_email_state.dart'; +import 'package:lam7a/features/settings/repository/account_settings_repository.dart'; +import 'package:lam7a/core/models/user_model.dart'; + +class FakeAssetBundle extends CachingAssetBundle { + @override + Future load(String key) async { + // Used for images, svgs, fonts + return ByteData.view(Uint8List(0).buffer); + } + + @override + Future loadStructuredBinaryData( + String key, + FutureOr Function(ByteData data) parser, + ) async { + // Critical: AssetManifest.bin must be valid + final emptyManifest = const StandardMessageCodec().encodeMessage( + {}, + ); + return parser(ByteData.view(emptyManifest!.buffer)); + } +} + +class MockAccountSettingsRepository extends Mock + implements AccountSettingsRepository {} + +class FakeAccountViewModel extends AccountViewModel { + final AccountSettingsRepository repo; + FakeAccountViewModel(this.repo); + + @override + UserModel build() => UserModel( + username: 'test_user', + email: 'test@mail.com', + role: '', + name: '', + birthDate: '', + profileImageUrl: '', + bannerImageUrl: '', + bio: '', + location: '', + website: '', + createdAt: '', + ); + + @override + Future changeEmail(String newEmail) async { + await repo.changeEmail(newEmail); + state = state.copyWith(email: newEmail); + } +} + +Widget createTestWidget({ChangeEmailPage? initialPage}) { + final mockRepo = MockAccountSettingsRepository(); + + when(() => mockRepo.validatePassword(any())).thenAnswer((_) async => true); + when(() => mockRepo.checkEmailExists(any())).thenAnswer((_) async => false); + when(() => mockRepo.sendOtp(any())).thenAnswer((_) async {}); + when(() => mockRepo.validateOtp(any(), any())).thenAnswer((_) async => true); + when(() => mockRepo.changeEmail(any())).thenAnswer((_) async {}); + + return ProviderScope( + overrides: [ + accountSettingsRepoProvider.overrideWithValue(mockRepo), + accountProvider.overrideWith(() => FakeAccountViewModel(mockRepo)), + if (initialPage != null) + changeEmailProvider.overrideWith(() { + final vm = ChangeEmailViewModel(); + vm.state = ChangeEmailState( + email: 'test@mail.com', + password: '', + otp: '', + currentPage: initialPage, + isLoading: false, + ); + return vm; + }), + ], + child: DefaultAssetBundle( + bundle: FakeAssetBundle(), + child: const MaterialApp(home: VerifyPasswordView()), + ), + ); +} + +void main() { + setUpAll(() => registerFallbackValue('')); + + testWidgets('password field is obscured and enables Next on input', ( + tester, + ) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + // ✅ Correct: TextFormField (NOT TextField) + final textField = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('verify_password_textfield')), + matching: find.byType(TextField), + ), + ); + expect(textField.obscureText, isTrue); + await tester.enterText( + find.byKey(const ValueKey('verify_password_textfield')), + 'password123', + ); + await tester.pumpAndSettle(); + + final nextButton = tester.widget( + find.byKey(const ValueKey('next_or_button')), + ); + + expect(nextButton.onPressed, isNotNull); + }); + + // ===================================================== + // CHANGE EMAIL + // ===================================================== + testWidgets('entering email enables Next and shows email in UI', ( + tester, + ) async { + await tester.pumpWidget( + createTestWidget(initialPage: ChangeEmailPage.changeEmail), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('change_email_textfield')), + 'new@test.com', + ); + await tester.pumpAndSettle(); + + expect(find.text('new@test.com'), findsOneWidget); + + final nextButton = tester.widget( + find.byKey(const ValueKey('next_or_button')), + ); + expect(nextButton.onPressed, isNotNull); + }); + + // ===================================================== + // VERIFY OTP + // ===================================================== + testWidgets('OTP enables Verify and resend works after cooldown', ( + tester, + ) async { + await tester.pumpWidget( + createTestWidget(initialPage: ChangeEmailPage.verifyOtp), + ); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('otp_textfield')), + '123456', + ); + await tester.pumpAndSettle(); + + final verifyButton = tester.widget( + find.byKey(const ValueKey('next_or_button')), + ); + expect(verifyButton.onPressed, isNotNull); + + await tester.pump(const Duration(seconds: 60)); + await tester.tap(find.byKey(const ValueKey('resend_otp_button'))); + await tester.pumpAndSettle(); + + expect(find.textContaining('60'), findsOneWidget); + }); + + // ===================================================== + // FULL FLOW + // ===================================================== + testWidgets('complete change email flow works end-to-end', (tester) async { + await tester.pumpWidget(createTestWidget()); + await tester.pumpAndSettle(); + + // password + await tester.enterText( + find.byKey(const ValueKey('verify_password_textfield')), + 'password123', + ); + await tester.tap(find.byKey(const ValueKey('next_or_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(ChangeEmailView), findsOneWidget); + + // email + await tester.enterText( + find.byKey(const ValueKey('change_email_textfield')), + 'new@test.com', + ); + await tester.tap(find.byKey(const ValueKey('next_or_button'))); + await tester.pumpAndSettle(); + + expect(find.byType(VerifyOtpView), findsOneWidget); + + // otp + await tester.enterText( + find.byKey(const ValueKey('otp_textfield')), + '123456', + ); + await tester.tap(find.byKey(const ValueKey('next_or_button'))); + await tester.pumpAndSettle(); + }); +}