summaryrefslogtreecommitdiff
path: root/player-android/test/screens/bootstrap_screen_test.dart
blob: 2b47ced495586dca10da0a4910df686bcc6b1112 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
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,
      );
    });
  });
}