summaryrefslogtreecommitdiff
path: root/player-android/test
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 16:21:11 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 16:21:11 +0300
commitb4a200fa07a5c659661a2a695c6cfe57c70e3ab1 (patch)
tree6ba75f6b86fd9916abbeeda58c1e0f47a9832937 /player-android/test
parentee99ef26a45cbcf7595bf77bc315644d59473f30 (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.dart704
-rw-r--r--player-android/test/screens/settings_screen_test.dart75
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);
+ });
+ });
}