diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-22 16:52:25 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-22 16:52:25 +0300 |
| commit | 19f535df56ba17760bc81351c91fc7b8bb568035 (patch) | |
| tree | e3889b8e831aab5cd080c8eb7772d087bf138cb0 /player-android/test/screens | |
| parent | d15b31ec2248846af2adede429e7278e374cb4c3 (diff) | |
Fix review issues and add widget tests for admin screens (eb)
- Fix typo _isTriggerring → _isTriggering in AdminRescanScreen
- Replace index-based removeAt with identity-based removeWhere in AdminTrashScreen
- Add .cast<T>() for list results from Future.wait in AdminPermissionsScreen
- Add tap-outside safety comment in _confirmHardDelete
- Fix broken dartdoc reference [RescanScreen] → [AdminRescanScreen]
- Add widget tests for AdminRescanScreen, AdminTrashScreen, AdminPermissionsScreen
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android/test/screens')
3 files changed, 1398 insertions, 0 deletions
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 trigger+fetch, the screen sees a running scan and starts a polling + // timer. Use pump() with an explicit duration instead of pumpAndSettle() + // so the test does not time out waiting for the periodic timer to stop. + final fakeClient = _FakeApiClient()..progressResult = _kRunningProgress; + + await _pumpRescanScreen(tester, fakeClient); + // Allow initState fetch to complete. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + await tester.tap(find.byKey(const Key('admin_rescan_trigger'))); + // Allow trigger + post-trigger fetch to complete. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Running label should be visible. + expect( + find.byKey(const Key('admin_rescan_running_label')), + findsOneWidget, + ); + }); + + testWidgets('trigger button is disabled while scan is running', + (tester) async { + // Same approach: after trigger+fetch the scan is running and the polling + // timer is active, so we avoid pumpAndSettle. + final fakeClient = _FakeApiClient()..progressResult = _kRunningProgress; + + await _pumpRescanScreen(tester, fakeClient); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Trigger the scan. + await tester.tap(find.byKey(const Key('admin_rescan_trigger'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + // Button should now be disabled (scan is running). + final btn = tester.widgetList<FilledButton>(find.byType(FilledButton)).first; + expect(btn.onPressed, isNull); + }); + + testWidgets('shows error snackbar when triggerRescan throws', (tester) async { + final fakeClient = _FakeApiClient() + ..progressResult = _kIdleProgress + ..triggerError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/scan'), + type: DioExceptionType.connectionError, + ); + + await _pumpRescanScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_rescan_trigger'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_rescan_error')), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Poll timer + // -------------------------------------------------------------------------- + + group('poll timer', () { + testWidgets('poll timer fires and updates status from running to complete', + (tester) async { + // Start with a running scan so the polling timer begins. + final fakeClient = _FakeApiClient() + ..progressResult = _kRunningProgress; + + await _pumpRescanScreen(tester, fakeClient); + // Allow the initial fetch to complete without pumpAndSettle (the timer + // is now active and would cause pumpAndSettle to spin forever). + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final callCountAfterInit = fakeClient.getScanProgressCallCount; + expect(find.byKey(const Key('admin_rescan_running_label')), findsOneWidget); + + // Switch the fake to return "complete" so the next poll stops the timer. + fakeClient.progressResult = _kCompleteProgress; + + // Advance time past the 2-second poll interval so the timer fires. + await tester.pump(const Duration(seconds: 2)); + // Allow the polled fetch to settle (timer is now cancelled because scan + // is no longer running, so pumpAndSettle is safe again). + await tester.pumpAndSettle(); + + // At least one more poll occurred. + expect( + fakeClient.getScanProgressCallCount, + greaterThan(callCountAfterInit), + ); + + // Status card now shows "Scan complete". + expect(find.byKey(const Key('admin_rescan_idle_label')), findsOneWidget); + expect(find.textContaining('Scan complete'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Scan complete state + // -------------------------------------------------------------------------- + + group('scan complete state', () { + testWidgets('shows Scan complete label when files have been scanned', + (tester) async { + final fakeClient = _FakeApiClient() + ..progressResult = _kCompleteProgress; + + await _pumpRescanScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_rescan_idle_label')), findsOneWidget); + expect(find.textContaining('Scan complete'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when getScanProgress throws', (tester) async { + final fakeClient = _FakeApiClient() + ..progressError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/scan-progress'), + type: DioExceptionType.connectionError, + ); + + await _pumpRescanScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_rescan_error')), findsOneWidget); + expect(find.textContaining('Could not reach the server'), findsOneWidget); + }); + }); +} diff --git a/player-android/test/screens/admin_trash_screen_test.dart b/player-android/test/screens/admin_trash_screen_test.dart new file mode 100644 index 0000000..20d1392 --- /dev/null +++ b/player-android/test/screens/admin_trash_screen_test.dart @@ -0,0 +1,456 @@ +// Widget tests for AdminTrashScreen (admin_trash_screen.dart). +// +// Tests cover: +// 1. Trash list renders items from listTrash. +// 2. Restore action calls restoreMedia and removes item from the list. +// 3. Restore failure re-adds item (optimistic revert) and shows SnackBar. +// 4. Hard-delete shows a confirmation dialog before acting. +// 5. Confirming hard-delete calls deleteMedia and removes item from the list. +// 6. Cancelling confirmation does NOT call deleteMedia. +// 7. Loading spinner shown before first list fetch completes. +// 8. Empty-state shown when listTrash returns []. +// 9. Error-state shown when listTrash throws. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/admin_trash_screen_test.dart + +import 'dart:async'; + +import 'package:dio/dio.da |
