import 'package:dartz/dartz.dart'; import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:superport/core/constants/api_endpoints.dart'; import 'package:superport/core/errors/failures.dart'; import 'package:superport/data/datasources/remote/interceptors/auth_interceptor.dart'; import 'package:superport/data/models/auth/refresh_token_request.dart'; import 'package:superport/data/models/auth/login_request.dart'; import 'package:superport/data/models/auth/login_response.dart'; import 'package:superport/data/models/auth/auth_user.dart'; import 'package:superport/data/models/auth/token_response.dart'; import 'package:superport/domain/repositories/auth_repository.dart'; class _InMemoryAuthRepository implements AuthRepository { String? _accessToken; String? _refreshToken; bool cleared = false; _InMemoryAuthRepository({String? accessToken, String? refreshToken}) : _accessToken = accessToken, _refreshToken = refreshToken; @override Future> getStoredAccessToken() async => Right(_accessToken); @override Future> getStoredRefreshToken() async => Right(_refreshToken); @override Future> refreshToken(RefreshTokenRequest refreshRequest) async { if (refreshRequest.refreshToken != _refreshToken || _refreshToken == null) { return Left(ServerFailure(message: 'Invalid refresh token')); } // Issue new tokens _accessToken = 'NEW_TOKEN'; _refreshToken = 'NEW_REFRESH'; return Right(TokenResponse( accessToken: _accessToken!, refreshToken: _refreshToken!, tokenType: 'Bearer', expiresIn: 3600, )); } @override Future> clearLocalSession() async { cleared = true; _accessToken = null; _refreshToken = null; return const Right(null); } // Unused in these tests @override Future> changePassword(String currentPassword, String newPassword) async => const Right(null); @override Future> requestPasswordReset(String email) async => const Right(null); @override Future> isAuthenticated() async => const Right(true); @override Future> logout() async => const Right(null); @override Future> validateSession() async => const Right(true); @override Future> login(LoginRequest loginRequest) async => Left(ServerFailure(message: 'not used in test')); @override Future> getCurrentUser() async => Left(ServerFailure(message: 'not used in test')); } /// Interceptor that terminates requests without hitting network. class _TerminatorInterceptor extends Interceptor { RequestOptions? lastOptions; @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { lastOptions = options; // Default success response (200) handler.resolve(Response(requestOptions: options, statusCode: 200, data: {'ok': true})); } } /// Interceptor that rejects with 401 unless it sees the expected token. class _UnauthorizedThenOkInterceptor extends Interceptor { final String requiredBearer; _UnauthorizedThenOkInterceptor(this.requiredBearer); @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { final auth = options.headers['Authorization']; // Debug: record current auth header on the test interceptor // ignore: avoid_print print('Test UnauthorizedThenOkInterceptor saw Authorization: ' + (auth?.toString() ?? 'NULL')); if (auth == 'Bearer $requiredBearer') { handler.resolve(Response(requestOptions: options, statusCode: 200, data: {'ok': true})); } else { handler.reject(DioException( requestOptions: options, response: Response(requestOptions: options, statusCode: 401), type: DioExceptionType.badResponse, message: 'Unauthorized', )); } } } /// Simple HTTP adapter that returns 200 when Authorization matches, /// otherwise returns 401. This is used to exercise `dio.fetch(...)` retry path. class _AuthTestAdapter implements HttpClientAdapter { @override void close({bool force = false}) {} @override Future fetch( RequestOptions options, Stream>? requestStream, Future? cancelFuture, ) async { final auth = options.headers['Authorization']; // Login endpoint always returns 200 if (options.path == ApiEndpoints.login) { return ResponseBody.fromString('{"ok":true}', 200, headers: {}); } if (auth == 'Bearer NEW_TOKEN') { return ResponseBody.fromString('{"ok":true}', 200, headers: {}); } return ResponseBody.fromString('', 401, headers: {}); } } void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('AuthInterceptor', () { late GetIt sl; setUp(() { sl = GetIt.instance; sl.reset(dispose: true); }); tearDown(() { sl.reset(dispose: true); }); test('attaches Authorization header for protected endpoints', () async { final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH'); sl.registerLazySingleton(() => repo); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final terminator = _TerminatorInterceptor(); dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo)); dio.interceptors.add(terminator); final res = await dio.get(ApiEndpoints.users); expect(res.statusCode, 200); expect(terminator.lastOptions, isNotNull); expect(terminator.lastOptions!.headers['Authorization'], 'Bearer OLD_TOKEN'); }); test('skips Authorization header for auth endpoints', () async { final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH'); sl.registerLazySingleton(() => repo); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); final terminator = _TerminatorInterceptor(); dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo)); dio.interceptors.add(terminator); final res = await dio.get(ApiEndpoints.login); expect(res.statusCode, 200); expect(terminator.lastOptions, isNotNull); expect(terminator.lastOptions!.headers.containsKey('Authorization'), isFalse); }); test('on 401, refresh token and retry succeeds', () async { final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: 'REFRESH'); sl.registerLazySingleton(() => repo); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); dio.httpClientAdapter = _AuthTestAdapter(); dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo)); final res = await dio.get(ApiEndpoints.users); expect(res.statusCode, 200); // ensure repo now holds new token final tokenEither = await repo.getStoredAccessToken(); expect(tokenEither.getOrElse(() => null), 'NEW_TOKEN'); }); test('on 401 and refresh fails, session cleared and error bubbles', () async { // Repo with no refresh token (will fail refresh) final repo = _InMemoryAuthRepository(accessToken: 'OLD_TOKEN', refreshToken: null); sl.registerLazySingleton(() => repo); // Verify registration exists expect(GetIt.instance.isRegistered(), true); final dio = Dio(BaseOptions(baseUrl: 'https://example.com')); dio.httpClientAdapter = _AuthTestAdapter(); dio.interceptors.add(AuthInterceptor(dio, overrideAuthRepository: repo)); DioException? caught; try { await dio.get(ApiEndpoints.users); } on DioException catch (e) { caught = e; } expect(caught, isNotNull); expect(caught!.response?.statusCode, 401); expect(repo.cleared, isTrue); }); }); }