Description
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.