summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--player-android/lib/app_routes.dart3
-rw-r--r--player-android/lib/router.dart16
-rw-r--r--player-android/lib/screens/bootstrap_screen.dart281
-rw-r--r--player-android/test/screens/bootstrap_screen_test.dart410
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,
+ );
+ });
+ });
+}