From 19f535df56ba17760bc81351c91fc7b8bb568035 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 22 May 2026 16:52:25 +0300 Subject: Fix review issues and add widget tests for admin screens (eb) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix typo _isTriggerring → _isTriggering in AdminRescanScreen - Replace index-based removeAt with identity-based removeWhere in AdminTrashScreen - Add .cast() 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 --- .../lib/screens/admin_permissions_screen.dart | 8 +- .../lib/screens/admin_rescan_screen.dart | 20 +- player-android/lib/screens/admin_trash_screen.dart | 11 +- player-android/lib/utils/error_mappers.dart | 2 +- .../screens/admin_permissions_screen_test.dart | 566 +++++++++++++++++++++ .../test/screens/admin_rescan_screen_test.dart | 376 ++++++++++++++ .../test/screens/admin_trash_screen_test.dart | 456 +++++++++++++++++ 7 files changed, 1422 insertions(+), 17 deletions(-) create mode 100644 player-android/test/screens/admin_permissions_screen_test.dart create mode 100644 player-android/test/screens/admin_rescan_screen_test.dart create mode 100644 player-android/test/screens/admin_trash_screen_test.dart 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; - final sets = results[1] as List; + // Use .cast() 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(); + final sets = (results[1] as List).cast(); final permsData = results[2] as Map; 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 { 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 { /// Sends a trigger-rescan request and immediately begins polling for progress. Future _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 { 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 { /// problem. Re-appending (rather than re-inserting at the original index) /// avoids position jitter from concurrent mutations. Future _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 { 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 { ], ), ); + // 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 readToken() async => 'admin'; + + @override + Future writeToken(String token) async {} + + @override + Future 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? usersResult; + Object? usersError; + + @override + Future> listUsers() async { + if (usersError != null) throw usersError!; + return usersResult!; + } + + // ---- listSets ---- + + List? setsResult; + Object? setsError; + + @override + Future> listSets() async { + if (setsError != null) throw setsError!; + return setsResult!; + } + + // ---- listPermissions ---- + + Map? permsResult; + Object? permsError; + + @override + Future> listPermissions() async { + if (permsError != null) throw permsError!; + return permsResult!; + } + + // ---- grantPermission ---- + + Object? grantError; + int? grantedUserId; + int? grantedSetId; + int grantCallCount = 0; + + @override + Future 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 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>(); + final _setsCompleter = Completer>(); + final _permsCompleter = Completer>(); + + void complete({ + required List users, + required List sets, + required Map perms, + }) { + _usersCompleter.complete(users); + _setsCompleter.complete(sets); + _permsCompleter.complete(perms); + } + + @override + Future> listUsers() => _usersCompleter.future; + + @override + Future> listSets() => _setsCompleter.future; + + @override + Future> 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 _emptyPerms() => {'permissions': []}; + +/// Builds a permission-matrix response with a single (userId, setId) pair. +Map _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 _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( + 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( + 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( + 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( + 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( + 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( + 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 readToken() async => 'admin'; + + @override + Future writeToken(String token) async {} + + @override + Future 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? progressResult; + + /// When non-null, [getScanProgress] throws this instead. + Object? progressError; + + /// Number of times [getScanProgress] has been called. + int getScanProgressCallCount = 0; + + @override + Future> 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 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>(); + + /// Resolves the pending [getScanProgress] with [result]. + void complete(Map result) => _completer.complete(result); + + @override + Future> getScanProgress() => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// Progress map representing an idle scanner (no scan running, nothing scanned). +const _kIdleProgress = { + '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 = { + '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 = { + '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 _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(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(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.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_trash_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that returns a fixed username without hitting +/// the OS keychain. +class _FakeTokenStorage implements TokenStorage { + @override + Future readToken() async => 'admin'; + + @override + Future writeToken(String token) async {} + + @override + Future deleteToken() async {} +} + +/// Controllable [PlayerApiClient] stub for [AdminTrashScreen] tests. +/// +/// [listTrash], [restoreMedia], and [deleteMedia] 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'))); + + // ---- listTrash ---- + + /// When non-null, [listTrash] returns this list. + List? trashResult; + + /// When non-null, [listTrash] throws this instead of returning. + Object? trashError; + + /// Number of times [listTrash] has been called. + int listTrashCallCount = 0; + + @override + Future> listTrash() async { + listTrashCallCount++; + if (trashError != null) throw trashError!; + return trashResult!; + } + + // ---- restoreMedia ---- + + /// When non-null, [restoreMedia] throws this instead of returning. + Object? restoreError; + + /// The id passed to the last [restoreMedia] call. + int? restoredMediaId; + + /// Number of times [restoreMedia] has been called. + int restoreCallCount = 0; + + @override + Future restoreMedia(int mediaId) async { + restoredMediaId = mediaId; + restoreCallCount++; + if (restoreError != null) throw restoreError!; + } + + // ---- deleteMedia ---- + + /// When non-null, [deleteMedia] throws this instead of returning. + Object? deleteError; + + /// The id passed to the last [deleteMedia] call. + int? deletedMediaId; + + /// Number of times [deleteMedia] has been called. + int deleteCallCount = 0; + + @override + Future deleteMedia(int mediaId) async { + deletedMediaId = mediaId; + deleteCallCount++; + if (deleteError != null) throw deleteError!; + } +} + +/// [PlayerApiClient] stub whose [listTrash] 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>(); + + /// Resolves the pending [listTrash] with [items]. + void complete(List items) => _completer.complete(items); + + @override + Future> listTrash() => _completer.future; +} + +// --------------------------------------------------------------------------- +// Sample data +// --------------------------------------------------------------------------- + +/// Builds a minimal [Media] suitable for trash-screen tests. +/// +/// Only the fields that the trash screen reads are populated; all other fields +/// use sensible zero values. +Media _makeMedia({required int id, required String fileName, String type = 'video'}) { + return Media( + id: id, + setId: 1, + relPath: 'path/$fileName', + fileName: fileName, + absPath: '/media/$fileName', + type: type, + duration: 120.0, + codec: 'h264', + resolution: '1920x1080', + bitrate: 3000, + fileSizeBytes: 50000000, + width: 1920, + height: 1080, + thumbnailPath: '', + playCount: 0, + ); +} + +final _kVideoA = _makeMedia(id: 1, fileName: 'video_a.mp4'); +final _kVideoB = _makeMedia(id: 2, fileName: 'video_b.mp4'); +final _kAudioC = _makeMedia(id: 3, fileName: 'audio_c.mp3', type: 'audio'); + +// --------------------------------------------------------------------------- +// Helper: pump AdminTrashScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [AdminTrashScreen] 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 dialogs and SnackBars. +Future _pumpTrashScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(_FakeTokenStorage()), + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp(home: AdminTrashScreen()), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('shows loading spinner while listTrash is in flight', + (tester) async { + final fakeClient = _DelayedFakeApiClient(); + + await _pumpTrashScreen(tester, fakeClient); + // One pump so addPostFrameCallback fires but Future has not resolved. + await tester.pump(); + + expect(find.byKey(const Key('admin_trash_loading')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Resolve to avoid dangling-async warnings. + fakeClient.complete([]); + await tester.pumpAndSettle(); + }); + }); + + // -------------------------------------------------------------------------- + // Renders trash list + // -------------------------------------------------------------------------- + + group('renders trash list', () { + testWidgets('shows a tile for each item returned by listTrash', + (tester) async { + final fakeClient = _FakeApiClient() + ..trashResult = [_kVideoA, _kVideoB, _kAudioC]; + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_trash_list')), findsOneWidget); + expect(find.byKey(const Key('admin_trash_tile_1')), findsOneWidget); + expect(find.byKey(const Key('admin_trash_tile_2')), findsOneWidget); + expect(find.byKey(const Key('admin_trash_tile_3')), findsOneWidget); + expect(find.text('video_a.mp4'), findsOneWidget); + expect(find.text('video_b.mp4'), findsOneWidget); + expect(find.text('audio_c.mp3'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Restore action + // -------------------------------------------------------------------------- + + group('restore action', () { + testWidgets('restore calls restoreMedia and removes item from list', + (tester) async { + final fakeClient = _FakeApiClient() + ..trashResult = [_kVideoA, _kVideoB]; + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Tap restore on video_a (id=1). + await tester.tap(find.byKey(const Key('admin_trash_restore_1'))); + await tester.pumpAndSettle(); + + // restoreMedia should have been called with the correct id. + expect(fakeClient.restoredMediaId, equals(1)); + expect(fakeClient.restoreCallCount, equals(1)); + + // The restored item should no longer appear in the list. + expect(find.byKey(const Key('admin_trash_tile_1')), findsNothing); + // The other item should still be present. + expect(find.byKey(const Key('admin_trash_tile_2')), findsOneWidget); + }); + + testWidgets('restore failure re-adds item and shows error SnackBar', + (tester) async { + final fakeClient = _FakeApiClient() + ..trashResult = [_kVideoA, _kVideoB] + ..restoreError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/media/1/restore'), + type: DioExceptionType.connectionError, + ); + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Tap restore on video_a (id=1); the API will fail. + await tester.tap(find.byKey(const Key('admin_trash_restore_1'))); + await tester.pumpAndSettle(); + + // Item should be re-appended (reverted) after the failure. + expect(find.byKey(const Key('admin_trash_tile_1')), findsOneWidget); + + // Error SnackBar should be visible. + expect( + find.byKey(const Key('admin_trash_error_snackbar')), + findsOneWidget, + ); + expect(find.textContaining('Could not reach the server'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Hard-delete action + // -------------------------------------------------------------------------- + + group('hard-delete action', () { + testWidgets('tapping hard-delete shows confirmation dialog', (tester) async { + final fakeClient = _FakeApiClient()..trashResult = [_kVideoA]; + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_trash_delete_1'))); + await tester.pumpAndSettle(); + + // Confirmation dialog should be visible. + expect(find.text('Permanently delete?'), findsOneWidget); + // Both action buttons should be present. + expect(find.byKey(const Key('admin_trash_confirm_cancel')), findsOneWidget); + expect(find.byKey(const Key('admin_trash_confirm_delete')), findsOneWidget); + }); + + testWidgets('cancelling confirmation does NOT call deleteMedia', + (tester) async { + final fakeClient = _FakeApiClient()..trashResult = [_kVideoA]; + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_trash_delete_1'))); + await tester.pumpAndSettle(); + + // Cancel the dialog. + await tester.tap(find.byKey(const Key('admin_trash_confirm_cancel'))); + await tester.pumpAndSettle(); + + // Dialog dismissed; deleteMedia was never called. + expect(find.text('Permanently delete?'), findsNothing); + expect(fakeClient.deleteCallCount, equals(0)); + expect(fakeClient.deletedMediaId, isNull); + + // Item is still in the list. + expect(find.byKey(const Key('admin_trash_tile_1')), findsOneWidget); + }); + + testWidgets('confirming hard-delete calls deleteMedia and removes item', + (tester) async { + final fakeClient = _FakeApiClient()..trashResult = [_kVideoA, _kVideoB]; + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + // Hard-delete video_a (id=1). + await tester.tap(find.byKey(const Key('admin_trash_delete_1'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_trash_confirm_delete'))); + await tester.pumpAndSettle(); + + // deleteMedia called with the correct id. + expect(fakeClient.deletedMediaId, equals(1)); + expect(fakeClient.deleteCallCount, equals(1)); + + // video_a removed from the list. + expect(find.byKey(const Key('admin_trash_tile_1')), findsNothing); + // video_b still present. + expect(find.byKey(const Key('admin_trash_tile_2')), findsOneWidget); + + // Success SnackBar shown. + expect( + find.byKey(const Key('admin_trash_delete_snackbar')), + findsOneWidget, + ); + }); + + testWidgets('hard-delete failure re-adds item and shows error SnackBar', + (tester) async { + final fakeClient = _FakeApiClient() + ..trashResult = [_kVideoA] + ..deleteError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/media/1'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/admin/media/1'), + statusCode: 403, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_trash_delete_1'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('admin_trash_confirm_delete'))); + await tester.pumpAndSettle(); + + // Item re-appended after error. + expect(find.byKey(const Key('admin_trash_tile_1')), findsOneWidget); + + // Error SnackBar shown. + expect( + find.byKey(const Key('admin_trash_error_snackbar')), + findsOneWidget, + ); + expect(find.textContaining('permission'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Empty state + // -------------------------------------------------------------------------- + + group('empty state', () { + testWidgets('shows empty-state widget when listTrash returns []', + (tester) async { + final fakeClient = _FakeApiClient()..trashResult = []; + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_trash_empty')), findsOneWidget); + expect(find.byKey(const Key('admin_trash_list')), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error state + // -------------------------------------------------------------------------- + + group('error state', () { + testWidgets('shows error message when listTrash throws a network error', + (tester) async { + final fakeClient = _FakeApiClient() + ..trashError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/trash'), + type: DioExceptionType.connectionError, + ); + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_trash_error')), findsOneWidget); + expect(find.byKey(const Key('admin_trash_list')), findsNothing); + expect(find.textContaining('Could not reach the server'), findsOneWidget); + }); + + testWidgets('retry button re-calls listTrash after an error', (tester) async { + final fakeClient = _FakeApiClient() + ..trashError = DioException( + requestOptions: RequestOptions(path: '/api/v1/admin/trash'), + type: DioExceptionType.connectionError, + ); + + await _pumpTrashScreen(tester, fakeClient); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('admin_trash_retry')), findsOneWidget); + + // Fix the error so the retry succeeds. + fakeClient + ..trashError = null + ..trashResult = [_kVideoA]; + + await tester.tap(find.byKey(const Key('admin_trash_retry'))); + await tester.pumpAndSettle(); + + // List visible after successful retry. + expect(find.byKey(const Key('admin_trash_list')), findsOneWidget); + // listTrash called twice: once on init, once on retry. + expect(fakeClient.listTrashCallCount, equals(2)); + }); + }); +} -- cgit v1.2.3