Fix AuthInterceptor replay logout-on-transient-error + add tests

Address PR re-review findings:
- Only a 401 on the replay falls through to logout; other DioExceptions
  (timeout, connection drop, 5xx) now propagate so a flaky network can't
  wipe a valid session and callers see the real error.
- Skip the refresh when the failed request's bearer no longer matches the
  stored one (a concurrent refresh already rotated it) and replay directly.
- Guard _logout() with a _loggingOut flag so the auth-failed callback fires
  once per burst rather than once per concurrent caller.

Add unit tests (fake Dio adapter + mocked prefs) covering the retry guard,
single-flight refresh, and non-401 replay propagation paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AmirReza Jamali
2026-06-13 16:20:23 +03:30
parent deda384aaa
commit 231d800b47
2 changed files with 214 additions and 14 deletions
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tm_app/core/constants/pref_keys.dart';
import 'package:tm_app/core/network/auth_interceptor.dart';
import 'package:tm_app/core/utils/logger.dart';
/// A configurable [HttpClientAdapter] that lets each test decide, per request,
/// what the wire should return. Avoids a mocking dependency the repo doesn't
/// pull in while still exercising the real Dio request/replay machinery.
class _FakeAdapter implements HttpClientAdapter {
_FakeAdapter(this.responder);
Future<ResponseBody> Function(RequestOptions options) responder;
int requestCount = 0;
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? cancelFuture,
) {
requestCount++;
return responder(options);
}
@override
void close({bool force = false}) {}
}
ResponseBody _json(int status, [Map<String, dynamic> body = const {}]) {
return ResponseBody.fromString(
jsonEncode(body),
status,
headers: <String, List<String>>{
Headers.contentTypeHeader: <String>[Headers.jsonContentType],
},
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
// The interceptor logs through the app-wide singleton, which is `late`-init.
appLogger.init();
late Dio dio;
late SharedPreferences prefs;
late int refreshCalls;
late int logoutCalls;
setUp(() async {
SharedPreferences.setMockInitialValues(<String, Object>{
PrefKeys.bearer: 'old-token',
PrefKeys.refreshToken: 'old-refresh',
});
prefs = await SharedPreferences.getInstance();
refreshCalls = 0;
logoutCalls = 0;
dio = Dio(BaseOptions(baseUrl: 'https://example.test'));
});
/// Refresh that succeeds: rotates the stored bearer and returns the new token,
/// mirroring the production refresh which persists before replaying.
Future<String?> refreshSucceeds() async {
refreshCalls++;
// A small delay so concurrent 401s both register on the single-flight
// future before it resolves, exercising the coalescing path for real.
await Future<void>.delayed(const Duration(milliseconds: 10));
await prefs.setString(PrefKeys.bearer, 'new-token');
return 'new-token';
}
void attachInterceptor({Future<String?> Function()? refreshToken}) {
dio.interceptors.add(
AuthInterceptor(
dio: dio,
prefs: prefs,
refreshToken: refreshToken ?? refreshSucceeds,
onAuthFailed: () => () async {
logoutCalls++;
await prefs.clear();
},
),
);
}
test(
'retry guard: a replay that still 401s does not trigger a second refresh',
() async {
// Every request — original and replay — comes back 401.
dio.httpClientAdapter = _FakeAdapter(
(RequestOptions options) async => _json(401, <String, dynamic>{
'message': 'unauthorized',
}),
);
attachInterceptor();
await expectLater(
dio.get<dynamic>('/api/v1/protected'),
throwsA(isA<DioException>()),
);
// The refresh + replay happens exactly once; the second 401 short-circuits
// via the auth_retried flag instead of refreshing again.
expect(refreshCalls, 1);
expect(logoutCalls, 1);
},
);
test('single-flight: concurrent 401s share a single refresh', () async {
// First attempt 401s; the tagged replay succeeds.
dio.httpClientAdapter = _FakeAdapter((RequestOptions options) async {
final bool isReplay = options.extra['auth_retried'] == true;
return _json(isReplay ? 200 : 401, <String, dynamic>{'ok': isReplay});
});
attachInterceptor();
final List<Response<dynamic>> responses = await Future.wait(
<Future<Response<dynamic>>>[
dio.get<dynamic>('/api/v1/a'),
dio.get<dynamic>('/api/v1/b'),
],
);
expect(refreshCalls, 1);
expect(logoutCalls, 0);
expect(
responses.every((Response<dynamic> r) => r.statusCode == 200),
isTrue,
);
});
test(
'non-401 replay failure propagates and does not log the user out',
() async {
// Refresh succeeds, but the replay hits a transient/server error (a 500
// here stands in for the timeout/connection-drop case): authentication is
// fine, so the error must surface to the caller rather than wipe the
// session.
dio.httpClientAdapter = _FakeAdapter((RequestOptions options) async {
final bool isReplay = options.extra['auth_retried'] == true;
return _json(isReplay ? 500 : 401);
});
attachInterceptor();
await expectLater(
dio.get<dynamic>('/api/v1/protected'),
throwsA(
isA<DioException>().having(
(DioException e) => e.response?.statusCode,
'replay status code',
500,
),
),
);
expect(refreshCalls, 1);
expect(logoutCalls, 0);
// Session left intact for the retry — credentials were not cleared.
expect(prefs.getString(PrefKeys.bearer), 'new-token');
},
);
}