summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/screens/admin_permissions_screen.dart8
-rw-r--r--player-android/lib/screens/admin_rescan_screen.dart20
-rw-r--r--player-android/lib/screens/admin_trash_screen.dart11
-rw-r--r--player-android/lib/utils/error_mappers.dart2
-rw-r--r--player-android/test/screens/admin_permissions_screen_test.dart566
-rw-r--r--player-android/test/screens/admin_rescan_screen_test.dart376
-rw-r--r--player-android/test/screens/admin_trash_screen_test.dart456
7 files changed, 1422 insertions, 17 deletions
diff --git a/player-android/lib/screens/admin_permissions_screen.dart b/player-android/lib/screens/admin_permissions_screen.dart
index 0c35a1b..4f7002d 100644
--- a/player-android/lib/screens/admin_permissions_screen.dart
+++ b/player-android/lib/screens/admin_permissions_screen.dart
@@ -84,8 +84,12 @@ class _AdminPermissionsScreenState
if (!mounted || generation != _generation) return;
- final users = results[0] as List<User>;
- final sets = results[1] as List<MediaSet>;
+ // Use .cast<T>() for list results so a type mismatch produces a useful
+ // error at element access rather than silently failing on a direct cast.
+ // The Map result is kept as-is with a cast because there is no cast()
+ // method on Map in the Dart core library.
+ final users = (results[0] as List).cast<User>();
+ final sets = (results[1] as List).cast<MediaSet>();
final permsData = results[2] as Map<String, dynamic>;
setState(() {
diff --git a/player-android/lib/screens/admin_rescan_screen.dart b/player-android/lib/screens/admin_rescan_screen.dart
index 3ce8953..4e275c4 100644
--- a/player-android/lib/screens/admin_rescan_screen.dart
+++ b/player-android/lib/screens/admin_rescan_screen.dart
@@ -39,7 +39,7 @@ class _AdminRescanScreenState extends ConsumerState<AdminRescanScreen> {
String? _error;
// True while the trigger request is in flight.
- bool _isTriggerring = false;
+ bool _isTriggering = false;
// Active polling timer; cancelled in dispose and whenever the scan finishes.
Timer? _pollTimer;
@@ -117,23 +117,23 @@ class _AdminRescanScreenState extends ConsumerState<AdminRescanScreen> {
/// Sends a trigger-rescan request and immediately begins polling for progress.
Future<void> _triggerRescan() async {
- if (!mounted || _isTriggerring) return;
+ if (!mounted || _isTriggering) return;
setState(() {
- _isTriggerring = true;
+ _isTriggering = true;
_error = null;
});
try {
await ref.read(apiClientProvider).triggerRescan();
if (!mounted) return;
- setState(() => _isTriggerring = false);
+ setState(() => _isTriggering = false);
// Start polling immediately so the user sees progress as soon as the
// server reports the scan has begun.
await _fetchStatus();
} catch (e) {
if (!mounted) return;
setState(() {
- _isTriggerring = false;
+ _isTriggering = false;
_error = adminRescanErrorMessage(e);
});
}
@@ -175,7 +175,7 @@ class _AdminRescanScreenState extends ConsumerState<AdminRescanScreen> {
const SizedBox(height: 32),
_TriggerButton(
isRunning: _status?.isRunning ?? false,
- isTriggerring: _isTriggerring,
+ isTriggering: _isTriggering,
onTap: _triggerRescan,
),
],
@@ -398,23 +398,23 @@ class _ErrorRow extends StatelessWidget {
class _TriggerButton extends StatelessWidget {
const _TriggerButton({
required this.isRunning,
- required this.isTriggerring,
+ required this.isTriggering,
required this.onTap,
});
final bool isRunning;
- final bool isTriggerring;
+ final bool isTriggering;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
// Disable the button while a scan is active or the trigger is in flight.
- final canTrigger = !isRunning && !isTriggerring;
+ final canTrigger = !isRunning && !isTriggering;
return FilledButton.icon(
key: const Key('admin_rescan_trigger'),
onPressed: canTrigger ? onTap : null,
- icon: isTriggerring
+ icon: isTriggering
? const SizedBox(
width: 18,
height: 18,
diff --git a/player-android/lib/screens/admin_trash_screen.dart b/player-android/lib/screens/admin_trash_screen.dart
index 1b63596..db0354a 100644
--- a/player-android/lib/screens/admin_trash_screen.dart
+++ b/player-android/lib/screens/admin_trash_screen.dart
@@ -87,8 +87,9 @@ class _AdminTrashScreenState extends ConsumerState<AdminTrashScreen> {
/// problem. Re-appending (rather than re-inserting at the original index)
/// avoids position jitter from concurrent mutations.
Future<void> _restore(Media item, int index) async {
- // Optimistic removal: remove the item from the list immediately.
- setState(() => _items!.removeAt(index));
+ // Identity-based removal (by id) avoids position drift from concurrent
+ // operations that could shift list indices between tap and setState.
+ setState(() => _items!.removeWhere((e) => e.id == item.id));
try {
await ref.read(apiClientProvider).restoreMedia(item.id);
@@ -122,8 +123,9 @@ class _AdminTrashScreenState extends ConsumerState<AdminTrashScreen> {
final confirmed = await _confirmHardDelete(item.fileName);
if (!confirmed || !mounted) return;
- // Optimistic removal.
- setState(() => _items!.removeAt(index));
+ // Identity-based removal (by id) avoids position drift from concurrent
+ // operations that could shift list indices between tap and setState.
+ setState(() => _items!.removeWhere((e) => e.id == item.id));
try {
// deleteMedia soft-deletes again (no-op for an already-deleted item)
@@ -174,6 +176,7 @@ class _AdminTrashScreenState extends ConsumerState<AdminTrashScreen> {
],
),
);
+ // Tapping outside the dialog returns null; treat as cancel (no deletion).
return result ?? false;
}
diff --git a/player-android/lib/utils/error_mappers.dart b/player-android/lib/utils/error_mappers.dart
index 271a283..6e6f118 100644
--- a/player-android/lib/utils/error_mappers.dart
+++ b/player-android/lib/utils/error_mappers.dart
@@ -385,7 +385,7 @@ String adminPermissionErrorMessage(Object error) {
/// [PlayerApiClient.getScanProgress] to a UI string.
///
/// Adds a 403-specific message (admin-only) on top of the generic
-/// connection-error fallback so [RescanScreen] surfaces actionable guidance.
+/// connection-error fallback so [AdminRescanScreen] surfaces actionable guidance.
/// Kept as a separate top-level function (Open-Closed, DRY).
String adminRescanErrorMessage(Object error) {
if (error is DioException) {
diff --git a/player-android/test/screens/admin_permissions_screen_test.dart b/player-android/test/screens/admin_permissions_screen_test.dart
new file mode 100644
index 0000000..51bb741
--- /dev/null
+++ b/player-android/test/screens/admin_permissions_screen_test.dart
@@ -0,0 +1,566 @@
+// Widget tests for AdminPermissionsScreen (admin_permissions_screen.dart).
+//
+// Tests cover:
+// 1. Permission matrix renders with user rows and set columns.
+// 2. Checking a checkbox calls grantPermission.
+// 3. Unchecking a checkbox calls revokePermission.
+// 4. Admin rows are disabled (cannot toggle).
+// 5. Optimistic toggle reverts on error.
+// 6. Loading spinner shown before first data fetch completes.
+// 7. Empty-state shown when no users or sets are returned.
+// 8. Error-state shown when the load throws.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server or OS keychain.
+//
+// Run with: flutter test test/screens/admin_permissions_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/screens/admin_permissions_screen.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// In-memory [TokenStorage] that returns a fixed username without hitting
+/// the OS keychain.
+class _FakeTokenStorage implements TokenStorage {
+ @override
+ Future<String?> readToken() async => 'admin';
+
+ @override
+ Future<void> writeToken(String token) async {}
+
+ @override
+ Future<void> deleteToken() async {}
+}
+
+/// Controllable [PlayerApiClient] stub for [AdminPermissionsScreen] tests.
+///
+/// [listUsers], [listSets], [listPermissions], [grantPermission], and
+/// [revokePermission] 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 ----
+
+ List<User>? usersResult;
+ Object? usersError;
+
+ @override
+ Future<List<User>> listUsers() async {
+ if (usersError != null) throw usersError!;
+ return usersResult!;
+ }
+
+ // ---- listSets ----
+
+ List<MediaSet>? setsResult;
+ Object? setsError;
+
+ @override
+ Future<List<MediaSet>> listSets() async {
+ if (setsError != null) throw setsError!;
+ return setsResult!;
+ }
+
+ // ---- listPermissions ----
+
+ Map<String, dynamic>? permsResult;
+ Object? permsError;
+
+ @override
+ Future<Map<String, dynamic>> listPermissions() async {
+ if (permsError != null) throw permsError!;
+ return permsResult!;
+ }
+
+ // ---- grantPermission ----
+
+ Object? grantError;
+ int? grantedUserId;
+ int? grantedSetId;
+ int grantCallCount = 0;
+
+ @override
+ Future<void> grantPermission({
+ required int setId,
+ required int userId,
+ required String role,
+ }) async {
+ grantedUserId = userId;
+ grantedSetId = setId;
+ grantCallCount++;
+ if (grantError != null) throw grantError!;
+ }
+
+ // ---- revokePermission ----
+
+ Object? revokeError;
+ int? revokedUserId;
+ int? revokedSetId;
+ int revokeCallCount = 0;
+
+ @override
+ Future<void> revokePermission({
+ required int setId,
+ required int userId,
+ }) async {
+ revokedUserId = userId;
+ revokedSetId = setId;
+ revokeCallCount++;
+ if (revokeError != null) throw revokeError!;
+ }
+}
+
+/// [PlayerApiClient] stub that suspends all three parallel loads until
+/// [complete] is called — used to inspect the mid-flight loading state.
+class _DelayedFakeApiClient extends PlayerApiClient {
+ _DelayedFakeApiClient() : super(dio: Dio());
+
+ final _usersCompleter = Completer<List<User>>();
+ final _setsCompleter = Completer<List<MediaSet>>();
+ final _permsCompleter = Completer<Map<String, dynamic>>();
+
+ void complete({
+ required List<User> users,
+ required List<MediaSet> sets,
+ required Map<String, dynamic> perms,
+ }) {
+ _usersCompleter.complete(users);
+ _setsCompleter.complete(sets);
+ _permsCompleter.complete(perms);
+ }
+
+ @override
+ Future<List<User>> listUsers() => _usersCompleter.future;
+
+ @override
+ Future<List<MediaSet>> listSets() => _setsCompleter.future;
+
+ @override
+ Future<Map<String, dynamic>> listPermissions() => _permsCompleter.future;
+}
+
+// ---------------------------------------------------------------------------
+// Sample data helpers
+// ---------------------------------------------------------------------------
+
+/// Builds a minimal [User] with the given [id], [username], and [isAdmin] flag.
+User _makeUser({required int id, required String username, bool isAdmin = false}) {
+ return User(id: id, username: username, isAdmin: isAdmin);
+}
+
+/// Builds a minimal [MediaSet] with the given [id] and [name].
+MediaSet _makeSet({required int id, required String name}) {
+ return MediaSet(
+ id: id,
+ name: name,
+ rootPath: '/media/$name',
+ coverThumbnailPath: '',
+ isPodcast: false,
+ );
+}
+
+/// Builds an empty permission-matrix response (no explicit permissions).
+Map<String, dynamic> _emptyPerms() => {'permissions': <dynamic>[]};
+
+/// Builds a permission-matrix response with a single (userId, setId) pair.
+Map<String, dynamic> _permsWithGrant({required int userId, required int setId}) {
+ return {
+ 'permissions': [
+ {'user_id': userId, 'set_id': setId, 'role': 'viewer'},
+ ],
+ };
+}
+
+// Pre-built test fixtures.
+final _kAdmin = _makeUser(id: 1, username: 'alice', isAdmin: true);
+final _kBob = _makeUser(id: 2, username: 'bob');
+final _kSetA = _makeSet(id: 10, name: 'Movies');
+final _kSetB = _makeSet(id: 20, name: 'Music');
+
+// ---------------------------------------------------------------------------
+// Helper: pump AdminPermissionsScreen inside a minimal ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Pumps [AdminPermissionsScreen] with a [ProviderScope] that overrides
+/// [apiClientProvider] with [fakeClient] and [tokenStorageProvider] with an
+/// in-memory fake. [MaterialApp] is sufficient because the screen does not
+/// navigate away — it only shows SnackBars.
+Future<void> _pumpPermsScreen(
+ WidgetTester tester,
+ PlayerApiClient fakeClient,
+) async {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ tokenStorageProvider.overrideWithValue(_FakeTokenStorage()),
+ apiClientProvider.overrideWithValue(fakeClient),
+ ],
+ child: const MaterialApp(home: AdminPermissionsScreen()),
+ ),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+void main() {
+ // --------------------------------------------------------------------------
+ // Loading state
+ // --------------------------------------------------------------------------
+
+ group('loading state', () {
+ testWidgets('shows loading spinner while parallel loads are in flight',
+ (tester) async {
+ final fakeClient = _DelayedFakeApiClient();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ // One pump so addPostFrameCallback fires but Futures have not resolved.
+ await tester.pump();
+
+ expect(find.byKey(const Key('admin_perms_loading')), findsOneWidget);
+ expect(find.byType(CircularProgressIndicator), findsOneWidget);
+
+ // Resolve to avoid dangling-async warnings.
+ fakeClient.complete(
+ users: [_kAdmin, _kBob],
+ sets: [_kSetA],
+ perms: _emptyPerms(),
+ );
+ await tester.pumpAndSettle();
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Matrix rendering
+ // --------------------------------------------------------------------------
+
+ group('matrix rendering', () {
+ testWidgets('renders DataTable with user rows and set columns',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kAdmin, _kBob]
+ ..setsResult = [_kSetA, _kSetB]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('admin_perms_table')), findsOneWidget);
+ // User names visible in the table.
+ expect(find.text('alice'), findsOneWidget);
+ expect(find.text('bob'), findsOneWidget);
+ // Set names visible as column headers.
+ expect(find.text('Movies'), findsOneWidget);
+ expect(find.text('Music'), findsOneWidget);
+ });
+
+ testWidgets('checkbox checked when permission exists', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kAdmin, _kBob]
+ ..setsResult = [_kSetA]
+ ..permsResult = _permsWithGrant(userId: 2, setId: 10);
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // bob (id=2) has permission on setA (id=10) — checkbox should be checked.
+ final checkbox = tester.widget<Checkbox>(
+ find.byKey(const Key('perm_2_10')),
+ );
+ expect(checkbox.value, isTrue);
+ });
+
+ testWidgets('checkbox unchecked when no permission exists', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kAdmin, _kBob]
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // bob (id=2) has no permission on setA (id=10) — checkbox should be unchecked.
+ final checkbox = tester.widget<Checkbox>(
+ find.byKey(const Key('perm_2_10')),
+ );
+ expect(checkbox.value, isFalse);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Checking a checkbox (grant)
+ // --------------------------------------------------------------------------
+
+ group('grant permission', () {
+ testWidgets('checking a checkbox calls grantPermission', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kBob]
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // bob has no permission on setA; tap the checkbox to grant.
+ await tester.tap(find.byKey(const Key('perm_2_10')));
+ await tester.pumpAndSettle();
+
+ expect(fakeClient.grantCallCount, equals(1));
+ expect(fakeClient.grantedUserId, equals(2));
+ expect(fakeClient.grantedSetId, equals(10));
+ });
+
+ testWidgets('optimistic grant reverts on error and shows SnackBar',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kBob]
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms()
+ ..grantError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/admin/permissions'),
+ response: Response(
+ requestOptions: RequestOptions(path: '/api/v1/admin/permissions'),
+ statusCode: 403,
+ ),
+ type: DioExceptionType.badResponse,
+ );
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Tap the unchecked checkbox — the optimistic update will check it,
+ // then revert it when the API returns 403.
+ await tester.tap(find.byKey(const Key('perm_2_10')));
+ await tester.pumpAndSettle();
+
+ // After error: checkbox should be reverted to unchecked.
+ final checkbox = tester.widget<Checkbox>(
+ find.byKey(const Key('perm_2_10')),
+ );
+ expect(checkbox.value, isFalse);
+
+ // Error SnackBar shown.
+ expect(
+ find.byKey(const Key('admin_perms_error_snackbar')),
+ findsOneWidget,
+ );
+ expect(find.textContaining('permission'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Unchecking a checkbox (revoke)
+ // --------------------------------------------------------------------------
+
+ group('revoke permission', () {
+ testWidgets('unchecking a checkbox calls revokePermission', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kBob]
+ ..setsResult = [_kSetA]
+ ..permsResult = _permsWithGrant(userId: 2, setId: 10);
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // bob has permission on setA; tap the checkbox to revoke.
+ await tester.tap(find.byKey(const Key('perm_2_10')));
+ await tester.pumpAndSettle();
+
+ expect(fakeClient.revokeCallCount, equals(1));
+ expect(fakeClient.revokedUserId, equals(2));
+ expect(fakeClient.revokedSetId, equals(10));
+ });
+
+ testWidgets('optimistic revoke reverts on error and shows SnackBar',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kBob]
+ ..setsResult = [_kSetA]
+ ..permsResult = _permsWithGrant(userId: 2, setId: 10)
+ ..revokeError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/admin/permissions'),
+ response: Response(
+ requestOptions: RequestOptions(path: '/api/v1/admin/permissions'),
+ statusCode: 404,
+ ),
+ type: DioExceptionType.badResponse,
+ );
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Tap the checked checkbox to revoke — the optimistic update unchecks it,
+ // then reverts it when the API returns 404.
+ await tester.tap(find.byKey(const Key('perm_2_10')));
+ await tester.pumpAndSettle();
+
+ // After error: checkbox should be reverted to checked.
+ final checkbox = tester.widget<Checkbox>(
+ find.byKey(const Key('perm_2_10')),
+ );
+ expect(checkbox.value, isTrue);
+
+ // Error SnackBar shown.
+ expect(
+ find.byKey(const Key('admin_perms_error_snackbar')),
+ findsOneWidget,
+ );
+ expect(find.textContaining('not found'), findsOneWidget);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Admin rows are disabled
+ // --------------------------------------------------------------------------
+
+ group('admin rows', () {
+ testWidgets('admin user checkbox has null onChanged (disabled)',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kAdmin]
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Admin checkbox for setA — should be disabled.
+ final checkbox = tester.widget<Checkbox>(
+ find.byKey(const Key('perm_1_10')),
+ );
+ expect(checkbox.onChanged, isNull);
+ });
+
+ testWidgets('admin user checkbox is always checked regardless of perms data',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kAdmin]
+ ..setsResult = [_kSetA]
+ // Admins have no explicit permission row — but should show as checked.
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ final checkbox = tester.widget<Checkbox>(
+ find.byKey(const Key('perm_1_10')),
+ );
+ // Admins always have access; the UI reflects this with a checked, disabled cell.
+ expect(checkbox.value, isTrue);
+ });
+
+ testWidgets('tapping admin checkbox does NOT call grantPermission',
+ (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kAdmin]
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ // Tapping a disabled Checkbox widget is a no-op — the tap should not
+ // propagate to grantPermission.
+ await tester.tap(find.byKey(const Key('perm_1_10')));
+ await tester.pumpAndSettle();
+
+ expect(fakeClient.grantCallCount, equals(0));
+ expect(fakeClient.revokeCallCount, equals(0));
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Empty state
+ // --------------------------------------------------------------------------
+
+ group('empty state', () {
+ testWidgets('shows empty-state when listUsers returns []', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = []
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('admin_perms_empty')), findsOneWidget);
+ expect(find.byKey(const Key('admin_perms_table')), findsNothing);
+ });
+
+ testWidgets('shows empty-state when listSets returns []', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersResult = [_kBob]
+ ..setsResult = []
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('admin_perms_empty')), findsOneWidget);
+ expect(find.byKey(const Key('admin_perms_table')), findsNothing);
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Error state
+ // --------------------------------------------------------------------------
+
+ group('error state', () {
+ testWidgets('shows error when any parallel load throws', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/admin/users'),
+ type: DioExceptionType.connectionError,
+ )
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('admin_perms_error')), findsOneWidget);
+ expect(find.byKey(const Key('admin_perms_table')), findsNothing);
+ expect(find.textContaining('Could not reach the server'), findsOneWidget);
+ });
+
+ testWidgets('retry button re-calls load after an error', (tester) async {
+ final fakeClient = _FakeApiClient()
+ ..usersError = DioException(
+ requestOptions: RequestOptions(path: '/api/v1/admin/users'),
+ type: DioExceptionType.connectionError,
+ )
+ ..setsResult = [_kSetA]
+ ..permsResult = _emptyPerms();
+
+ await _pumpPermsScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('admin_perms_retry')), findsOneWidget);
+
+ // Fix the error so the retry succeeds.
+ fakeClient
+ ..usersError = null
+ ..usersResult = [_kBob];
+
+ await tester.tap(find.byKey(const Key('admin_perms_retry')));
+ await tester.pumpAndSettle();
+
+ // After successful retry the matrix is visible.
+ expect(find.byKey(const Key('admin_perms_table')), findsOneWidget);
+ });
+ });
+}
diff --git a/player-android/test/screens/admin_rescan_screen_test.dart b/player-android/test/screens/admin_rescan_screen_test.dart
new file mode 100644
index 0000000..51b89a7
--- /dev/null
+++ b/player-android/test/screens/admin_rescan_screen_test.dart
@@ -0,0 +1,376 @@
+// Widget tests for AdminRescanScreen (admin_rescan_screen.dart).
+//
+// Tests cover:
+// 1. Trigger button visible and enabled when not scanning.
+// 2. Tapping trigger calls triggerRescan and shows scanning state.
+// 3. Poll timer fires and updates status.
+// 4. "Scan complete" shown when done.
+// 5. Error state shown when getScanProgress throws.
+// 6. Loading spinner shown before first status fetch completes.
+//
+// Riverpod providers are overridden with fakes so tests run without a real
+// server or OS keychain.
+//
+// Run with: flutter test test/screens/admin_rescan_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/providers/api_client_provider.dart';
+import 'package:player_android/screens/admin_rescan_screen.dart';
+
+// ---------------------------------------------------------------------------
+// Fakes
+// ---------------------------------------------------------------------------
+
+/// In-memory [TokenStorage] that returns a fixed username without hitting
+/// the OS keychain.
+class _FakeTokenStorage implements TokenStorage {
+ @override
+ Future<String?> readToken() async => 'admin';
+
+ @override
+ Future<void> writeToken(String token) async {}
+
+ @override
+ Future<void> deleteToken() async {}
+}
+
+/// Controllable [PlayerApiClient] stub for [AdminRescanScreen] tests.
+///
+/// [getScanProgress] and [triggerRescan] 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')));
+
+ // ---- getScanProgress ----
+
+ /// When non-null, [getScanProgress] returns this map.
+ Map<String, dynamic>? progressResult;
+
+ /// When non-null, [getScanProgress] throws this instead.
+ Object? progressError;
+
+ /// Number of times [getScanProgress] has been called.
+ int getScanProgressCallCount = 0;
+
+ @override
+ Future<Map<String, dynamic>> getScanProgress() async {
+ getScanProgressCallCount++;
+ if (progressError != null) throw progressError!;
+ return progressResult!;
+ }
+
+ // ---- triggerRescan ----
+
+ /// When non-null, [triggerRescan] throws this instead of returning.
+ Object? triggerError;
+
+ /// Number of times [triggerRescan] has been called.
+ int triggerRescanCallCount = 0;
+
+ @override
+ Future<void> triggerRescan() async {
+ triggerRescanCallCount++;
+ if (triggerError != null) throw triggerError!;
+ }
+}
+
+/// [PlayerApiClient] stub whose [getScanProgress] is controlled by an external
+/// [Completer] — lets tests inspect the loading state before the fetch resolves.
+class _DelayedFakeApiClient extends PlayerApiClient {
+ _DelayedFakeApiClient() : super(dio: Dio());
+
+ final _completer = Completer<Map<String, dynamic>>();
+
+ /// Resolves the pending [getScanProgress] with [result].
+ void complete(Map<String, dynamic> result) => _completer.complete(result);
+
+ @override
+ Future<Map<String, dynamic>> getScanProgress() => _completer.future;
+}
+
+// ---------------------------------------------------------------------------
+// Sample data
+// ---------------------------------------------------------------------------
+
+/// Progress map representing an idle scanner (no scan running, nothing scanned).
+const _kIdleProgress = <String, dynamic>{
+ 'running': false,
+ 'current_set': '',
+ 'sets_total': 0,
+ 'sets_done': 0,
+ 'files_total': 0,
+ 'files_done': 0,
+};
+
+/// Progress map representing an active scan in progress.
+const _kRunningProgress = <String, dynamic>{
+ 'running': true,
+ 'current_set': 'Music',
+ 'sets_total': 3,
+ 'sets_done': 1,
+ 'files_total': 500,
+ 'files_done': 200,
+};
+
+/// Progress map representing a completed scan (not running, files > 0).
+const _kCompleteProgress = <String, dynamic>{
+ 'running': false,
+ 'current_set': '',
+ 'sets_total': 3,
+ 'sets_done': 3,
+ 'files_total': 500,
+ 'files_done': 500,
+};
+
+// ---------------------------------------------------------------------------
+// Helper: pump AdminRescanScreen inside a minimal ProviderScope.
+// ---------------------------------------------------------------------------
+
+/// Pumps [AdminRescanScreen] with a [ProviderScope] that overrides
+/// [apiClientProvider] with [fakeClient] and [tokenStorageProvider] with an
+/// in-memory fake. Using [MaterialApp] is sufficient because the screen does
+/// not navigate away — it only shows SnackBars and updates its own state.
+Future<void> _pumpRescanScreen(
+ WidgetTester tester,
+ PlayerApiClient fakeClient,
+) async {
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ tokenStorageProvider.overrideWithValue(_FakeTokenStorage()),
+ apiClientProvider.overrideWithValue(fakeClient),
+ ],
+ child: const MaterialApp(home: AdminRescanScreen()),
+ ),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+void main() {
+ // --------------------------------------------------------------------------
+ // Loading state
+ // --------------------------------------------------------------------------
+
+ group('loading state', () {
+ testWidgets('shows loading spinner before first status fetch completes',
+ (tester) async {
+ final fakeClient = _DelayedFakeApiClient();
+
+ await _pumpRescanScreen(tester, fakeClient);
+ // One pump so addPostFrameCallback fires but Future has not resolved.
+ await tester.pump();
+
+ expect(
+ find.byKey(const Key('admin_rescan_status_loading')),
+ findsOneWidget,
+ );
+
+ // Resolve to avoid dangling-async warnings.
+ fakeClient.complete(_kIdleProgress);
+ await tester.pumpAndSettle();
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Idle state
+ // --------------------------------------------------------------------------
+
+ group('idle state', () {
+ testWidgets('trigger button is visible and enabled when not scanning',
+ (tester) async {
+ final fakeClient = _FakeApiClient()..progressResult = _kIdleProgress;
+
+ await _pumpRescanScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(find.byKey(const Key('admin_rescan_trigger')), findsOneWidget);
+
+ // The trigger button key is on a FilledButton.icon widget — locate the
+ // FilledButton by key directly using its widget type.
+ final btn = tester.widgetList<FilledButton>(find.byType(FilledButton)).first;
+ expect(btn.onPressed, isNotNull);
+ });
+
+ testWidgets('shows idle label when no scan has run', (tester) async {
+ final fakeClient = _FakeApiClient()..progressResult = _kIdleProgress;
+
+ await _pumpRescanScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ expect(
+ find.byKey(const Key('admin_rescan_idle_label')),
+ findsOneWidget,
+ );
+ });
+ });
+
+ // --------------------------------------------------------------------------
+ // Trigger action
+ // --------------------------------------------------------------------------
+
+ group('trigger action', () {
+ testWidgets('tapping trigger calls triggerRescan', (tester) async {
+ // Use idle progress so no polling timer is started — pumpAndSettle is
+ // safe when there is no active periodic timer.
+ final fakeClient = _FakeApiClient()..progressResult = _kIdleProgress;
+
+ await _pumpRescanScreen(tester, fakeClient);
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const Key('admin_rescan_trigger')));
+ await tester.pumpAndSettle();
+
+ expect(fakeClient.triggerRescanCallCount, equals(1));
+ });
+
+ testWidgets('shows scanning state after trigger when scan is running',
+ (tester) async {
+ // After tri