diff options
| -rw-r--r-- | player-android/lib/api/dio_player_api_client.dart | 13 | ||||
| -rw-r--r-- | player-android/lib/api/player_api_client.dart | 6 | ||||
| -rw-r--r-- | player-android/lib/providers/first_run_provider.dart | 33 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 45 | ||||
| -rw-r--r-- | player-android/lib/screens/login_screen.dart | 232 | ||||
| -rw-r--r-- | player-android/test/screens/login_screen_test.dart | 380 | ||||
| -rw-r--r-- | player-android/test/widget_smoke_test.dart | 81 | ||||
| -rw-r--r-- | player-server/internal/api/handlers_auth.go | 18 | ||||
| -rw-r--r-- | player-server/internal/api/handlers_test.go | 62 | ||||
| -rw-r--r-- | player-server/internal/api/server.go | 3 |
10 files changed, 827 insertions, 46 deletions
diff --git a/player-android/lib/api/dio_player_api_client.dart b/player-android/lib/api/dio_player_api_client.dart index 6de1954..d9b24ed 100644 --- a/player-android/lib/api/dio_player_api_client.dart +++ b/player-android/lib/api/dio_player_api_client.dart @@ -75,6 +75,19 @@ class DioPlayerApiClient extends PlayerApiClient { // Health // --------------------------------------------------------------------------- + /// Returns the total number of registered users from the server. + /// + /// GET /api/v1/auth/count — public endpoint; no session required. + /// Mobile clients call this on startup to detect first-run (count == 0) + /// and redirect to /bootstrap instead of /login. + @override + Future<int> countUsers() async { + final response = await rawDio.get<Map<String, dynamic>>( + '$_kApiV1/auth/count', + ); + return (response.data?['count'] as int?) ?? 0; + } + /// Liveness probe — returns immediately without touching the database. /// /// GET /healthz — 200 means the server process is alive. diff --git a/player-android/lib/api/player_api_client.dart b/player-android/lib/api/player_api_client.dart index 9e987c2..13b5ead 100644 --- a/player-android/lib/api/player_api_client.dart +++ b/player-android/lib/api/player_api_client.dart @@ -51,6 +51,12 @@ class PlayerApiClient { Future<void> healthz() => throw UnimplementedError(); Future<void> readyz() => throw UnimplementedError(); + /// Returns the total number of registered users. + /// + /// Clients use this to detect first-run (count == 0) and redirect to the + /// bootstrap screen instead of the login screen. + Future<int> countUsers() => throw UnimplementedError(); + // --------------------------------------------------------------------------- // Shared / public endpoints (no auth required) // --------------------------------------------------------------------------- diff --git a/player-android/lib/providers/first_run_provider.dart b/player-android/lib/providers/first_run_provider.dart new file mode 100644 index 0000000..4f1c6f5 --- /dev/null +++ b/player-android/lib/providers/first_run_provider.dart @@ -0,0 +1,33 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'api_client_provider.dart'; + +/// Returns whether the server has no registered users yet (first-run state). +/// +/// The go_router redirect callback reads this provider to decide whether +/// an unauthenticated visit should go to /login (normal case) or /bootstrap +/// (no accounts exist). +/// +/// Implementation notes: +/// - Uses [FutureProvider] so the loading/error states are handled uniformly +/// alongside [authStateProvider] in the router's redirect callback. +/// - [FutureProvider] caches its result for the lifetime of the enclosing +/// [ProviderScope]; the value is re-fetched only when the provider is +/// explicitly invalidated (e.g. via `ref.invalidate`) or the scope is +/// recreated. After bootstrap completes the app navigates away and the +/// cached value is simply never re-read in the same session. +/// - Errors (e.g. server unreachable) are treated as non-first-run so the +/// login screen is shown and the user can retry; this avoids looping to +/// /bootstrap on connectivity failures. +final firstRunProvider = FutureProvider<bool>((ref) async { + final apiClient = ref.read(apiClientProvider); + try { + final count = await apiClient.countUsers(); + return count == 0; + } catch (_) { + // Network or server error: assume not first-run so we show login. + // The login screen will surface the connectivity problem when the user + // attempts to authenticate. + return false; + } +}); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index a54f4f0..4c78259 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'app_routes.dart'; import 'navigation_key.dart'; import 'providers/auth_state_provider.dart'; +import 'providers/first_run_provider.dart'; import 'screens/bootstrap_screen.dart'; import 'screens/home_screen.dart'; import 'screens/login_screen.dart'; @@ -54,16 +55,27 @@ final routerProvider = Provider<GoRouter>((ref) { // Bootstrap is a public route (user is unauthenticated by definition). final isBootstrapRoute = location == AppRoutes.bootstrap; - if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { - // Guard every authenticated route: bounce to login. - return AppRoutes.login; - } - if (auth.isAuthenticated && (isLoginRoute || isBootstrapRoute)) { // Prevent already-authenticated users from viewing auth/setup screens. return AppRoutes.home; } + if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { + // Unauthenticated: determine whether this is first-run (no users) or + // a normal returning-user scenario. firstRunProvider returns true when + // the server reports count == 0 (no accounts exist yet). + // + // While the check is loading we stay put; the router re-evaluates when + // firstRunProvider's AsyncValue settles (via refreshListenable). + final firstRunAsync = ref.read(firstRunProvider); + if (firstRunAsync.isLoading) return null; + + // On first-run redirect to /bootstrap so the admin account can be set + // up; otherwise send to /login for normal credential entry. + final isFirstRun = firstRunAsync.valueOrNull ?? false; + return isFirstRun ? AppRoutes.bootstrap : AppRoutes.login; + } + // No redirect needed. return null; }, @@ -101,25 +113,34 @@ final routerProvider = Provider<GoRouter>((ref) { // Internal helpers // --------------------------------------------------------------------------- -/// Bridges Riverpod's [authStateProvider] to [GoRouter.refreshListenable]. +/// Bridges Riverpod's auth and first-run providers to [GoRouter.refreshListenable]. /// /// GoRouter expects a [ChangeNotifier] (or any [Listenable]) for its refresh -/// mechanism. This notifier listens to the provider and calls [notifyListeners] -/// on every change, causing the router to re-run its redirect callback. +/// mechanism. This notifier listens to both [authStateProvider] and +/// [firstRunProvider], calling [notifyListeners] on every change so the router +/// re-runs its redirect callback whenever auth state or first-run status settles. class _RouterRefreshNotifier extends ChangeNotifier { _RouterRefreshNotifier(Ref ref) { - // Keep a reference to the subscription so we can cancel it on dispose. - _subscription = ref.listen<AsyncValue<AuthState>>( + // Listen to auth state changes (login, logout, token expiry). + _authSubscription = ref.listen<AsyncValue<AuthState>>( authStateProvider, (_, __) => notifyListeners(), ); + // Listen to first-run state so the router re-evaluates after the initial + // user-count check resolves from loading to a concrete true/false value. + _firstRunSubscription = ref.listen<AsyncValue<bool>>( + firstRunProvider, + (_, __) => notifyListeners(), + ); } - late final ProviderSubscription<AsyncValue<AuthState>> _subscription; + late final ProviderSubscription<AsyncValue<AuthState>> _authSubscription; + late final ProviderSubscription<AsyncValue<bool>> _firstRunSubscription; @override void dispose() { - _subscription.close(); + _authSubscription.close(); + _firstRunSubscription.close(); super.dispose(); } } diff --git a/player-android/lib/screens/login_screen.dart b/player-android/lib/screens/login_screen.dart index 8a07877..bb5fce7 100644 --- a/player-android/lib/screens/login_screen.dart +++ b/player-android/lib/screens/login_screen.dart @@ -1,17 +1,237 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -/// Login screen — will host the credentials form once the auth API is wired. +import '../providers/api_client_provider.dart'; +import '../providers/auth_state_provider.dart'; + +/// Sign-in screen shown to returning users who already have an account. +/// +/// Displays a username and password form; on successful authentication the +/// session token is persisted via [TokenStorage] and the auth state transitions +/// to [AuthStatus.authenticated], which causes go_router to redirect to the +/// home screen automatically (no explicit navigation call needed). /// -/// Currently a lightweight placeholder; feature implementation will replace -/// the body without touching the router or other screens. -class LoginScreen extends StatelessWidget { +/// Design notes: +/// - Mirrors the structure of [BootstrapScreen] for consistency. +/// - All mutable state lives in [_LoginScreenState]. +/// - [ConsumerStatefulWidget] is used so [WidgetRef] is available across the +/// async submit path without storing a stale ref as an instance field. +/// - Error mapping is a top-level function ([_dioErrorMessage]) — pure, no +/// widget state, no BuildContext — so it is easy to unit-test in isolation. +class LoginScreen extends ConsumerStatefulWidget { const LoginScreen({super.key}); @override + ConsumerState<LoginScreen> createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState<LoginScreen> { + // Form key for programmatic validation across all fields. + final _formKey = GlobalKey<FormState>(); + + // Controllers for the two text fields; disposed in [dispose] to prevent + // memory leaks after the widget is removed from the tree. + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + + // Drives the loading indicator and disables the submit button while an + // HTTP request is in flight to prevent double-submission. + bool _isLoading = false; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Validation helpers + // --------------------------------------------------------------------------- + + /// Returns an error string when [value] is empty or null; null otherwise. + String? _validateRequired(String? value) { + if (value == null || value.trim().isEmpty) { + return 'This field is required.'; + } + return null; + } + + // --------------------------------------------------------------------------- + // Submit logic + // --------------------------------------------------------------------------- + + /// Validates the form and submits the login request to the server. + /// + /// On success, stores the username as the session token via [AuthStateNotifier.login] + /// so that [AuthStateNotifier.build] can restore the session on the next app + /// start; go_router's redirect callback then navigates to [AppRoutes.home] + /// automatically. + /// + /// On failure, a human-readable error is shown in a [SnackBar]. All async + /// continuations check [mounted] to avoid using a stale [BuildContext] after + /// the widget is disposed. + Future<void> _submit() async { + // Client-side validation: abort early if any field is invalid. + if (!(_formKey.currentState?.validate() ?? false)) return; + + setState(() => _isLoading = true); + + try { + final apiClient = ref.read(apiClientProvider); + final username = _usernameController.text.trim(); + final password = _passwordController.text; + + // POST /api/v1/auth/login. Returns the authenticated [User] on 200; + // throws [DioException] with status 401 for wrong credentials. + final user = await apiClient.login( + username: username, + password: password, + ); + + // Persist the session marker so [AuthStateNotifier.build] can restore + // auth state on the next cold start without requiring re-login. + // The username acts as the session presence marker here; a follow-up + // task will replace this with a real bearer token from createAPIToken. + if (!mounted) return; + await ref.read(authStateProvider.notifier).login(user.username); + } on DioException catch (e) { + // Guard against stale BuildContext if the widget was disposed during + // the async gap (e.g. a rapid navigation triggered by another listener). + if (!mounted) return; + _showError(_dioErrorMessage(e)); + } catch (e) { + if (!mounted) return; + _showError('An unexpected error occurred. Please try again.'); + } finally { + // Only call setState if the widget is still in the tree; dispose can + // run before the finally block when error-path navigation triggers it. + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// Displays [message] in a [SnackBar] anchored to the nearest [Scaffold]. + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Login')), - body: const Center(child: Text('Login placeholder')), + appBar: AppBar(title: const Text('Sign In')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Page heading. + Text( + 'Welcome back', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'Enter your credentials to continue.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 32), + + // Username field. + TextFormField( + key: const Key('login_username'), + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + autocorrect: false, + validator: _validateRequired, + ), + const SizedBox(height: 16), + + // Password field (obscured; Enter/Done triggers submit). + TextFormField( + key: const Key('login_password'), + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + ), + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _isLoading ? null : _submit(), + validator: _validateRequired, + ), + const SizedBox(height: 32), + + // Submit button: replaced by a centered progress indicator + // while the HTTP request is in flight. + _isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton( + key: const Key('login_submit'), + onPressed: _submit, + child: const Text('Sign In'), + ), + ], + ), + ), + ), + ), ); } } + +// --------------------------------------------------------------------------- +// File-level helpers +// --------------------------------------------------------------------------- + +/// Extracts a user-friendly error message from a [DioException]. +/// +/// Pure data-transformation function: no widget state, no Riverpod reads, no +/// BuildContext — lives at the top level to make that clear and to ease testing. +/// +/// Priority order for message extraction: +/// 1. Server-supplied `message` or `error` field from the JSON response body. +/// 2. Status-code–specific fallback strings. +/// 3. Generic connectivity message when no HTTP response is available. +String _dioErrorMessage(DioException e) { + final statusCode = e.response?.statusCode; + + // Prefer a human-readable message from the server's JSON response body. + final body = e.response?.data; + if (body is Map<String, dynamic>) { + final msg = body['message'] as String? ?? body['error'] as String?; + if (msg != null && msg.isNotEmpty) return msg; + } + + // Status-code fallbacks for common auth error cases. + if (statusCode == 401) { + return 'Invalid username or password.'; + } + if (statusCode == 400) { + return 'Invalid request. Check your username and password.'; + } + if (statusCode != null) { + return 'Server error ($statusCode). Please try again.'; + } + + // No HTTP response: connectivity or DNS failure. + return 'Could not reach the server. Check your network connection.'; +} diff --git a/player-android/test/screens/login_screen_test.dart b/player-android/test/screens/login_screen_test.dart new file mode 100644 index 0000000..d05e4a2 --- /dev/null +++ b/player-android/test/screens/login_screen_test.dart @@ -0,0 +1,380 @@ +// Widget tests for LoginScreen. +// +// Tests cover: +// 1. Successful login: API called with correct credentials, token persisted, +// auth state transitions to authenticated. +// 2. 401 error display: invalid-credentials response shows the error message. +// 3. Network error display: connection failure shows connectivity message. +// 4. Loading state: CircularProgressIndicator replaces the submit button +// while the HTTP request is in flight. +// 5. Form validation: empty fields prevent submission. +// +// Riverpod providers are overridden with fakes so tests run without a real +// server or OS keychain. +// +// Run with: flutter test test/screens/login_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/login_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] used to avoid the platform-specific OS keychain +/// in tests. Stores the token in a plain Dart field. +class _FakeTokenStorage implements TokenStorage { + String? _token; + + @override + Future<String?> readToken() async => _token; + + @override + Future<void> writeToken(String token) async => _token = token; + + @override + Future<void> deleteToken() async => _token = null; +} + +/// [PlayerApiClient] stub whose [login] behaviour is controlled by the test +/// via [loginResult] and [loginError]. +/// +/// Every other method is left as [UnimplementedError] — [LoginScreen] only +/// calls [login]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [login] returns this [User]. + User? loginResult; + + /// When non-null, [login] throws this exception instead of returning. + Object? loginError; + + // Captures the last credentials passed to login for assertion in tests. + String? capturedUsername; + String? capturedPassword; + + @override + Future<User> login({ + required String username, + required String password, + }) async { + capturedUsername = username; + capturedPassword = password; + if (loginError != null) throw loginError!; + return loginResult!; + } +} + +/// [PlayerApiClient] stub that delays the [login] response until [complete] +/// is called, allowing tests to inspect the loading state mid-flight. +class _DelayedFakeApiClient extends PlayerApiClient { + _DelayedFakeApiClient() : super(dio: Dio()); + + // Completer that the test resolves at a chosen point in time. + final _completer = Completer<User>(); + + /// Resolves the pending login call with [user]. + void complete(User user) => _completer.complete(user); + + @override + Future<User> login({ + required String username, + required String password, + }) => + _completer.future; +} + +// --------------------------------------------------------------------------- +// Helper: build the widget under test inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [LoginScreen] inside a [ProviderScope] that overrides: +/// - [apiClientProvider] with [fakeClient] +/// - [tokenStorageProvider] with an in-memory fake (avoids platform keychain) +/// +/// Returns the [_FakeTokenStorage] so callers can inspect what was persisted. +Future<_FakeTokenStorage> _pumpLoginScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + final fakeStorage = _FakeTokenStorage(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Avoid OS keychain calls during tests. + tokenStorageProvider.overrideWithValue(fakeStorage), + // Use the controllable fake instead of a real HTTP client. + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp( + home: LoginScreen(), + ), + ), + ); + + return fakeStorage; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Form validation + // -------------------------------------------------------------------------- + + group('form validation', () { + testWidgets('submitting empty form shows required-field errors', + (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpLoginScreen(tester, fakeClient); + + // Tap submit without filling any field. + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + + // Both fields should show a required-field error. + expect(find.text('This field is required.'), findsNWidgets(2)); + // No API call should have been made. + expect(fakeClient.capturedUsername, isNull); + }); + + testWidgets('empty username shows required error', (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpLoginScreen(tester, fakeClient); + + // Fill in password but leave username empty. + await tester.enterText( + find.byKey(const Key('login_password')), 'secret123'); + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + + expect(find.text('This field is required.'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Successful login + // -------------------------------------------------------------------------- + + group('successful login', () { + testWidgets('calls login with correct credentials', (tester) async { + final fakeClient = _FakeApiClient() + ..loginResult = const User(id: 1, username: 'alice', isAdmin: false); + + await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'alice'); + await tester.enterText( + find.byKey(const Key('login_password')), 'mysecret'); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // The fake should have received the exact credentials. + expect(fakeClient.capturedUsername, equals('alice')); + expect(fakeClient.capturedPassword, equals('mysecret')); + }); + + testWidgets('persists returned username as session token', (tester) async { + final fakeClient = _FakeApiClient() + ..loginResult = const User(id: 2, username: 'bob', isAdmin: false); + + final fakeStorage = await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'bob'); + await tester.enterText( + find.byKey(const Key('login_password')), 'supersecret'); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // Username is persisted as the session marker (mirrors BootstrapScreen). + expect(fakeStorage._token, equals('bob')); + }); + + testWidgets('shows submit button initially and no loading indicator', + (tester) async { + final fakeClient = _FakeApiClient() + ..loginResult = const User(id: 1, username: 'alice', isAdmin: false); + + await _pumpLoginScreen(tester, fakeClient); + + // Before any interaction: button visible, no spinner. + expect(find.byKey(const Key('login_submit')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Loading state + // -------------------------------------------------------------------------- + + group('loading state', () { + testWidgets('loading indicator shown during a delayed login', + (tester) async { + // Use a completer so the login response is held until we choose to + // resolve it, giving us a window to assert on the loading state. + final fakeClient = _DelayedFakeApiClient(); + + await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'alice'); + await tester.enterText( + find.byKey(const Key('login_password')), 'supersecret'); + + // Tap submit — the _DelayedFakeApiClient won't resolve yet. + await tester.tap(find.byKey(const Key('login_submit'))); + // Pump exactly one frame: setState(_isLoading=true) has run but the + // Future has not yet resolved. + await tester.pump(); + + // Loading state: spinner replaces the submit button. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byKey(const Key('login_submit')), findsNothing); + + // Resolve the fake and let the widget settle. + fakeClient.complete(const User(id: 1, username: 'alice', isAdmin: false)); + await tester.pumpAndSettle(); + + // After completion: spinner gone. + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error display + // -------------------------------------------------------------------------- + + group('error display', () { + testWidgets('401 DioException shows invalid-credentials message', + (tester) async { + final fakeClient = _FakeApiClient() + ..loginError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + statusCode: 401, + // Server returns {"error": "invalid credentials"} for bad logins. + data: <String, dynamic>{'error': 'invalid credentials'}, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'alice'); + await tester.enterText( + find.byKey(const Key('login_password')), 'wrongpassword'); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // Server-supplied error message from the JSON body is shown in the + // SnackBar — this is the "error" field from the response. + expect(find.text('invalid credentials'), findsOneWidget); + }); + + testWidgets('401 without body shows fallback invalid-credentials message', + (tester) async { + final fakeClient = _FakeApiClient() + ..loginError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + statusCode: 401, + // Empty body — no server-supplied message. + data: <String, dynamic>{}, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'alice'); + await tester.enterText( + find.byKey(const Key('login_password')), 'wrongpassword'); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // Without a body message, the 401 fallback text is shown. + expect(find.text('Invalid username or password.'), findsOneWidget); + }); + + testWidgets('network error shows connectivity message', (tester) async { + final fakeClient = _FakeApiClient() + ..loginError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + type: DioExceptionType.connectionError, + ); + + await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'alice'); + await tester.enterText( + find.byKey(const Key('login_password')), 'secret'); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('500 server error shows generic server-error message', + (tester) async { + final fakeClient = _FakeApiClient() + ..loginError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/auth/login'), + statusCode: 500, + data: <String, dynamic>{}, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpLoginScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('login_username')), 'alice'); + await tester.enterText( + find.byKey(const Key('login_password')), 'secret'); + + await tester.tap(find.byKey(const Key('login_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.text('Server error (500). Please try again.'), + findsOneWidget, + ); + }); + }); +} diff --git a/player-android/test/widget_smoke_test.dart b/player-android/test/widget_smoke_test.dart index fba21d4..842a95e 100644 --- a/player-android/test/widget_smoke_test.dart +++ b/player-android/test/widget_smoke_test.dart @@ -1,48 +1,73 @@ // Widget smoke tests for PlayerAndroidApp. // -// These tests verify that the two named routes defined in main.dart render -// without errors and contain the expected key widgets. Navigation between -// HomeScreen and NowPlayingScreen is exercised end-to-end. +// These tests verify that the app boots without crashing and that the root +// widget tree renders correctly. Navigation is handled by go_router and +// Riverpod; deeper screen tests live in test/screens/*.dart. +// +// The app requires a [ProviderScope] ancestor at the root — [PlayerAndroidApp] +// is a [ConsumerWidget] that reads [routerProvider] from Riverpod. Wrapping +// it in a [ProviderScope] and overriding [tokenStorageProvider] and +// [apiClientProvider] avoids any platform-specific code (OS keychain, network) +// during tests. // // Run with: flutter test test/widget_smoke_test.dart -import 'package:flutter/material.dart'; +import 'package:dio/dio.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/main.dart'; +import 'package:player_android/providers/api_client_provider.dart'; -void main() { - group('PlayerAndroidApp', () { - testWidgets('starts on HomeScreen showing Library title', (tester) async { - await tester.pumpWidget(const PlayerAndroidApp()); +// --------------------------------------------------------------------------- +// Minimal fakes to avoid platform-specific code in smoke tests. +// --------------------------------------------------------------------------- - expect(find.text('Library'), findsOneWidget); - expect(find.text('Now Playing'), findsOneWidget); - }); +/// In-memory token storage that simulates a logged-in session so the router +/// does not attempt a redirect to /login (which would trigger firstRunProvider +/// and make a real network call). +class _LoggedInTokenStorage implements TokenStorage { + @override + Future<String?> readToken() async => 'fake-session-token'; - testWidgets('navigates to NowPlayingScreen on button tap', (tester) async { - await tester.pumpWidget(const PlayerAndroidApp()); + @override + Future<void> writeToken(String token) async {} - await tester.tap(find.widgetWithText(ElevatedButton, 'Now Playing')); - await tester.pumpAndSettle(); + @override + Future<void> deleteToken() async {} +} - expect(find.text('Now Playing'), findsWidgets); - expect(find.text('No media selected'), findsOneWidget); - }); +/// Minimal [PlayerApiClient] stub — only [countUsers] is exercised by the +/// router redirect when auth state is unauthenticated. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); - testWidgets('NowPlayingScreen back-navigates to HomeScreen', (tester) async { - await tester.pumpWidget(const PlayerAndroidApp()); + @override + Future<int> countUsers() async => 1; // Non-zero: normal login flow. +} - // Navigate to NowPlayingScreen. - await tester.tap(find.widgetWithText(ElevatedButton, 'Now Playing')); - await tester.pumpAndSettle(); - expect(find.text('No media selected'), findsOneWidget); +void main() { + group('PlayerAndroidApp', () { + testWidgets('boots without crashing inside a ProviderScope', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Simulate a logged-in session so the router does not try to + // hit the network for the firstRunProvider check. + tokenStorageProvider.overrideWithValue(_LoggedInTokenStorage()), + api |
