Skip to content

How to test http client with interceptor? TimeoutException after ... Future not completed #118

Open
@macik1423

Description

@macik1423

Describe the bug
I wonder how to test http client with intercepor. I get every time TimeoutException after 0:00:15.000000: Future not completed or Przekroczono limit czasu semafora. if I remove timeout in client.get(...). Below there are my classes, I hope you can easly reproduce issue.

To Reproduce
Steps to reproduce the behavior:
lib\
authentication_repository.dart

import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:testing_app/storage_service.dart';
import 'package:testing_app/token.dart';

enum AuthenticationStatus {
  unknown,
  authenticated,
  unauthenticated,
  sessionExpired
}

class AuthenticationRepository {
  final _controller = StreamController<AuthenticationStatus>();
  final API_URL = dotenv.env['API_URL']!;
  final SERVER_TIMEOUT_SECONDS = 15;
  final http.Client _httpClient;
  final IStorage storage;

  AuthenticationRepository(this.storage, {http.Client? httpClient})
      : _httpClient = httpClient ?? http.Client();

  Stream<AuthenticationStatus> get status async* {
    yield AuthenticationStatus.unauthenticated;
    yield* _controller.stream;
  }

  Future<void> logIn({
    required String username,
    required String password,
  }) async {
    final request = Uri.http(API_URL, '/api/authentication/login-user');

    try {
      final loginResponse = await _httpClient
          .post(
            request,
            body: jsonEncode(
                {"emailAddress": "${username}", "password": "${password}"}),
            headers: {
              "Access_Control_Allow_Methods": "POST, OPTIONS",
              "Content-Type": "application/json",
              "Access-Control-Allow-Origin": "*",
              "Access-Control-Allow-Credentials": 'true'
            },
            encoding: Encoding.getByName("utf-8"),
          )
          .timeout(
            Duration(
              seconds: SERVER_TIMEOUT_SECONDS,
            ),
          );
      if (loginResponse.statusCode != 200) {
        throw Exception();
      } else {
        final accessToken = jsonDecode(loginResponse.body)['token'];
        final expiresAt = jsonDecode(loginResponse.body)['expiresAt'];
        final refreshToken = jsonDecode(loginResponse.body)['refreshToken'];
        if (accessToken != null) {
          storage.writeSecureData(
            StorageItem(
              key: 'token',
              value: Token.serialize(Token(
                accessToken: accessToken,
                expiresAt: expiresAt,
                refreshToken: refreshToken,
              )),
            ),
          );
        }
        _controller.add(AuthenticationStatus.authenticated);
      }
    } on TimeoutException catch (e) {
      developer.log("TIMEOUT!!!");
      throw TimeoutException(e.message);
    } on Exception catch (e) {
      developer.log('${e}');
      throw Exception();
    }
  }

  Future<void> getRefreshToken({required Token token}) async {
    storage.deleteSecureData('token');
    final request = Uri.http(API_URL, '/api/Authentication/refresh-token');

    try {
      final response = await _httpClient
          .post(
            request,
            headers: {
              'Accept': 'application/json',
              'Content-Type': 'application/json',
            },
            body: jsonEncode(
              {
                'token': token.accessToken,
                'refreshToken': token.refreshToken,
              },
            ),
          )
          .timeout(Duration(seconds: SERVER_TIMEOUT_SECONDS));

      if (response.statusCode != 200) {
        throw Exception();
      } else {
        final accessToken = jsonDecode(response.body)['token'];
        final expiresAt = jsonDecode(response.body)['expiresAt'];
        final refreshToken = jsonDecode(response.body)['refreshToken'];
        if (accessToken != null) {
          storage.writeSecureData(
            StorageItem(
              key: 'token',
              value: Token.serialize(Token(
                accessToken: accessToken,
                expiresAt: expiresAt,
                refreshToken: refreshToken,
              )),
            ),
          );
        }
      }
    } on TimeoutException catch (e) {
      throw TimeoutException(e.message);
    } on Exception catch (_) {
      sessionExpired();
      throw Exception();
    }
  }

  void sessionExpired() {
    storage.deleteSecureData('token');
    _controller.add(AuthenticationStatus.sessionExpired);
  }

  void logOut() {
    storage.deleteSecureData('token');
    _controller.add(AuthenticationStatus.unauthenticated);
  }

  void dispose() => _controller.close();
}

material.dart

import 'package:json_annotation/json_annotation.dart';

part 'material.g.dart';

@JsonSerializable()
class Material {
  const Material({
    required this.symbol,
    required this.value,
  });

  factory Material.fromJson(Map<String, dynamic> json) =>
      _$MaterialFromJson(json);

  final int value;
  final String symbol;
}

material.g.dart

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'material.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

Material _$MaterialFromJson(Map<String, dynamic> json) => Material(
      symbol: json['symbol'] as String,
      value: json['value'] as int,
    );

Map<String, dynamic> _$MaterialToJson(Material instance) => <String, dynamic>{
      'value': instance.value,
      'symbol': instance.symbol,
    };

storage_service.dart

import 'package:flutter_secure_storage/flutter_secure_storage.dart';

abstract class IStorage {
  Future<void> writeSecureData(StorageItem newItem);
  Future<String?> readSecureData(String key);
  Future<void> deleteSecureData(String key);
  Future<bool> containsKeyInSecureData(String key);
  Future<void> deleteAllSecureData();
}

class StorageItem {
  StorageItem({required this.key, required this.value});

  final String key;
  final String value;
}

class StorageService implements IStorage {
  final FlutterSecureStorage flutterSecureStorage;

  StorageService({required this.flutterSecureStorage});

  AndroidOptions _getAndroidOptions() => const AndroidOptions(
        encryptedSharedPreferences: true,
      );

  @override
  Future<void> writeSecureData(StorageItem newItem) async {
    await flutterSecureStorage.write(key: newItem.key, value: newItem.value);
  }

  @override
  Future<String?> readSecureData(String key) async {
    return await flutterSecureStorage.read(
        key: key, aOptions: _getAndroidOptions());
  }

  @override
  Future<void> deleteSecureData(String key) async {
    await flutterSecureStorage.delete(key: key, aOptions: _getAndroidOptions());
  }

  @override
  Future<bool> containsKeyInSecureData(String key) async {
    return flutterSecureStorage.containsKey(
        key: key, aOptions: _getAndroidOptions());
  }

  @override
  Future<void> deleteAllSecureData() async {
    await flutterSecureStorage.deleteAll(aOptions: _getAndroidOptions());
  }
}

token.dart

import 'dart:convert';

class Token {
  final String accessToken;
  final String refreshToken;
  final String expiresAt;

  Token({
    required this.accessToken,
    required this.refreshToken,
    required this.expiresAt,
  });

  factory Token.fromJson(Map<String, dynamic> json) {
    return Token(
      accessToken: json['accessToken'] as String,
      refreshToken: json['refreshToken'] as String,
      expiresAt: json['expiresAt'] as String,
    );
  }

  static Map<String, dynamic> toMap(Token token) {
    return {
      'accessToken': token.accessToken,
      'refreshToken': token.refreshToken,
      'expiresAt': token.expiresAt,
    };
  }

  static String serialize(Token token) {
    return json.encode(toMap(token));
  }

  static Token deserialize(String? serialized) {
    return Token.fromJson(json.decode(serialized!));
  }
}

warehouse_api_client.dart

import 'dart:convert';

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:http/http.dart' as http;
import 'package:http_interceptor/http/interceptor_contract.dart';
import 'package:http_interceptor/http_interceptor.dart';
import 'package:testing_app/authentication_repository.dart';
import 'package:testing_app/material.dart';
import 'package:testing_app/storage_service.dart';
import 'package:testing_app/token.dart';

class AuthorizationInterceptor implements InterceptorContract {
  final IStorage storage;

  AuthorizationInterceptor({required this.storage});

  @override
  Future<RequestData> interceptRequest({required RequestData data}) async {
    try {
      final Token? token =
          Token.deserialize(await storage.readSecureData('token'));
      data.headers.clear();

      data.headers['authorization'] = 'Bearer ' + token!.accessToken;
      data.headers['content-type'] = 'application/json';
    } catch (e) {
      print(e);
    }

    return data;
  }

  @override
  Future<ResponseData> interceptResponse({required ResponseData data}) async {
    return data;
  }
}

class ExpiredTokenRetryPolicy extends RetryPolicy {
  final IStorage storage;
  final AuthenticationRepository authRepository;
  @override
  int maxRetryAttempts = 1;

  ExpiredTokenRetryPolicy(
      {required this.authRepository, required this.storage});

  @override
  Future<bool> shouldAttemptRetryOnResponse(ResponseData response) async {
    if (response.statusCode == 401) {
      await regenerateToken();
      return true;
    }
    return false;
  }

  Future<void> regenerateToken() async {
    final token = Token.deserialize(await storage.readSecureData('token'));

    await authRepository.getRefreshToken(token: token);

    final Token? newToken =
        Token.deserialize(await storage.readSecureData('token'));

    if (newToken == null) {
      authRepository.sessionExpired();
      throw Exception();
    }
  }
}

class WarehouseApiClient {
  final IStorage storage;
  final API_URL = dotenv.env['API_URL']!;
  final http.Client client;
  WarehouseApiClient(this.storage, this.authRepository)
      : client = InterceptedClient.build(
          interceptors: [AuthorizationInterceptor(storage: storage)],
          retryPolicy: ExpiredTokenRetryPolicy(
              authRepository: authRepository, storage: storage),
        );

  final AuthenticationRepository authRepository;

  Future<List<Material>> getMaterial() async {
    final request = Uri.http(API_URL, '/api/material');

    final token = await storage.readSecureData('token');

    final deserialized = Token.deserialize(token);

    final accessToken = deserialized.accessToken;

    http.Response response = await client.get(
      request,
      headers: {
        'Authorization': 'Bearer $accessToken',
        'Accept': 'application/json',
        'Content-Type': 'application/json',
      },
    ).timeout(Duration(seconds: 15));

    switch (response.statusCode) {
      case 200:
        final materialJson = jsonDecode(response.body) as List;
        final list = materialJson
            .map((item) => Material.fromJson(item as Map<String, dynamic>))
            .toList();
        return list;
      default:
        throw Exception();
    }
  }
}

test\my_test.dart

import 'dart:io';

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:testing_app/authentication_repository.dart';
import 'package:testing_app/storage_service.dart';
import 'package:testing_app/token.dart';
import 'package:testing_app/warehouse_api_client.dart';

class MockHttpClient extends Mock implements http.Client {}

class MockResponse extends Mock implements http.Response {}

class FakeUri extends Fake implements Uri {}

class MockAuthenticationRepository extends Mock
    implements AuthenticationRepository {}

class MockStorage extends Mock implements IStorage {}

Future<void> main() async {
  await dotenv.load(fileName: '.env.development');
  group('WarehouseApiClient', () {
    late http.Client httpClient;
    late WarehouseApiClient apiClient;
    late MockAuthenticationRepository authenticationRepository;
    late MockStorage storage;
    final apiUrl = dotenv.env['API_URL']!;

    setUpAll(() {
      registerFallbackValue(FakeUri());
    });

    setUp(() {
      httpClient = MockHttpClient();
      authenticationRepository = MockAuthenticationRepository();
      storage = MockStorage();
      apiClient = WarehouseApiClient(
        storage,
        authenticationRepository,
      );
      when(() => authenticationRepository.status)
          .thenAnswer((_) => const Stream.empty());
    });

    group('getMaterial', () {
      test('makes correct http request', () async {
        final response = MockResponse();
        when(() => response.statusCode).thenReturn(200);
        when(() => response.body).thenReturn('{}');
        when(() => httpClient.get(any(), headers: any(named: 'headers')))
            .thenAnswer((_) async => response);
        when(() => storage.readSecureData(any())).thenAnswer(
          (_) async => """{
                "accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoibSIsImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWVpZGVudGlmaWVyIjoiOSIsImVtYWlsIjoibSIsInN1YiI6Im0iLCJqdGkiOiI2YjdlZWRlNy1jMDJjLTRiNzMtYTMxYS01ZTYwNTA1NTkzMWYiLCJleHAiOjE2NjkxMDI2OTEsImlzcyI6Imh0dHA6Ly8xMjcuMC4wLjE6NTAwMSIsImF1ZCI6InVzZXIifQ.q14peCD-pfEhu9zm1JeAVps-WxHhriruGLadu3QNzeY",
                "refreshToken":"24e57093-ccac-489c-bc83-27cf4ca36285-29c13587-88e9-4f25-87f9-9d2134d845e2",
                "expiresAt":"2022-11-22T07:38:11Z"
              }""",
        );
        try {
          await apiClient.getMaterial();
        } catch (e) {
          print(e);
        }
        final uri = Uri.https(
          apiUrl,
          '/api/material',
        );
        verify(
          () => httpClient.get(uri),
        ).called(1);
      });
    });
  });
}

.env.development

API_URL=10.0.2.2:5001

pubspec.yaml

name: testing_app
description: A new Flutter project.

# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=2.18.4 <3.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter
  http_interceptor: ^1.0.2
  flutter_secure_storage: ^6.0.0
  flutter_dotenv: ^5.0.2
  http: ^0.13.0

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  mocktail: ^0.3.0
  build_runner: ^2.0.0
  json_serializable: ^6.0.0

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true
  assets:
    - .env.development

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages

Expected behavior
I want to test my api where I use http client with interceptor but when I run test the async method takes few seconds and return error that I have mentioned above.

Please complete the following information):
[√] Flutter (Channel stable, 3.3.8, on Microsoft Windows [Version 10.0.19042.2194], locale pl-PL)
[!] Android toolchain - develop for Android devices (Android SDK version 33.0.0-rc4)
! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses
[√] Chrome - develop for the web
[!] Visual Studio - develop for Windows (Visual Studio Community 2022 17.4.1)
X Visual Studio is missing necessary components. Please re-run the Visual Studio installer for the "Desktop development with C++" workload, and include these components:
MSVC v142 - VS 2019 C++ x64/x86 build tools
- If there are multiple build tool versions available, install the latest
C++ CMake tools for Windows
Windows 10 SDK
[√] Android Studio (version 2021.3)
[√] IntelliJ IDEA Community Edition (version 2022.1)
[√] VS Code (version 1.73.1)
[√] Connected device (3 available)
[√] HTTP Host Availability

! Doctor found issues in 2 categories.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions