diff --git a/lib/core/extensions/task_extensions.dart b/lib/core/extensions/task_extensions.dart new file mode 100644 index 000000000..9f13d1b85 --- /dev/null +++ b/lib/core/extensions/task_extensions.dart @@ -0,0 +1,14 @@ +import 'package:fpdart/fpdart.dart'; + +extension TaskX on Task { + /// Ensure that this task takes at least [duration] to complete. + /// + /// If the task completes before [duration], the returned task will + /// wait for the remaining time before returning the result. + Task withMinimumDuration(Duration duration) => Task(() async { + final minimumDurationTask = Future.delayed(duration); + final result = await run(); + await minimumDurationTask; + return result; + }); +} diff --git a/lib/core/throttler.dart b/lib/core/throttler.dart index 1c1207bfa..8ed26c9d0 100644 --- a/lib/core/throttler.dart +++ b/lib/core/throttler.dart @@ -2,7 +2,8 @@ import 'package:fpdart/fpdart.dart'; /// A utility class for throttling the execution of asynchronous tasks. /// -/// This class allows you to limit the rate at which a task is executed by +/// {@template throttler} +/// A [Throttler] allows you to limit the rate at which a task is executed by /// ensuring that only one task runs through the [Throttler] at any given time. /// If a task is already running, subsequent calls to execute a task will /// instead return the currently running task. @@ -36,7 +37,11 @@ import 'package:fpdart/fpdart.dart'; /// // 5 /// } /// ``` +/// {@endtemplate} class Throttler { + /// Creates a new [Throttler] instance for [Future]s that return a [T]. + /// + /// {@macro throttler} Throttler(); Future? _storedTask; @@ -52,9 +57,10 @@ class Throttler { } extension ThrottlerX on Task { - /// Throttles this task using the given [throttler]. + /// Adds the given [throttler] to this task. /// /// If no task is currently running through the [throttler], starts this task /// and stores it. Otherwise, returns the currently running task. - Future runThrottled(Throttler throttler) => throttler.throttle(this); + Task throttleWith(Throttler throttler) => + Task(() => throttler.throttle(this)); } diff --git a/lib/service_locator.dart b/lib/service_locator.dart index 2d4e52d7f..34bb7c351 100644 --- a/lib/service_locator.dart +++ b/lib/service_locator.dart @@ -351,7 +351,11 @@ void initRegister() { void initHttp() { ignoreValue( sl.registerSingleton( - RetryAuthenticator.uninitialized(serviceLocator: sl), + RetryAuthenticator.uninitialized( + repository: sl(), + cubit: sl(), + logger: sl(), + ), ), ); diff --git a/lib/src/authentication/authentication.dart b/lib/src/authentication/authentication.dart index 2d091e19e..a4c424f29 100644 --- a/lib/src/authentication/authentication.dart +++ b/lib/src/authentication/authentication.dart @@ -1,5 +1,6 @@ export 'bloc/authentication_cubit.dart'; export 'http_utils/authentication_interceptor.dart'; export 'http_utils/retry_authenticator.dart'; +export 'http_utils/with_bearer_token.dart'; export 'models/authentication_info.dart'; export 'repository.dart'; diff --git a/lib/src/authentication/http_utils/authentication_interceptor.dart b/lib/src/authentication/http_utils/authentication_interceptor.dart index 75b1a47ff..c30cdc0f5 100644 --- a/lib/src/authentication/http_utils/authentication_interceptor.dart +++ b/lib/src/authentication/http_utils/authentication_interceptor.dart @@ -11,14 +11,11 @@ class AuthenticationInterceptor implements chopper.RequestInterceptor { final AuthenticationRepository repository; @override - FutureOr onRequest(chopper.Request request) { - return repository.getAuthenticationInfo().match( - () => request, - (authenticationInfo) { - final updatedHeaders = Map.of(request.headers); - updatedHeaders['Authorization'] = 'Bearer ${authenticationInfo.token}'; - return request.copyWith(headers: updatedHeaders); - }, - ).run(); + FutureOr onRequest(chopper.Request originalRequest) { + return repository + .getAuthenticationToken() + .map(originalRequest.withBearerToken) + .getOrElse(() => originalRequest) + .run(); } } diff --git a/lib/src/authentication/http_utils/retry_authenticator.dart b/lib/src/authentication/http_utils/retry_authenticator.dart index 15cca704c..fd84eee3c 100644 --- a/lib/src/authentication/http_utils/retry_authenticator.dart +++ b/lib/src/authentication/http_utils/retry_authenticator.dart @@ -1,23 +1,26 @@ import 'dart:async'; -import 'package:chopper/chopper.dart' as chopper; +import 'package:chopper/chopper.dart'; +import 'package:coffeecard/core/extensions/task_extensions.dart'; import 'package:coffeecard/core/throttler.dart'; import 'package:coffeecard/features/authentication.dart'; import 'package:coffeecard/features/login/data/datasources/account_remote_data_source.dart'; import 'package:coffeecard/generated/api/coffeecard_api.models.swagger.dart' show LoginDto; import 'package:fpdart/fpdart.dart'; -import 'package:get_it/get_it.dart'; import 'package:logger/logger.dart'; -class RetryAuthenticator extends chopper.Authenticator { +class RetryAuthenticator extends Authenticator { /// Creates a new [RetryAuthenticator] instance. /// /// This instance is not ready to be used. Call [initialize] before using it. - RetryAuthenticator.uninitialized({required GetIt serviceLocator}) - : _repository = serviceLocator(), - _cubit = serviceLocator(), - _logger = serviceLocator(); + RetryAuthenticator.uninitialized({ + required AuthenticationRepository repository, + required AuthenticationCubit cubit, + required Logger logger, + }) : _repository = repository, + _cubit = cubit, + _logger = logger; final AuthenticationRepository _repository; final AuthenticationCubit _cubit; @@ -26,30 +29,30 @@ class RetryAuthenticator extends chopper.Authenticator { // Will be set by [initialize]. late final AccountRemoteDataSource _accountRemoteDataSource; - final _throttler = Throttler>(); - bool _initialized = false; + final _initializationCompleter = Completer(); + final _throttler = Throttler(); /// Initializes the [RetryAuthenticator] by providing the /// [AccountRemoteDataSource] to use. /// /// This method must be called before the [RetryAuthenticator] is used. void initialize(AccountRemoteDataSource accountRemoteDataSource) { - _initialized = true; + _initializationCompleter.complete(); _accountRemoteDataSource = accountRemoteDataSource; } @override - Future authenticate( - chopper.Request request, - chopper.Response response, [ - chopper.Request? _, - ]) { - // If the [ReactivationAuthenticator] is not ready, an error is thrown. - assert( - _initialized, - 'ReactivationAuthenticator is not ready. ' - 'Call initialize() before using it.', - ); + Future authenticate( + Request request, + Response response, [ + Request? _, + ]) async { + if (!_initializationCompleter.isCompleted) { + throw StateError( + 'This RetryAuthenticator is not ready. ' + 'Call initialize() before using it.', + ); + } // If the response is not unauthorized, we don't need to do anything. if (response.statusCode != 401) { @@ -63,33 +66,21 @@ class RetryAuthenticator extends chopper.Authenticator { return Future.value(); } - // Try to refresh the token. - final maybeNewToken = Task(() async { - // Set a minimum duration for the token refresh to allow for throttling. - final minimumDuration = Future.delayed(const Duration(milliseconds: 250)); - - final maybeNewAuthenticationInfo = await _retryLogin(request).run(); - await minimumDuration; - - // Side effect: save the new token or evict the user. - final _ = maybeNewAuthenticationInfo.match( - _evictUser, - _saveNewAuthenticationInfo, - ); - return maybeNewAuthenticationInfo; - }).runThrottled(_throttler); - - final maybeNewRequest = TaskOption(() => maybeNewToken).match( - () => null, - (info) => request..headers['Authorization'] = 'Bearer ${info.token}', - ); - - return maybeNewRequest.run(); + return _retryLogin(request) + .match( + () => _onRetryFailed(), + (newAuthInfo) => _onRetrySucceeded(newAuthInfo, request), + ) + // Flatten; transform the Task> into a Task. + .flatMap(identity) + .withMinimumDuration(const Duration(milliseconds: 250)) + .throttleWith(_throttler) + .run(); } /// Attempt to retrieve new [AuthenticationInfo] by logging in with the /// stored credentials. - TaskOption _retryLogin(chopper.Request request) { + TaskOption _retryLogin(Request request) { _logger.d( 'Token refresh triggered by request:\n\t${request.method} ${request.url}', ); @@ -108,16 +99,20 @@ class RetryAuthenticator extends chopper.Authenticator { ); } - Task _saveNewAuthenticationInfo(AuthenticationInfo newInfo) { - return _repository - .saveAuthenticationInfo(newInfo) - .map((_) => _logger.d('Successfully refreshed token.')) - .map((_) => unit); - } - - Task _evictUser() { + /// Handles the side effects of a failed token refresh and returns null. + Task _onRetryFailed() { _logger.e('Failed to refresh token. Signing out.'); _cubit.unauthenticated(); - return Task.of(unit); + return _repository.clearAuthenticationInfo().map((_) => null); + } + + /// Handles the side effects of a successful token refresh and returns the + /// new [Request] with the updated token. + Task _onRetrySucceeded(AuthenticationInfo newInfo, Request req) { + _logger.d('Successfully refreshed token. Retrying request.'); + + return _repository + .saveAuthenticationInfo(newInfo) + .map((_) => req.withBearerToken(newInfo.token)); } } diff --git a/lib/src/authentication/http_utils/with_bearer_token.dart b/lib/src/authentication/http_utils/with_bearer_token.dart new file mode 100644 index 000000000..bd31afa1f --- /dev/null +++ b/lib/src/authentication/http_utils/with_bearer_token.dart @@ -0,0 +1,8 @@ +import 'package:chopper/chopper.dart'; + +extension RequestX on Request { + /// Returns a copy of this [Request] with the given [token] added to the + /// `Authorization` header. + Request withBearerToken(String token) => + this..headers['Authorization'] = 'Bearer $token'; +} diff --git a/lib/src/authentication/repository.dart b/lib/src/authentication/repository.dart index 069a31ab2..620171bbe 100644 --- a/lib/src/authentication/repository.dart +++ b/lib/src/authentication/repository.dart @@ -28,6 +28,10 @@ class AuthenticationRepository { return store.getAsTaskOption(_authenticationInfoKey); } + TaskOption getAuthenticationToken() { + return getAuthenticationInfo().map((info) => info.token); + } + Task clearAuthenticationInfo() { return store .clearAsTask() diff --git a/test/core/throttler_test.dart b/test/core/throttler_test.dart index f9cebe5da..9b0ed2620 100644 --- a/test/core/throttler_test.dart +++ b/test/core/throttler_test.dart @@ -28,7 +28,7 @@ void main() { 'WHEN a task is throttled ' 'THEN it should execute the task', () async { - await incrementTask.runThrottled(throttler); + await incrementTask.throttleWith(throttler).run(); expect(counter, 1); }, ); @@ -40,10 +40,11 @@ void main() { () async { // Throttle a task that will wait for the completer to complete, // and then increment the counter. - final task1 = incrementAndWaitTask(completer).runThrottled(throttler); + final task1 = + incrementAndWaitTask(completer).throttleWith(throttler).run(); // Throttle a task that will increment the counter immediately. - final task2 = incrementTask.runThrottled(throttler); + final task2 = incrementTask.throttleWith(throttler).run(); // Since task2 was run while task1 was still running, // their futures should be identical. @@ -61,13 +62,14 @@ void main() { () async { // Throttle a task that will wait for the completer to complete, // and then increment the counter. - final task1 = incrementAndWaitTask(completer).runThrottled(throttler); + final task1 = + incrementAndWaitTask(completer).throttleWith(throttler).run(); // Throttle another task that will increment the counter immediately. - final task2 = incrementTask.runThrottled(throttler); + final task2 = incrementTask.throttleWith(throttler).run(); // Throttle one more task that will increment the counter immediately. - final task3 = incrementTask.runThrottled(throttler); + final task3 = incrementTask.throttleWith(throttler).run(); // Since task2 and task3 were run while task1 was still running, // all futures should be identical. @@ -89,10 +91,10 @@ void main() { 'THEN it should execute the task', () async { // Throttle a task and wait for completion. - await incrementTask.runThrottled(throttler); + await incrementTask.throttleWith(throttler).run(); // Throttle another task and wait for completion. - await incrementTask.runThrottled(throttler); + await incrementTask.throttleWith(throttler).run(); // The counter should have incremented twice. expect(counter, 2); diff --git a/test/features/authentication/bloc/authentication_cubit_test.dart b/test/features/authentication/bloc/authentication_cubit_test.dart index ba36e96e8..795d680b2 100644 --- a/test/features/authentication/bloc/authentication_cubit_test.dart +++ b/test/features/authentication/bloc/authentication_cubit_test.dart @@ -7,13 +7,13 @@ import 'package:mockito/mockito.dart'; import 'authentication_cubit_test.mocks.dart'; -@GenerateMocks([AuthenticationRepository]) +@GenerateNiceMocks([MockSpec()]) void main() { late AuthenticationCubit cubit; late MockAuthenticationRepository repo; - provideDummy>(TaskOption.none()); - provideDummy>(Task.of(unit)); + provideDummy(TaskOption.none()); + provideDummy(Task.of(unit)); setUp(() { repo = MockAuthenticationRepository(); @@ -21,10 +21,10 @@ void main() { provideDummy>(none()); }); - const testAuthenticationInfo = AuthenticationInfo( - email: 'email', - token: 'token', - encodedPasscode: 'encodedPasscode', + const testAuthInfo = AuthenticationInfo( + email: 'a', + token: 'b', + encodedPasscode: 'c', ); test('initial state is AuthenticationState.unknown', () { @@ -33,38 +33,40 @@ void main() { group('appStarted', () { blocTest( - 'should emit [Unauthenticated] when no user is stored', + 'GIVEN no authentication info is stored ' + 'WHEN appStarted is called ' + 'THEN emit [AuthenticationState.unauthenticated()]', build: () => cubit, - setUp: () => when(repo.getAuthenticationInfo()) - .thenAnswer((_) => TaskOption.none()), + setUp: () => + when(repo.getAuthenticationInfo()).thenReturn(TaskOption.none()), act: (_) => cubit.appStarted(), expect: () => [const AuthenticationState.unauthenticated()], ); blocTest( - 'should emit [Authenticated] when a user is stored', + 'GIVEN authentication info is stored ' + 'WHEN appStarted is called ' + 'THEN emit [AuthenticationState.authenticated]', build: () => cubit, setUp: () => when(repo.getAuthenticationInfo()) - .thenAnswer((_) => TaskOption.some(testAuthenticationInfo)), + .thenReturn(TaskOption.of(testAuthInfo)), act: (_) => cubit.appStarted(), - expect: () => [ - const AuthenticationState.authenticated(testAuthenticationInfo), - ], + expect: () => [const AuthenticationState.authenticated(testAuthInfo)], ); }); group('authenticated', () { blocTest( - 'should emit [Authenticated] and save the user to storage', + 'GIVEN an authentication info ' + 'WHEN authenticated is called ' + 'THEN emit [AuthenticationState.authenticated] and save the info', build: () => cubit, - setUp: () => when(repo.saveAuthenticationInfo(testAuthenticationInfo)) - .thenAnswer((_) => Task.of(unit)), - act: (_) => cubit.authenticated(testAuthenticationInfo), - expect: () => - [const AuthenticationState.authenticated(testAuthenticationInfo)], - verify: (_) => verify( - repo.saveAuthenticationInfo(testAuthenticationInfo), - ), + setUp: () => when(repo.saveAuthenticationInfo(testAuthInfo)) + .thenReturn(Task.of(unit)), + act: (_) => cubit.authenticated(testAuthInfo), + expect: () => [const AuthenticationState.authenticated(testAuthInfo)], + verify: (_) => + verify(repo.saveAuthenticationInfo(testAuthInfo)).called(1), ); }); @@ -73,10 +75,10 @@ void main() { 'should emit [Unauthenticated] and clear the user from storage', build: () => cubit, setUp: () => - when(repo.clearAuthenticationInfo()).thenAnswer((_) => Task.of(unit)), + when(repo.clearAuthenticationInfo()).thenReturn(Task.of(unit)), act: (_) => cubit.unauthenticated(), expect: () => [const AuthenticationState.unauthenticated()], - verify: (_) => verify(repo.clearAuthenticationInfo()), + verify: (_) => verify(repo.clearAuthenticationInfo()).called(1), ); }); } diff --git a/test/features/authentication/http_utils/authentication_interceptor_test.dart b/test/features/authentication/http_utils/authentication_interceptor_test.dart index 0baaa8714..0c62f8662 100644 --- a/test/features/authentication/http_utils/authentication_interceptor_test.dart +++ b/test/features/authentication/http_utils/authentication_interceptor_test.dart @@ -6,31 +6,31 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'authentication_interceptor_test.mocks.dart'; +import 'test_utils.dart'; -@GenerateMocks([AuthenticationRepository]) +@GenerateNiceMocks([MockSpec()]) void main() { - late MockAuthenticationRepository repo; + late MockAuthenticationRepository repository; late AuthenticationInterceptor interceptor; late Request request; - setUp(() async { + setUp(() { provideDummy>(TaskOption.none()); - repo = MockAuthenticationRepository(); - interceptor = AuthenticationInterceptor(repo); + repository = MockAuthenticationRepository(); + interceptor = AuthenticationInterceptor(repository); request = Request('POST', Uri.parse('url'), Uri.parse('baseurl')); }); test( - 'GIVEN a token in SecureStorage ' + 'GIVEN stored authentication info in AuthenticationRepository ' 'WHEN calling onRequest ' 'THEN Authorization Header is added to the request', - () async { - const token = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; - - when(repo.getAuthenticationInfo()).thenAnswer( - (_) => TaskOption.some( + () { + // arrange + const token = 'a'; + when(repository.getAuthenticationInfo()).thenReturn( + TaskOption.some( const AuthenticationInfo( email: 'email', token: token, @@ -39,21 +39,27 @@ void main() { ), ); - final result = await interceptor.onRequest(request); + // act + final result = interceptor.onRequest(request); - expect(result.headers.containsKey('Authorization'), isTrue); - expect(result.headers['Authorization'], equals('Bearer $token')); + // assert + expect(result, requestHavingAuthHeader(equals('Bearer $token'))); }, ); test( - 'GIVEN no token in SecureStorage ' + 'GIVEN no stored authentication info in AuthenticationRepository ' 'WHEN calling onRequest ' 'THEN no Authorization Header is added to the request', - () async { - when(repo.getAuthenticationInfo()).thenAnswer((_) => TaskOption.none()); - final result = await interceptor.onRequest(request); - expect(result.headers.containsKey('Authorization'), isFalse); + () { + // arrange + when(repository.getAuthenticationInfo()).thenReturn(TaskOption.none()); + + // act + final result = interceptor.onRequest(request); + + // assert + expect(result, requestHavingAuthHeader(isNull)); }, ); } diff --git a/test/features/authentication/http_utils/retry_authenticator_test.dart b/test/features/authentication/http_utils/retry_authenticator_test.dart index d56022990..ff5bbe85f 100644 --- a/test/features/authentication/http_utils/retry_authenticator_test.dart +++ b/test/features/authentication/http_utils/retry_authenticator_test.dart @@ -2,60 +2,50 @@ import 'package:chopper/chopper.dart' as chopper; import 'package:coffeecard/core/errors/failures.dart'; import 'package:coffeecard/features/authentication.dart'; import 'package:coffeecard/features/login/data/datasources/account_remote_data_source.dart'; -import 'package:coffeecard/generated/api/coffeecard_api.swagger.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:get_it/get_it.dart'; import 'package:http/http.dart' as http; import 'package:logger/logger.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'retry_authenticator_test.mocks.dart'; +import 'test_utils.dart'; -@GenerateMocks([ - AuthenticationCubit, - AccountRemoteDataSource, - AuthenticationRepository, - Logger, +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), ]) void main() { - late _FakeGetIt serviceLocator; late MockAuthenticationCubit authenticationCubit; late MockAccountRemoteDataSource accountRemoteDataSource; late MockAuthenticationRepository repository; + late MockLogger logger; late RetryAuthenticator authenticator; setUp(() { - serviceLocator = _FakeGetIt.fromMockedObjects( - authenticationCubit: MockAuthenticationCubit(), - accountRemoteDataSource: MockAccountRemoteDataSource(), - authenticationRepository: MockAuthenticationRepository(), - mockLogger: MockLogger(), + authenticationCubit = MockAuthenticationCubit(); + accountRemoteDataSource = MockAccountRemoteDataSource(); + repository = MockAuthenticationRepository(); + logger = MockLogger(); + + authenticator = RetryAuthenticator.uninitialized( + repository: repository, + cubit: authenticationCubit, + logger: logger, + )..initialize(accountRemoteDataSource); + + provideDummy(TaskOption.none()); + provideDummy( + Either.left(const ConnectionFailure()), ); + provideDummy(Task.of(unit)); - authenticationCubit = serviceLocator.getMock(); - accountRemoteDataSource = - serviceLocator.getMock(); - repository = serviceLocator.getMock(); - - authenticator = - RetryAuthenticator.uninitialized(serviceLocator: serviceLocator); - authenticator.initialize(accountRemoteDataSource); - - provideDummy>(TaskOption.none()); - provideDummy>( - const Left(ConnectionFailure()), - ); - provideDummy>(Task.of(unit)); - - when(repository.clearAuthenticationInfo()).thenAnswer( - (_) => Task.of(unit), - ); - when(repository.saveAuthenticationInfo(any)).thenAnswer( - (_) => Task.of(unit), - ); + when(repository.clearAuthenticationInfo()).thenReturn(Task.of(unit)); + when(repository.saveAuthenticationInfo(any)).thenReturn(Task.of(unit)); }); test( @@ -68,10 +58,10 @@ void main() { final response = _responseFromStatusCode(200); // Act - final result = await authenticator.authenticate(request, response); + final result = authenticator.authenticate(request, response); // Assert - expect(result, isNull); + expect(result, completion(isNull)); }, ); @@ -79,7 +69,7 @@ void main() { 'GIVEN ' '1) a response with status code 401, ' '2) no prior calls to authenticate, ' - 'and 3) no stored login credentials ' + '3) no stored login credentials ' 'WHEN authenticate is called ' 'THEN it should return null', () async { @@ -87,14 +77,13 @@ void main() { final request = _requestFromMethod('GET'); final response = _responseFromStatusCode(401); - when(repository.getAuthenticationInfo()) - .thenAnswer((_) => TaskOption.none()); + when(repository.getAuthenticationInfo()).thenReturn(TaskOption.none()); // Act - final result = await authenticator.authenticate(request, response); + final result = authenticator.authenticate(request, response); // Assert - expect(result, isNull); + expectLater(result, completion(isNull)); }, ); @@ -102,61 +91,47 @@ void main() { 'GIVEN ' '1) response with status code 401, ' '2) no prior calls to authenticate, ' - 'and 3) stored login credentials that are invalid ' + '3) stored login credentials that are invalid ' 'WHEN authenticate is called ' 'THEN ' '1) AccountRemoteDataSource.login should be called with the stored credentials, ' '2) AuthenticationCubit.unauthenticated should be called, ' - 'and 3) it should return null', + '3) authenticate should return null', () async { // Arrange - const email = 'email'; - const encodedPasscode = 'encodedPasscode'; - const token = 'token'; - const reason = 'invalid credentials'; - final loginRequest = chopper.Request( - 'method', - Uri.parse('test'), - Uri.parse('basetest'), - body: const LoginDto( - email: 'email', - password: 'encodedPasscode', - version: 'verison', - ), - ); + const email = 'a'; + const encodedPasscode = 'b'; + const token = 'c'; - when(repository.getAuthenticationInfo()).thenAnswer( - (_) => TaskOption.some( + when(repository.getAuthenticationInfo()).thenReturn( + TaskOption.some( const AuthenticationInfo( email: email, token: token, - encodedPasscode: 'encodedPasscode', + encodedPasscode: encodedPasscode, ), ), ); when(accountRemoteDataSource.login(email, encodedPasscode)).thenAnswer( - (_) async { - // Simulate a failed login attempt through the NetworkRequestExecutor - final _ = await authenticator.authenticate( - loginRequest, - _responseFromStatusCode(401), - ); - return left(const ServerFailure(reason, 500)); - }, + (_) async => Either.left(const ConnectionFailure()), ); final request = _requestFromMethod('GET'); final response = _responseFromStatusCode(401); // Act - final result = await authenticator.authenticate(request, response); + final result = authenticator.authenticate(request, response); // Assert + await result; + // 1 verify(accountRemoteDataSource.login(email, encodedPasscode)).called(1); - verify(authenticationCubit.unauthenticated()).called(1); - expect(result, isNull); verifyNoMoreInteractions(accountRemoteDataSource); + // 2 + verify(authenticationCubit.unauthenticated()).called(1); verifyNoMoreInteractions(authenticationCubit); + // 3 + expect(result, completion(isNull)); }, ); @@ -164,21 +139,21 @@ void main() { 'GIVEN ' '1) a response with status code 401, ' '2) no prior calls to authenticate, ' - 'and 3) valid stored login credentials ' + '3) valid stored login credentials ' 'WHEN authenticate is called ' 'THEN ' '1) AccountRemoteDataSource.login should be called with the stored credentials, ' - '2) SecureStorage.updateToken should be called, ' - 'and 3) it should return a new request with the updated token', + '2) repository should save the new authentication info, ' + '3) authenticate should return a new request with the updated token', () async { // Arrange - const email = 'email'; - const encodedPasscode = 'encodedPasscode'; - const oldToken = 'oldToken'; - const newToken = 'newToken'; + const email = 'a'; + const encodedPasscode = 'b'; + const oldToken = 'c'; + const newToken = 'd'; - when(repository.getAuthenticationInfo()).thenAnswer( - (_) => TaskOption.some( + when(repository.getAuthenticationInfo()).thenReturn( + TaskOption.some( const AuthenticationInfo( email: email, token: oldToken, @@ -192,7 +167,7 @@ void main() { const AuthenticationInfo( email: email, token: newToken, - encodedPasscode: 'encodedPasscode', + encodedPasscode: encodedPasscode, ), ), ); @@ -201,18 +176,19 @@ void main() { final response = _responseFromStatusCode(401); // Act - final result = await authenticator.authenticate(request, response); + final result = authenticator.authenticate(request, response); // Assert + await result; + // 1 verify(accountRemoteDataSource.login(email, encodedPasscode)).called(1); verifyNoMoreInteractions(accountRemoteDataSource); - + // 2 verify(repository.getAuthenticationInfo().run()).called(1); verify(repository.saveAuthenticationInfo(any).run()).called(1); verifyNoMoreInteractions(repository); - - expect(result, isNotNull); - expect(result!.headers['Authorization'], 'Bearer $newToken'); + // 3 + expect(result, requestHavingAuthHeader(equals('Bearer $newToken'))); }, ); @@ -220,20 +196,22 @@ void main() { 'GIVEN ' '1) a response with status code 401, ' '2) a prior call to authenticate is running, ' - 'and 3) and stored valid login credentials exist ' + '3) and stored valid login credentials exist ' 'WHEN authenticate is called ' - 'THEN it should return a new request with the updated token', + 'THEN ' + '1) it should return a new request with the updated token ' + '2) it should not call the login method again', () async { // Arrange - const email = 'email'; - const encodedPasscode = 'encodedPasscode'; - const oldToken = 'oldToken'; + const email = 'a'; + const encodedPasscode = 'b'; + const oldToken = 'c'; int counter = 0; String getNewToken() => '${++counter}'; - when(repository.getAuthenticationInfo()).thenAnswer( - (_) => TaskOption.some( + when(repository.getAuthenticationInfo()).thenReturn( + TaskOption.some( const AuthenticationInfo( email: email, token: oldToken, @@ -247,7 +225,7 @@ void main() { AuthenticationInfo( email: email, token: getNewToken(), - encodedPasscode: 'encodedPasscode', + encodedPasscode: encodedPasscode, ), ), ); @@ -262,14 +240,13 @@ void main() { final call2 = authenticator.authenticate(request, response); // Assert - final result1 = await call1; - expect(result1, isNotNull); - expect(result1!.headers['Authorization'], 'Bearer 1'); - - // Both calls should have the same new token - final result2 = await call2; - expect(result2, isNotNull); - expect(result2!.headers['Authorization'], 'Bearer 1'); + // 1 + expect(call1, requestHavingAuthHeader(equals('Bearer 1'))); + expect(call2, requestHavingAuthHeader(equals('Bearer 1'))); + // 2 + await Future.wait([call1, call2]); + verify(accountRemoteDataSource.login(email, encodedPasscode)).called(1); + verifyNoMoreInteractions(accountRemoteDataSource); }, ); @@ -280,9 +257,9 @@ void main() { 'THEN it should return a new request with the updated token', () async { // Arrange - const email = 'email'; - const encodedPasscode = 'encodedPasscode'; - const newToken = 'newToken'; + const email = 'a'; + const encodedPasscode = 'b'; + const newToken = 'c'; when(repository.getAuthenticationInfo()).thenAnswer( (_) => TaskOption.some( @@ -299,7 +276,7 @@ void main() { const AuthenticationInfo( email: email, token: newToken, - encodedPasscode: 'encodedPasscode', + encodedPasscode: encodedPasscode, ), ), ); @@ -308,11 +285,10 @@ void main() { final response = _responseFromStatusCode(401); // Act - final result = await authenticator.authenticate(request, response); + final result = authenticator.authenticate(request, response); // Assert - expect(result, isNotNull); - expect(result!.headers['Authorization'], 'Bearer $newToken'); + expect(result, requestHavingAuthHeader(equals('Bearer $newToken'))); }, ); } @@ -324,53 +300,3 @@ chopper.Response _responseFromStatusCode(int statusCode, {T? body}) { chopper.Request _requestFromMethod(String method) { return chopper.Request(method, Uri.parse('test'), Uri.parse('basetest')); } - -class _FakeGetIt extends Fake implements GetIt { - _FakeGetIt.fromMockedObjects({ - required this.authenticationCubit, - required this.accountRemoteDataSource, - required this.authenticationRepository, - required this.mockLogger, - }); - - final MockAuthenticationCubit authenticationCubit; - final MockAccountRemoteDataSource accountRemoteDataSource; - final MockAuthenticationRepository authenticationRepository; - final MockLogger mockLogger; - - @override - // We don't care about the parameter types, so we can ignore the warning - // ignore: type_annotate_public_apis - T call({String? instanceName, param1, param2, Type? type}) { - return get( - instanceName: instanceName, - param1: param1, - param2: param2, - type: type, - ); - } - - @override - // We don't care about the parameter types, so we can ignore the warning - // ignore: type_annotate_public_apis - T get({String? instanceName, param1, param2, Type? type}) { - return switch (T) { - const (AuthenticationCubit) => authenticationCubit, - const (AccountRemoteDataSource) => accountRemoteDataSource, - const (AuthenticationRepository) => authenticationRepository, - const (Logger) => mockLogger, - _ => throw UnimplementedError('Mock for $T not implemented.'), - } as T; - } - - /// Given a mocked type, get the mocked object for the given type. - T getMock() { - return switch (T) { - const (MockAuthenticationCubit) => authenticationCubit, - const (MockAccountRemoteDataSource) => accountRemoteDataSource, - const (MockAuthenticationRepository) => authenticationRepository, - const (MockLogger) => mockLogger, - _ => throw UnimplementedError('Mock for $T not implemented.'), - } as T; - } -} diff --git a/test/features/authentication/http_utils/test_utils.dart b/test/features/authentication/http_utils/test_utils.dart new file mode 100644 index 000000000..2b9a13ef6 --- /dev/null +++ b/test/features/authentication/http_utils/test_utils.dart @@ -0,0 +1,14 @@ +import 'package:chopper/chopper.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Asynchronously matches a [Request] with an Authorization header that +/// matches the given [authHeaderMatcher]. +Matcher requestHavingAuthHeader(dynamic authHeaderMatcher) { + return completion( + isA().having( + (request) => request.headers['Authorization'], + 'Authorization header', + authHeaderMatcher, + ), + ); +} diff --git a/test/features/authentication/models/authentication_info_test.dart b/test/features/authentication/models/authentication_info_test.dart index 704591e7c..6796b85f4 100644 --- a/test/features/authentication/models/authentication_info_test.dart +++ b/test/features/authentication/models/authentication_info_test.dart @@ -4,35 +4,43 @@ import 'package:coffeecard/features/authentication.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - const model = AuthenticationInfo( + const testAuthenticationInfo = AuthenticationInfo( email: 'a', token: 'b', encodedPasscode: 'c', ); - group('fromJson', () { - test('should return model', () { + test( + 'GIVEN a json encoded authentication info ' + 'WHEN fromJson is called ' + 'THEN it should return an AuthenticationInfo object with expected values', + () { // arrange - const jsonString = '{"email":"a","token":"b","encodedPasscode":"c"}'; + const jsonEncodedInfo = '{"email":"a","token":"b","encodedPasscode":"c"}'; // act final actual = AuthenticationInfo.fromJson( - json.decode(jsonString) as Map, + json.decode(jsonEncodedInfo) as Map, ); // assert - expect(actual, model); - }); - }); - group('toJson', () { - test('should return map', () { - // act - final actual = model.toJson(); + expect(actual, testAuthenticationInfo); + }, + ); - // assert + test( + 'GIVEN an AuthenticationInfo object ' + 'WHEN toJson is called ' + 'THEN it should return a json encoded map with expected values', + () { + // arrange final expected = {'email': 'a', 'token': 'b', 'encodedPasscode': 'c'}; + // act + final actual = testAuthenticationInfo.toJson(); + + // assert expect(actual, expected); - }); - }); + }, + ); } diff --git a/test/features/authentication/repository.dart b/test/features/authentication/repository_test.dart similarity index 50% rename from test/features/authentication/repository.dart rename to test/features/authentication/repository_test.dart index 54679eb7b..68e91c04b 100644 --- a/test/features/authentication/repository.dart +++ b/test/features/authentication/repository_test.dart @@ -6,12 +6,10 @@ import 'package:logger/logger.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'repository.mocks.dart'; +import 'repository_test.mocks.dart'; @GenerateNiceMocks([MockSpec>(), MockSpec()]) void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - late AuthenticationRepository repo; late MockBox store; late MockLogger logger; @@ -22,7 +20,7 @@ void main() { repo = AuthenticationRepository(store: store, logger: logger); }); - const authenticationInfo = AuthenticationInfo( + const testAuthInfo = AuthenticationInfo( email: 'email', token: 'token', encodedPasscode: 'encodedPasscode', @@ -30,16 +28,16 @@ void main() { group('saveAuthenticationInfo', () { test( - 'should save user object', + 'GIVEN a valid authentication info ' + 'WHEN saveAuthenticationInfo is called ' + 'THEN the authentication info is stored and a log message is written', () async { - // arrange - // act - await repo.saveAuthenticationInfo(authenticationInfo).run(); + await repo.saveAuthenticationInfo(testAuthInfo).run(); // assert verifyInOrder([ - store.put(any, authenticationInfo), + store.put(any, testAuthInfo), logger.d(any), ]); }, @@ -48,51 +46,58 @@ void main() { group('getAuthenticationInfo', () { test( - 'should return user when storage contains key', - () async { + 'GIVEN a stored authentication info ' + 'WHEN getAuthenticationInfo is called ' + 'THEN the authentication info is returned', + () { // arrange - when(store.get(any)).thenAnswer((_) => authenticationInfo); + when(store.get(any)).thenAnswer((_) => testAuthInfo); // act - final actual = await repo.getAuthenticationInfo().run(); + final actual = repo.getAuthenticationInfo().run(); // assert - expect(actual.isSome(), true); - - actual.match( - () {}, - (actual) { - expect(actual.email, authenticationInfo.email); - expect(actual.token, authenticationInfo.token); - expect(actual.encodedPasscode, authenticationInfo.encodedPasscode); - }, + expect( + actual, + completion( + isA>().having( + (some) => some.value, 'AuthenticationInfo', testAuthInfo), + ), ); }, ); test( - 'should return none when storage does not contains key', - () async { + 'GIVEN no stored authentication info ' + 'WHEN getAuthenticationInfo is called ' + 'THEN none is returned', + () { // arrange when(store.get(any)).thenAnswer((_) => null); // act - final actual = await repo.getAuthenticationInfo().run(); + final actual = repo.getAuthenticationInfo().run(); // assert - expect(actual, none()); + expect(actual, completion(isA())); }, ); }); group('clearAuthenticationInfo', () { test( - 'should delete key', + 'GIVEN a stored authentication info ' + 'WHEN clearAuthenticationInfo is called ' + 'THEN store.clear() is called and a log message is written', () async { + // arrange + when(store.get(any)).thenAnswer((_) => testAuthInfo); + // act await repo.clearAuthenticationInfo().run(); // assert - verify(store.clear()); + verify(store.clear()).called(1); + verify(logger.d(any)).called(1); }, ); });