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
|
// 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,
);
});
});
}
|