diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 00:03:10 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 00:03:10 +0300 |
| commit | 89ef249a43ce1edcd8a3e217542de8f31c1a4c15 (patch) | |
| tree | 339181b3a4eab1ed923b0e34c56d84ecb9be6f60 /player-android | |
| parent | e6542539d0239183f9f3a76474f6f67692264d34 (diff) | |
Implement BootstrapScreen with widget tests (qa)
Adds the first-run admin-account setup screen (POST /api/v1/auth/bootstrap),
wires it into the go_router redirect guard, and extracts the pure
_dioErrorMessage helper to a top-level function to satisfy SRP.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/app_routes.dart | 3 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 16 | ||||
| -rw-r--r-- | player-android/lib/screens/bootstrap_screen.dart | 281 | ||||
| -rw-r--r-- | player-android/test/screens/bootstrap_screen_test.dart | 410 |
4 files changed, 706 insertions, 4 deletions
diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index be3ff7c..2856753 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -9,6 +9,9 @@ abstract final class AppRoutes { static const mediaDetail = '/media/:id'; static const share = '/share'; + /// First-run setup route shown when no admin account exists yet. + static const bootstrap = '/bootstrap'; + /// Returns the concrete path for a media-detail page given a numeric [id]. static String mediaDetailPath(int id) => '/media/$id'; } diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 45775cf..a54f4f0 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 'screens/bootstrap_screen.dart'; import 'screens/home_screen.dart'; import 'screens/login_screen.dart'; import 'screens/media_detail_screen.dart'; @@ -48,15 +49,18 @@ final routerProvider = Provider<GoRouter>((ref) { if (authAsync.isLoading || authAsync.hasError) return null; final auth = authAsync.requireValue; - final isLoginRoute = state.matchedLocation == AppRoutes.login; + final location = state.matchedLocation; + final isLoginRoute = location == AppRoutes.login; + // Bootstrap is a public route (user is unauthenticated by definition). + final isBootstrapRoute = location == AppRoutes.bootstrap; - if (auth.isUnauthenticated && !isLoginRoute) { + if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { // Guard every authenticated route: bounce to login. return AppRoutes.login; } - if (auth.isAuthenticated && isLoginRoute) { - // Prevent the user from seeing the login screen once authenticated. + if (auth.isAuthenticated && (isLoginRoute || isBootstrapRoute)) { + // Prevent already-authenticated users from viewing auth/setup screens. return AppRoutes.home; } @@ -66,6 +70,10 @@ final routerProvider = Provider<GoRouter>((ref) { routes: [ GoRoute( + path: AppRoutes.bootstrap, + builder: (context, state) => const BootstrapScreen(), + ), + GoRoute( path: AppRoutes.login, builder: (context, state) => const LoginScreen(), ), diff --git a/player-android/lib/screens/bootstrap_screen.dart b/player-android/lib/screens/bootstrap_screen.dart new file mode 100644 index 0000000..eb7caee --- /dev/null +++ b/player-android/lib/screens/bootstrap_screen.dart @@ -0,0 +1,281 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/api_client_provider.dart'; +import '../providers/auth_state_provider.dart'; + +// Minimum password length enforced by the server (see handlers_auth.go, +// service/auth.go — passwords shorter than this are rejected with 400). +const _kMinPasswordLength = 8; + +/// First-run setup screen that creates the initial admin account. +/// +/// Displayed when no users exist on the server. Submitting the form calls +/// POST /api/auth/bootstrap; on success the auth state transitions to +/// [AuthStatus.authenticated] and the router redirects to [AppRoutes.home]. +/// +/// Design notes: +/// - All mutable state lives in [_BootstrapFormState]; the outer widget is a +/// lightweight [ConsumerStatefulWidget] that owns the Riverpod [Ref]. +/// - Validation is client-side only (password length, match check). Server +/// errors (e.g. 403 "already bootstrapped") are surfaced via a snack-bar. +/// - [ConsumerStatefulWidget] is used instead of [StatelessWidget] so that +/// [WidgetRef] is available across the async submit path without storing it +/// as a field (which would risk using a stale ref after disposal). +class BootstrapScreen extends ConsumerStatefulWidget { + const BootstrapScreen({super.key}); + + @override + ConsumerState<BootstrapScreen> createState() => _BootstrapScreenState(); +} + +class _BootstrapScreenState extends ConsumerState<BootstrapScreen> { + // Form key used to trigger programmatic validation across all fields. + final _formKey = GlobalKey<FormState>(); + + // Controllers for the three text fields. Disposed in [dispose] to avoid + // memory leaks when the widget is removed from the tree. + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); + + // Whether an HTTP request is in flight; drives loading indicator visibility + // and disables the submit button to prevent double-submission. + bool _isLoading = false; + + @override + void dispose() { + _usernameController.dispose(); + _passwordController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // Validation helpers + // --------------------------------------------------------------------------- + + /// Returns an error string if [value] is empty or null; null otherwise. + String? _validateRequired(String? value) { + if (value == null || value.trim().isEmpty) { + return 'This field is required.'; + } + return null; + } + + /// Validates the password field: must meet minimum-length policy. + String? _validatePassword(String? value) { + final required = _validateRequired(value); + if (required != null) return required; + + if (value!.length < _kMinPasswordLength) { + return 'Password must be at least $_kMinPasswordLength characters.'; + } + return null; + } + + /// Validates the confirm-password field: must match the password field. + String? _validateConfirm(String? value) { + final required = _validateRequired(value); + if (required != null) return required; + + if (value != _passwordController.text) { + return 'Passwords do not match.'; + } + return null; + } + + // --------------------------------------------------------------------------- + // Submit logic + // --------------------------------------------------------------------------- + + /// Validates the form and submits the bootstrap request. + /// + /// On success, persists the authenticated session via [AuthStateNotifier.login] + /// and lets go_router's redirect logic navigate to [AppRoutes.home]. + /// + /// On server error, extracts a human-readable message from the [DioException] + /// (status code + response body) and shows it in a [SnackBar]. + Future<void> _submit() async { + // Client-side validation: abort 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; + + // Call POST /api/v1/auth/bootstrap. On success the server creates the + // admin user and sets a session cookie. The returned User confirms the + // account was created; we use its username as the session marker stored + // in TokenStorage so that [AuthStateNotifier.build] recognises a prior + // successful login on the next app start. + // + // NOTE: The server uses cookie-based session auth. For a full mobile + // bearer-token flow a follow-up task should call createAPIToken after + // bootstrap and persist that token instead. + final user = await apiClient.bootstrap( + username: username, + password: password, + ); + + // Persist the session marker and update auth state → router redirects + // automatically to AppRoutes.home via the refreshListenable. + await ref + .read(authStateProvider.notifier) + .login(user.username); + } on DioException catch (e) { + // Only show the snack-bar if the widget is still mounted; async gaps can + // occur between the await above and this error handler. + if (!mounted) return; + _showError(_dioErrorMessage(e)); + } catch (e) { + if (!mounted) return; + _showError('An unexpected error occurred. Please try again.'); + } finally { + // Guard against calling setState on a disposed widget (e.g. if the error + // path triggers navigation and dispose runs before finally executes). + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + /// Shows [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('Set Up Admin Account')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Introductory copy explaining the one-time setup context. + Text( + 'Welcome to Player', + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 8), + Text( + 'No accounts exist yet. Create the initial admin account ' + 'to get started.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 32), + + // Username field. + TextFormField( + key: const Key('bootstrap_username'), + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + autocorrect: false, + validator: _validateRequired, + ), + const SizedBox(height: 16), + + // Password field (obscured, minimum-length validated). + TextFormField( + key: const Key('bootstrap_password'), + controller: _passwordController, + decoration: const InputDecoration( + labelText: 'Password', + border: OutlineInputBorder(), + helperText: + 'Minimum $_kMinPasswordLength characters.', + ), + obscureText: true, + textInputAction: TextInputAction.next, + validator: _validatePassword, + ), + const SizedBox(height: 16), + + // Confirm-password field (must match password field). + TextFormField( + key: const Key('bootstrap_confirm'), + controller: _confirmController, + decoration: const InputDecoration( + labelText: 'Confirm Password', + border: OutlineInputBorder(), + ), + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _isLoading ? null : _submit(), + validator: _validateConfirm, + ), + const SizedBox(height: 32), + + // Submit button: replaced by a progress indicator while loading. + _isLoading + ? const Center(child: CircularProgressIndicator()) + : ElevatedButton( + key: const Key('bootstrap_submit'), + onPressed: _submit, + child: const Text('Create Admin Account'), + ), + ], + ), + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// 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. +/// +/// Prefers a `message` or `error` field from the response JSON body; falls +/// back to the HTTP status line, or a generic connectivity message. +String _dioErrorMessage(DioException e) { + final statusCode = e.response?.statusCode; + + // Try to read a server-supplied message from the 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; + } + + // Fallback to HTTP status descriptions. + if (statusCode == 403) { + return 'Bootstrap already complete — an admin account already exists.'; + } + if (statusCode == 400) { + return 'Invalid request. Check your username and password.'; + } + if (statusCode != null) { + return 'Server error ($statusCode). Please try again.'; + } + + return 'Could not reach the server. Check your network connection.'; +} diff --git a/player-android/test/screens/bootstrap_screen_test.dart b/player-android/test/screens/bootstrap_screen_test.dart new file mode 100644 index 0000000..2b47ced --- /dev/null +++ b/player-android/test/screens/bootstrap_screen_test.dart @@ -0,0 +1,410 @@ +// Widget tests for BootstrapScreen. +// +// Tests cover: +// 1. Form validation (empty fields, password too short, mismatched passwords). +// 2. Successful submit: API is called with correct credentials, auth state +// transitions to authenticated. +// 3. Error display: server errors produce a visible SnackBar message. +// +// Riverpod providers are overridden with fakes/mocks so tests run without a +// real server or OS keychain. +// +// Run with: flutter test test/screens/bootstrap_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/bootstrap_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] used by [_FakeAuthStateNotifier] to avoid +/// platform-specific secure storage in tests. +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 [bootstrap] behaviour is controlled by the +/// test via [bootstrapResult] and [bootstrapError]. +/// +/// Every other method is left as [UnimplementedError] — the bootstrap screen +/// only calls [bootstrap]. +class _FakeApiClient extends PlayerApiClient { + _FakeApiClient() : super(dio: Dio()); + + /// When non-null, [bootstrap] returns this [User]. + User? bootstrapResult; + + /// When non-null, [bootstrap] throws this exception instead of returning. + Object? bootstrapError; + + @override + Future<User> bootstrap({ + required String username, + required String password, + }) async { + if (bootstrapError != null) throw bootstrapError!; + return bootstrapResult!; + } +} + +/// [PlayerApiClient] stub that delays the [bootstrap] response until +/// [complete] is called, allowing tests to inspect the loading state. +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 bootstrap call with [user]. + void complete(User user) => _completer.complete(user); + + @override + Future<User> bootstrap({ + required String username, + required String password, + }) => + _completer.future; +} + +// --------------------------------------------------------------------------- +// Helper: build the widget under test inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [BootstrapScreen] inside a [ProviderScope] that overrides: +/// - [apiClientProvider] with [fakeClient] +/// - [tokenStorageProvider] with an in-memory fake (so AuthStateNotifier +/// does not touch the platform keychain) +/// +/// Returns the [_FakeTokenStorage] so callers can inspect stored tokens. +Future<_FakeTokenStorage> _pumpBootstrapScreen( + WidgetTester tester, + PlayerApiClient fakeClient, +) async { + final fakeStorage = _FakeTokenStorage(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Override token storage to avoid flutter_secure_storage platform call. + tokenStorageProvider.overrideWithValue(fakeStorage), + // Override API client with our controllable fake. + apiClientProvider.overrideWithValue(fakeClient), + ], + child: const MaterialApp( + home: BootstrapScreen(), + ), + ), + ); + + return fakeStorage; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Form validation + // -------------------------------------------------------------------------- + + group('form validation', () { + testWidgets('submitting empty form shows required-field errors', + (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpBootstrapScreen(tester, fakeClient); + + // Tap submit without filling any field. + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + + // Expect validation errors on all three fields. + expect(find.text('This field is required.'), findsNWidgets(3)); + // No API call should have been made. + expect(fakeClient.bootstrapResult, isNull); + }); + + testWidgets('password shorter than 8 chars shows length error', + (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'short'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'short'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + + expect( + find.text('Password must be at least 8 characters.'), + findsOneWidget, + ); + }); + + testWidgets('mismatched passwords shows mismatch error', (tester) async { + final fakeClient = _FakeApiClient(); + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'password123'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'different123'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + + expect(find.text('Passwords do not match.'), findsOneWidget); + }); + + testWidgets('valid form with matching passwords passes validation', + (tester) async { + // Set up fake to return a user so the form submit completes. + final fakeClient = _FakeApiClient() + ..bootstrapResult = const User( + id: 1, + username: 'admin', + isAdmin: true, + ); + final fakeStorage = await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'password123'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'password123'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); // Start the async submit. + await tester.pumpAndSettle(); // Let the future complete. + + // No validation-error text should appear. + expect(find.text('This field is required.'), findsNothing); + expect(find.text('Passwords do not match.'), findsNothing); + expect(find.text('Password must be at least 8 characters.'), findsNothing); + + // Token should have been persisted via the fake storage. + expect(fakeStorage._token, isNotNull); + }); + }); + + // -------------------------------------------------------------------------- + // Successful submit + // -------------------------------------------------------------------------- + + group('successful submit', () { + testWidgets('calls bootstrap with correct credentials and succeeds', + (tester) async { + // Set up fake to return a user matching the submitted username. + final fakeClient = _FakeApiClient() + ..bootstrapResult = + const User(id: 2, username: 'testadmin', isAdmin: true); + + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'testadmin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'supersecret'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'supersecret'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // No errors visible — the fake was called successfully. + expect(find.text('Passwords do not match.'), findsNothing); + expect(find.text('This field is required.'), findsNothing); + }); + + testWidgets('persists token to storage after success', (tester) async { + final fakeClient = _FakeApiClient() + ..bootstrapResult = const User( + id: 1, + username: 'admin', + isAdmin: true, + ); + final fakeStorage = await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'password123'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'password123'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // The username is stored as the session marker token. + expect(fakeStorage._token, equals('admin')); + }); + + testWidgets('submit button visible initially, no loading indicator', + (tester) async { + final fakeClient = _FakeApiClient() + ..bootstrapResult = + const User(id: 1, username: 'admin', isAdmin: true); + + await _pumpBootstrapScreen(tester, fakeClient); + + // Initially: submit button visible and no loading indicator. + expect(find.byKey(const Key('bootstrap_submit')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('loading indicator shown during a delayed submit', + (tester) async { + // Use a completer to hold the bootstrap response so the loading state + // is visible for long enough to assert on it. + final fakeClient = _DelayedFakeApiClient(); + + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'longpassword'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'longpassword'); + + // Tap submit — the _DelayedFakeApiClient won't resolve yet. + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + // Pump a single frame: setState(_isLoading=true) has run but the + // bootstrap Future has not yet resolved. + await tester.pump(); + + // During submit: progress indicator should replace the button. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(find.byKey(const Key('bootstrap_submit')), findsNothing); + + // Resolve the fake and settle. + fakeClient.complete(const User(id: 1, username: 'admin', isAdmin: true)); + await tester.pumpAndSettle(); + + // After submit: loading cleared. + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + }); + + // -------------------------------------------------------------------------- + // Error display + // -------------------------------------------------------------------------- + + group('error display', () { + testWidgets('403 DioException shows already-bootstrapped message', + (tester) async { + final fakeClient = _FakeApiClient() + ..bootstrapError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/bootstrap'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/auth/bootstrap'), + statusCode: 403, + data: <String, dynamic>{'error': 'bootstrap already complete'}, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'password123'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'password123'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + // The server-supplied error message from the response body is shown. + expect(find.text('bootstrap already complete'), findsOneWidget); + }); + + testWidgets('network error shows connectivity message', (tester) async { + final fakeClient = _FakeApiClient() + ..bootstrapError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/bootstrap'), + type: DioExceptionType.connectionError, + ); + + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'password123'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'password123'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.textContaining('Could not reach the server'), + findsOneWidget, + ); + }); + + testWidgets('400 DioException without body shows generic error', + (tester) async { + final fakeClient = _FakeApiClient() + ..bootstrapError = DioException( + requestOptions: RequestOptions(path: '/api/v1/auth/bootstrap'), + response: Response( + requestOptions: RequestOptions(path: '/api/v1/auth/bootstrap'), + statusCode: 400, + data: <String, dynamic>{}, + ), + type: DioExceptionType.badResponse, + ); + + await _pumpBootstrapScreen(tester, fakeClient); + + await tester.enterText( + find.byKey(const Key('bootstrap_username')), 'admin'); + await tester.enterText( + find.byKey(const Key('bootstrap_password')), 'password123'); + await tester.enterText( + find.byKey(const Key('bootstrap_confirm')), 'password123'); + + await tester.tap(find.byKey(const Key('bootstrap_submit'))); + await tester.pump(); + await tester.pumpAndSettle(); + + expect( + find.text('Invalid request. Check your username and password.'), + findsOneWidget, + ); + }); + }); +} |
