diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 16:21:11 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 16:21:11 +0300 |
| commit | b4a200fa07a5c659661a2a695c6cfe57c70e3ab1 (patch) | |
| tree | 6ba75f6b86fd9916abbeeda58c1e0f47a9832937 /player-android/test | |
| parent | ee99ef26a45cbcf7595bf77bc315644d59473f30 (diff) | |
Implement AdminUsersScreen with create/delete user and admin gating (db)
- Add AdminUsersScreen with user list, create dialog, delete confirmation,
and optimistic UI (revert on error) for both create and delete operations.
- Add currentUserProvider (FutureProvider) to resolve the logged-in User
object from token storage + listUsers, used for self-delete gating and
Settings admin section visibility.
- Gate Admin section (Manage Users tile) in SettingsScreen behind
currentUserProvider → isAdmin, providing defence-in-depth alongside
server-side 403 enforcement.
- Add adminUserErrorMessage to error_mappers.dart with 400/403/409 handling.
- Add adminUsers route constant (AppRoutes.adminUsers) and GoRouter entry.
- Add 25 tests in admin_users_screen_test.dart and 3 admin-section tests
in settings_screen_test.dart (397 tests total pass).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/test')
| -rw-r--r-- | player-android/test/screens/admin_users_screen_test.dart | 704 | ||||
| -rw-r--r-- | player-android/test/screens/settings_screen_test.dart | 75 |
2 files changed, 773 insertions, 6 deletions
diff --git a/player-android/test/screens/admin_users_screen_test.dart b/player-android/test/screens/admin_users_screen_test.dart new file mode 100644 index 0000000..17be47e --- /dev/null +++ b/player-android/test/screens/admin_users_screen_test.dart @@ -0,0 +1,704 @@ +// Widget tests for AdminUsersScreen (admin_users_screen.dart). +// +// Tests cover: +// 1. Loading state: spinner shown while listUsers is in flight. +// 2. Renders user list after a successful load. +// 3. Self-row: delete button is hidden for the current user's own row. +// 4. Non-self row: delete button is visible for other users. +// 5. Create dialog: opens on FAB tap and submits a new user. +// 6. Create dialog: cancel closes without calling createUser. +// 7. Create dialog: validation — empty username and short password are rejected. +// 8. Create optimistic UI: placeholder row appears immediately, replaced on success. +// 9. Create optimistic UI: placeholder reverted and error SnackBar shown on failure. +// 10. Delete: confirmation dialog appears; cancel leaves the row; confirm removes it. +// 11. Delete optimistic UI: row removed immediately, reinserted on API error. +// 12. Empty state: shown when listUsers returns []. +// 13. Error state: shown when listUsers throws. +// 14. Retry button re-calls listUsers after an error. +// 15. adminUserErrorMessage unit tests (400, 403, 409, connection, generic). +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/admin_users_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/api/player_api_client.dart'; +import 'package:player_android/models/models.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/providers/current_user_provider.dart'; +import 'package:player_android/screens/admin_users_screen.dart'; +import 'package:player_android/utils/error_mappers.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed username without hitting +/// the OS keychain. +class _FakeTokenStorage implements TokenStorage { + _FakeTokenStorage([this._token = 'alice']); + final String? _token; + + @override + Future<String?> readToken() async => _token; + + @override + Future<void> writeToken(String token) async {} + + @override + Future<void> deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [AdminUsersScreen] tests. +/// +/// [listUsers], [createUser], and [deleteUser] are the primary subjects. +/// All other methods remain [UnimplementedError] — the screen calls only these. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio(BaseOptions(baseUrl: 'http://test.local'))); + + // ---- listUsers ---- + + /// When non-null, [listUsers] returns this list. + List<User>? usersResult; + + /// When non-null, [listUsers] throws this instead of returning. + Object? usersError; + + /// Number of times [listUsers] has been called. + int listUsersCallCount = 0; + + @override + Future<List<User>> listUsers() async { + listUsersCallCount++; + if (usersError != null) throw usersError!; + return usersResult!; + } + + // ---- createUser ---- + + /// When non-null, [createUser] returns this user. + User? createResult; + + /// When non-null, [createUser] throws this instead of returning. + Object? createError; + + /// Captures the last call arguments to [createUser]. + String? createdUsername; + bool? createdIsAdmin; + + @override + Future<User> createUser({ + required String username, + required String password, + required bool isAdmin, + }) async { + createdUsername = username; + createdIsAdmin = isAdmin; + if (createError != null) throw createError!; + return createResult!; + } + + // ---- deleteUser ---- + + /// When non-null, [deleteUser] throws this instead of returning. + Object? deleteError; + + /// The ID passed to the last [deleteUser] call. + int? deletedUserId; + + @override + Future<void> deleteUser(int userId) async { + deletedUserId = userId; + if (deleteError != null) throw deleteError!; + } +} + +/// Controllable [PlayerApiClient] stub that delays [listUsers] until +/// [complete] is called — used to inspect the mid-flight loading state. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + final _completer = Completer<List<User>>(); + + /// Resolves the pending [listUsers] with [users]. + void complete(List<User> users) => _completer.complete(users); + + @override + Future<List<User>> listUsers() => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// Admin user (the one "logged in" — alice with id=1). +const _kAlice = User(id: 1, username: 'alice', isAdmin: true); + +/// Regular user. +const _kBob = User(id: 2, username: 'bob', isAdmin: false); + +/// Another regular user. +const _kCarol = User(id: 3, username: 'carol', isAdmin: false); + +// --------------------------------------------------------------------------- +// Helper: pump AdminUsersScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [AdminUsersScreen] with a [ProviderScope] that overrides: +/// - [apiClientProvider] with [fakeClient]. +/// - [tokenStorageProvider] with an in-memory fake. +/// - [currentUserProvider] with [currentUser] if provided, so the screen +/// knows which row is "self" and hides the delete button for it. +/// +/// Using [MaterialApp] (not [MaterialApp.router]) is sufficient here because +/// [AdminUsersScreen] does not call [context.go]; it only shows dialogs and +/// SnackBars. +Future<void> _pumpAdminUsersScreen( + WidgetTester tester, + PlayerApiClient fakeClient, { + User? currentUser = _kAlice, +}) async { + final overrides = <Override>[ + tokenStorageProvider.overrideWithValue( + _FakeTokenStorage(currentUser?.username), + ), + apiClientProvider.overrideWithValue(fakeClient), + // Override currentUserProvider so the screen's self-detection works + // without a real listUsers round-trip inside the provider itself. + if (currentUser != null) + currentUserProvider.overrideWith( + (ref) async => currentUser, + ), + ]; + + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: const MaterialApp( + home: AdminUsersScreen(), + ), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading indicator while listUsers is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpAdminUsersScreen(tester, fakeClient); + + // Pump a single frame so initState's addPostFrameCallback fires but the + // Future has not resolved yet. + await tester.pump(); + + expect(find.byKey(const Key('admin_users_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve to avoid dangling-async warnings. + fakeClient.complete([_kAlice]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders user list + // -------------------------------------------------------------------------- + + group('renders user list', () { + testWidgets('shows a tile for each user returned by listUsers', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice, _kBob, _kCarol]; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_users_list')), findsOneWidget); + expect(find.text('alice'), findsOneWidget); + expect(find.text('bob'), findsOneWidget); + expect(find.text('carol'), findsOneWidget); + }); + + testWidgets('hides delete button for the current user\'s own row', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice, _kBob]; + + // Current user is alice (id=1); her tile should not have a delete button. + await _pumpAdminUsersScreen(tester, fakeClient, currentUser: _kAlice); + await tester.pumpAndSettle(); + + // Alice tile: no delete button. + expect( + find.byKey(const Key('admin_user_delete_1')), + findsNothing, + ); + // Bob tile: delete button present. + expect(find.byKey(const Key('admin_user_delete_2')), findsOneWidget); + }); + + testWidgets('shows delete button for users other than the current user', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice, _kBob, _kCarol]; + + await _pumpAdminUsersScreen(tester, fakeClient, currentUser: _kAlice); + await tester.pumpAndSettle(); + + // Both non-self users have delete buttons. + expect(find.byKey(const Key('admin_user_delete_2')), findsOneWidget); + expect(find.byKey(const Key('admin_user_delete_3')), findsOneWidget); + }); + + testWidgets('shows "(you)" subtitle on the current user\'s own row', + (tester) async { + final fakeClient = _FakeApiClient()..usersResult = [_kAlice, _kBob]; + + await _pumpAdminUsersScreen(tester, fakeClient, currentUser: _kAlice); + await tester.pumpAndSettle(); + + expect(find.text('(you)'), findsOneWidget); + }); + + testWidgets('renders Admin badge for admin users', (tester) async { + final fakeClient = _FakeApiClient()..usersResult = [_kAlice, _kBob]; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // _kAlice is admin — badge key uses isAdmin=true → 'admin'. + expect(find.byKey(const Key('role_badge_admin')), findsOneWidget); + // _kBob is not admin — badge key uses isAdmin=false → 'user'. + expect(find.byKey(const Key('role_badge_user')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Create user dialog + // -------------------------------------------------------------------------- + + group('create user dialog', () { + testWidgets('opens on FAB tap', (tester) async { + final fakeClient = _FakeApiClient()..usersResult = [_kAlice]; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_users_fab'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_create_user_dialog')), findsOneWidget); + }); + + testWidgets('cancel closes the dialog without calling createUser', + (tester) async { + final fakeClient = _FakeApiClient()..usersResult = [_kAlice]; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_users_fab'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_create_cancel'))); + await tester.pumpAndSettle(); + + // Dialog dismissed. + expect(find.byKey(const Key('admin_create_user_dialog')), findsNothing); + // createUser was never called. + expect(fakeClient.createdUsername, isNull); + }); + + testWidgets('shows validation error for empty username', (tester) async { + final fakeClient = _FakeApiClient()..usersResult = [_kAlice]; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_users_fab'))); + await tester.pumpAndSettle(); + + // Leave username empty, fill a valid password, then submit. + await tester.enterText( + find.byKey(const Key('admin_create_password')), + 'password123', + ); + await tester.tap(find.byKey(const Key('admin_create_submit'))); + await tester.pumpAndSettle(); + + expect(find.text('Username is required.'), findsOneWidget); + // Dialog still open — createUser not called. + expect(find.byKey(const Key('admin_create_user_dialog')), findsOneWidget); + }); + + testWidgets('shows validation error for password shorter than 8 chars', + (tester) async { + final fakeClient = _FakeApiClient()..usersResult = [_kAlice]; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_users_fab'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('admin_create_username')), + 'newuser', + ); + await tester.enterText( + find.byKey(const Key('admin_create_password')), + 'short', + ); + await tester.tap(find.byKey(const Key('admin_create_submit'))); + await tester.pumpAndSettle(); + + expect( + find.text('Password must be at least 8 characters.'), + findsOneWidget, + ); + }); + + testWidgets('submits and adds user to the list on success', (tester) async { + const newUser = User(id: 99, username: 'newuser', isAdmin: false); + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice] + ..createResult = newUser; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_users_fab'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('admin_create_username')), + 'newuser', + ); + await tester.enterText( + find.byKey(const Key('admin_create_password')), + 'securepassword', + ); + await tester.tap(find.byKey(const Key('admin_create_submit'))); + await tester.pumpAndSettle(); + + // Dialog dismissed after successful submit. + expect(find.byKey(const Key('admin_create_user_dialog')), findsNothing); + + // createUser was called with the right username. + expect(fakeClient.createdUsername, equals('newuser')); + expect(fakeClient.createdIsAdmin, isFalse); + + // The new user's tile appears in the list. + expect(find.text('newuser'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Create optimistic UI + // -------------------------------------------------------------------------- + + group('create optimistic UI', () { + testWidgets('reverts placeholder and shows error SnackBar on createUser failure', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice] + ..createError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + statusCode: 409, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_users_fab'))); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const Key('admin_create_username')), + 'alice', + ); + await tester.enterText( + find.byKey(const Key('admin_create_password')), + 'password123', + ); + await tester.tap(find.byKey(const Key('admin_create_submit'))); + await tester.pumpAndSettle(); + + // Placeholder was optimistically added then removed after the error. + // The list should still contain only alice (id=1). + expect(find.byKey(const Key('admin_user_tile_1')), findsOneWidget); + + // Error SnackBar visible. + expect(find.byKey(const Key('admin_users_error_snackbar')), findsOneWidget); + expect( + find.textContaining('already exists'), + findsOneWidget, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Delete user action + // -------------------------------------------------------------------------- + + group('delete user action', () { + testWidgets('confirmation dialog cancel leaves the row intact', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice, _kBob]; + + await _pumpAdminUsersScreen(tester, fakeClient, currentUser: _kAlice); + await tester.pumpAndSettle(); + + // Tap delete for bob. + await tester.tap(find.byKey(const Key('admin_user_delete_2'))); + await tester.pumpAndSettle(); + + // Cancel the confirmation. + await tester.tap(find.byKey(const Key('admin_users_confirm_cancel'))); + await tester.pumpAndSettle(); + + // Bob's row is still present. + expect(find.byKey(const Key('admin_user_tile_2')), findsOneWidget); + expect(fakeClient.deletedUserId, isNull); + }); + + testWidgets('confirmation dialog confirm removes the user row', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice, _kBob]; + + await _pumpAdminUsersScreen(tester, fakeClient, currentUser: _kAlice); + await tester.pumpAndSettle(); + + // Tap delete for bob. + await tester.tap(find.byKey(const Key('admin_user_delete_2'))); + await tester.pumpAndSettle(); + + // Confirm deletion. + await tester.tap(find.byKey(const Key('admin_users_confirm_delete'))); + await tester.pumpAndSettle(); + + // Bob's row removed. + expect(find.byKey(const Key('admin_user_tile_2')), findsNothing); + // alice still present. + expect(find.byKey(const Key('admin_user_tile_1')), findsOneWidget); + + expect(fakeClient.deletedUserId, equals(2)); + + // Success SnackBar shown. + expect( + find.byKey(const Key('admin_users_delete_snackbar')), + findsOneWidget, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Delete optimistic UI + // -------------------------------------------------------------------------- + + group('delete optimistic UI', () { + testWidgets('reverts row and shows error SnackBar on deleteUser failure', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersResult = [_kAlice, _kBob] + ..deleteError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users/2'), + type: DioExceptionType.connectionError, + ); + + await _pumpAdminUsersScreen(tester, fakeClient, currentUser: _kAlice); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_user_delete_2'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('admin_users_confirm_delete'))); + await tester.pumpAndSettle(); + + // Bob's row should be re-inserted after the error. + expect(find.byKey(const Key('admin_user_tile_2')), findsOneWidget); + + // Error SnackBar shown. + expect( + find.byKey(const Key('admin_users_error_snackbar')), + findsOneWidget, + ); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state widget when listUsers returns []', + (tester) async { + final fakeClient = _FakeApiClient()..usersResult = []; + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_users_empty')), findsOneWidget); + expect(find.byKey(const Key('admin_users_list')), findsNothing); + expect(find.byKey(const Key('admin_users_loading')), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when listUsers throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + type: DioExceptionType.connectionError, + ); + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_users_error')), findsOneWidget); + expect(find.byKey(const Key('admin_users_list')), findsNothing); + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('retry button re-calls listUsers after an error', + (tester) async { + final fakeClient = _FakeApiClient() + ..usersError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + type: DioExceptionType.connectionError, + ); + + await _pumpAdminUsersScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_users_retry')), findsOneWidget); + + // Fix the error before retry so the second call succeeds. + fakeClient + ..usersError = null + ..usersResult = [_kAlice]; + + await tester.tap(find.byKey(const Key('admin_users_retry'))); + await tester.pumpAndSettle(); + + // After successful retry the list is visible. + expect(find.byKey(const Key('admin_users_list')), findsOneWidget); + // listUsers called twice: once on init, once on retry. + expect(fakeClient.listUsersCallCount, equals(2)); + }); + }); + + // -------------------------------------------------------------------------- + // adminUserErrorMessage unit tests + // -------------------------------------------------------------------------- + + group('adminUserErrorMessage', () { + test('returns connectivity message for connectionError', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + type: DioExceptionType.connectionError, + ); + expect(adminUserErrorMessage(err), contains('Could not reach the server')); + }); + + test('returns server-message from body for 400 when body has message field', + () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + statusCode: 400, + data: <String, dynamic>{'message': 'password too short'}, + ), + type: DioExceptionType.badResponse, + ); + expect(adminUserErrorMessage(err), equals('password too short')); + }); + + test('returns generic invalid-request message for 400 without body', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + statusCode: 400, + ), + type: DioExceptionType.badResponse, + ); + expect(adminUserErrorMessage(err), contains('Invalid request')); + }); + + test('returns permission message for 403', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + statusCode: 403, + ), + type: DioExceptionType.badResponse, + ); + expect(adminUserErrorMessage(err), contains('permission')); + }); + + test('returns already-exists message for 409', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + statusCode: 409, + ), + type: DioExceptionType.badResponse, + ); + expect(adminUserErrorMessage(err), contains('already exists')); + }); + + test('returns server-error message for 500', () { + final err = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/users'), + statusCode: 500, + ), + type: DioExceptionType.badResponse, + ); + expect(adminUserErrorMessage(err), contains('500')); + }); + + test('returns generic message for non-Dio error', () { + expect(adminUserErrorMessage(Exception('boom')), contains('Unexpected error')); + }); + }); +} diff --git a/player-android/test/screens/settings_screen_test.dart b/player-android/test/screens/settings_screen_test.dart index 468213a..ce61451 100644 --- a/player-android/test/screens/settings_screen_test.dart +++ b/player-android/test/screens/settings_screen_test.dart @@ -7,6 +7,8 @@ // settings provider. // 3. Logout flow: tapping Log Out calls AuthStateNotifier.logout and clears // the stored token. +// 4. Admin section: "Manage Users" tile shown only for admin users; +// hidden for non-admin users. // // Riverpod providers are overridden with in-memory fakes so tests run without // a real server, OS keychain, or SharedPreferences disk I/O. @@ -18,8 +20,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/models/models.dart'; import 'package:player_android/providers/api_client_provider.dart'; import 'package:player_android/providers/auth_state_provider.dart'; +import 'package:player_android/providers/current_user_provider.dart'; import 'package:player_android/providers/settings_provider.dart'; import 'package:player_android/providers/theme_provider.dart'; import 'package:player_android/screens/settings_screen.dart'; @@ -93,9 +97,11 @@ class _FakeThemeNotifier extends ThemeNotifier { /// - [tokenStorageProvider] with an in-memory fake (avoids OS keychain) /// - [settingsProvider] with an in-memory fake (avoids SharedPreferences) /// - [themeProvider] with [themeNotifier] when provided (avoids SharedPreferences) +/// - [currentUserProvider] with [currentUser] when provided (drives admin gating) /// /// Uses [MaterialApp.router] with a minimal [GoRouter] so that [context.go] -/// calls inside [SettingsScreen._logout] do not throw "No GoRouter in context". +/// calls inside [SettingsScreen._logout] and the admin tile do not throw +/// "No GoRouter in context". /// /// Returns a record containing: /// - [storage]: the fake token storage for post-test assertions. @@ -106,13 +112,14 @@ Future<({_FakeTokenStorage storage, _FakeSettingsNotifier settings})> String initialToken = 'alice', String initialUrl = 'http://10.0.2.2:8080', _FakeThemeNotifier? themeNotifier, + User? currentUser, }) async { final fakeStorage = _FakeTokenStorage().._token = initialToken; final fakeSettings = _FakeSettingsNotifier(initialUrl); - // A minimal GoRouter that renders SettingsScreen at '/'. The /login route - // is included so that the safety-net context.go(AppRoutes.login) in - // _logout() does not trigger a "route not found" error. + // A minimal GoRouter that renders SettingsScreen at '/'. The /login and + // /admin/users routes are included so that context.go calls inside the + // screen do not trigger "route not found" errors. final router = GoRouter( initialLocation: '/', routes: [ @@ -124,6 +131,10 @@ Future<({_FakeTokenStorage storage, _FakeSettingsNotifier settings})> path: '/login', builder: (_, __) => const Scaffold(body: Text('Login')), ), + GoRoute( + path: '/admin/users', + builder: (_, __) => const Scaffold(body: Text('Admin Users')), + ), ], ); @@ -136,13 +147,17 @@ Future<({_FakeTokenStorage storage, _FakeSettingsNotifier settings})> // Override theme provider when the caller supplies a fake notifier. if (themeNotifier != null) themeProvider.overrideWith(() => themeNotifier), + // Override currentUserProvider to control admin-section visibility + // without a real listUsers round-trip. + if (currentUser != null) + currentUserProvider.overrideWith((ref) async => currentUser), ], child: MaterialApp.router(routerConfig: router), ), ); - // Allow async providers (_currentUsernameProvider, settingsProvider) to - // resolve their futures before we inspect the widget tree. + // Allow async providers (_currentUsernameProvider, settingsProvider, + // currentUserProvider) to resolve their futures before we inspect the tree. await tester.pumpAndSettle(); return (storage: fakeStorage, settings: fakeSettings); @@ -405,4 +420,52 @@ void main() { expect(capturedState?.isUnauthenticated, isTrue); }); }); + + // -------------------------------------------------------------------------- + // Admin section visibility + // -------------------------------------------------------------------------- + + group('admin section visibility', () { + testWidgets('shows Manage Users tile when current user is admin', + (tester) async { + const adminUser = User(id: 1, username: 'alice', isAdmin: true); + await _pumpSettingsScreen( + tester, + initialToken: 'alice', + currentUser: adminUser, + ); + + // Scroll to ensure the admin section is rendered in the viewport. + await tester.ensureVisible( + find.byKey(const Key('settings_manage_users')), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('settings_manage_users')), findsOneWidget); + expect(find.text('Manage Users'), findsOneWidget); + }); + + testWidgets('hides Manage Users tile when current user is not admin', + (tester) async { + const regularUser = User(id: 2, username: 'bob', isAdmin: false); + await _pumpSettingsScreen( + tester, + initialToken: 'bob', + currentUser: regularUser, + ); + + // The admin tile must not be present for a non-admin user. + expect(find.byKey(const Key('settings_manage_users')), findsNothing); + expect(find.text('Administration'), findsNothing); + }); + + testWidgets('hides admin section when currentUserProvider returns null', + (tester) async { + // No currentUser override → currentUserProvider returns null (loading + // or unauthenticated) → admin section stays hidden. + await _pumpSettingsScreen(tester, initialToken: 'alice'); + + expect(find.byKey(const Key('settings_manage_users')), findsNothing); + }); + }); } |
