Compass App: WIP Auth logic refactor (#2394)

This PR follows up with the conversation we had via email and cleans up
the auth implementation.

**New `AuthRepository`**

The `AuthTokenRepository`, `AuthLoginComponent` and
`AuthLogoutComponent` have been refactored into a single
`AuthRepository`.

This `AuthRepository` exposes the `isAuthenticated` boolean, which is
used by `go_router` to handle redirects.

Also, has two methods, `login(email, pass)` and `logout()`, similar to
what we had in the components.

The implementation uses the new `SharedPreferencesService`, which is a
service to access the `shared_preferences` plugin to store the `String`
token.

**New `AuthApiClient`**

The `ApiClient` has been split in two. The `/login` REST call is moved
to the `AuthApiClient`, while the other calls that require an auth token
remain in the `ApiClient`.

**`ApiClient` credentials**

The main open question is, what is the best way to pass the auth token
to the api client?

I've found several alternatives:

1. `ApiClient` has a `token` setter which the `AuthRepository` calls to
set the token
  - Implemented in this PR.
  - Follows our architecture design.
- `AuthRepository` should be the source of truth, but the
`ApiClient.token` could be set externally by mistake, so the single
source of truth principle gets blurry.
2. `ApiClient` calls to the `AuthRepository` to obtain the token.
  - This is what the code was doing before.
  - `AuthRepository` is the source of truth.
- But the dependency "Service to Repository" breaks our architecture
design principle.
3. `ApiClient` calls to the `SharedPreferencesService` to obtain the
token (
- `SharedPreferencesService` is the source of truth, as the `ApiClient`
doesn't need the `AuthRepository`.
- We see a "Service calling a Service" which maybe breaks our
architecture design principle?
4. The `ApiClient` doesn't hold the token at all, intead, each call
requires us to pass a token.
  - Tried to implement it but becomes very complex.
- Makes other repositories depend on the `AuthRepository` or the
`SharedPreferencesService`.

Personal take: I have implemented 1 in this PR because it is the one
that follows best the architecture recommendations. However I see some
pitfalls, e.g. the `AuthRepository` needs to fetch the token from the
`SharedPreferencesService` at least once on app start and pass it to the
`ApiClient`. While on the solution 2 that's never a problem, since the
`AuthRepository` is being called from the `ApiClient` and so it is
always the source of truth for authentication.

I am looking forward for your thoughts @ericwindmill 

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-devrel
channel on [Discord].

<!-- Links -->
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md
[CLA]: https://cla.developers.google.com/
[Discord]:
https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md
[Contributors Guide]:
https://github.com/flutter/samples/blob/main/CONTRIBUTING.md
pull/2444/head
Miguel Beltran 2 years ago committed by GitHub
parent 56bf31fa21
commit 3be6873210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,14 +1,14 @@
import 'package:provider/single_child_widget.dart';
import 'package:provider/provider.dart';
import '../domain/components/auth/auth_login_component.dart';
import '../domain/components/auth/auth_logout_component.dart';
import '../data/repositories/auth/auth_repository.dart';
import '../data/repositories/auth/auth_repository_dev.dart';
import '../data/repositories/auth/auth_repository_remote.dart';
import '../data/services/auth_api_client.dart';
import '../data/services/shared_preferences_service.dart';
import '../data/repositories/activity/activity_repository.dart';
import '../data/repositories/activity/activity_repository_local.dart';
import '../data/repositories/activity/activity_repository_remote.dart';
import '../data/repositories/auth/auth_token_repository.dart';
import '../data/repositories/auth/auth_token_repository_dev.dart';
import '../data/repositories/auth/auth_token_repository_shared_prefs.dart';
import '../data/repositories/continent/continent_repository.dart';
import '../data/repositories/continent/continent_repository_local.dart';
import '../data/repositories/continent/continent_repository_remote.dart';
@ -34,30 +34,27 @@ List<SingleChildWidget> _sharedProviders = [
lazy: true,
create: (context) => BookingShareComponent.withSharePlus(),
),
Provider(
lazy: true,
create: (context) => AuthLogoutComponent(
authTokenRepository: context.read(),
itineraryConfigRepository: context.read(),
),
),
];
/// Configure dependencies for remote data.
/// This dependency list uses repositories that connect to a remote server.
List<SingleChildWidget> get providersRemote {
return [
ChangeNotifierProvider.value(
value: AuthTokenRepositorySharedPrefs() as AuthTokenRepository,
Provider(
create: (context) => AuthApiClient(),
),
Provider(
create: (context) => ApiClient(authTokenRepository: context.read()),
create: (context) => ApiClient(),
),
Provider(
create: (context) => AuthLoginComponent(
authTokenRepository: context.read(),
create: (context) => SharedPreferencesService(),
),
ChangeNotifierProvider(
create: (context) => AuthRepositoryRemote(
authApiClient: context.read(),
apiClient: context.read(),
),
sharedPreferencesService: context.read(),
) as AuthRepository,
),
Provider(
create: (context) => DestinationRepositoryRemote(
@ -87,7 +84,7 @@ List<SingleChildWidget> get providersRemote {
List<SingleChildWidget> get providersLocal {
return [
ChangeNotifierProvider.value(
value: AuthTokenRepositoryDev() as AuthTokenRepository,
value: AuthRepositoryDev() as AuthRepository,
),
Provider.value(
value: DestinationRepositoryLocal() as DestinationRepository,

@ -0,0 +1,18 @@
import 'package:flutter/foundation.dart';
import '../../../utils/result.dart';
abstract class AuthRepository extends ChangeNotifier {
/// Returns true when the user is logged in
/// Returns [Future] because it will load a stored auth state the first time.
Future<bool> get isAuthenticated;
/// Perform login
Future<Result<void>> login({
required String email,
required String password,
});
/// Perform logout
Future<Result<void>> logout();
}

@ -0,0 +1,23 @@
import '../../../utils/result.dart';
import 'auth_repository.dart';
class AuthRepositoryDev extends AuthRepository {
/// User is always authenticated in dev scenarios
@override
Future<bool> get isAuthenticated => Future.value(true);
/// Login is always successful in dev scenarios
@override
Future<Result<void>> login({
required String email,
required String password,
}) async {
return Result.ok(null);
}
/// Logout is always successful in dev scenarios
@override
Future<Result<void>> logout() async {
return Result.ok(null);
}
}

@ -0,0 +1,107 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import '../../services/auth_api_client.dart';
import '../../services/shared_preferences_service.dart';
import 'auth_repository.dart';
class AuthRepositoryRemote extends AuthRepository {
AuthRepositoryRemote({
required ApiClient apiClient,
required AuthApiClient authApiClient,
required SharedPreferencesService sharedPreferencesService,
}) : _apiClient = apiClient,
_authApiClient = authApiClient,
_sharedPreferencesService = sharedPreferencesService {
_apiClient.authHeaderProvider = _authHeaderProvider;
}
final AuthApiClient _authApiClient;
final ApiClient _apiClient;
final SharedPreferencesService _sharedPreferencesService;
bool? _isAuthenticated;
String? _authToken;
final _log = Logger('AuthRepositoryRemote');
/// Fetch token from shared preferences
Future<void> _fetch() async {
final result = await _sharedPreferencesService.fetchToken();
switch (result) {
case Ok<String?>():
_authToken = result.value;
_isAuthenticated = result.value != null;
case Error<String?>():
_log.severe(
'Failed to fech Token from SharedPreferences',
result.error,
);
}
}
@override
Future<bool> get isAuthenticated async {
// Status is cached
if (_isAuthenticated != null) {
return _isAuthenticated!;
}
// No status cached, fetch from storage
await _fetch();
return _isAuthenticated ?? false;
}
@override
Future<Result<void>> login({
required String email,
required String password,
}) async {
try {
final result = await _authApiClient.login(
LoginRequest(
email: email,
password: password,
),
);
switch (result) {
case Ok<LoginResponse>():
_log.info('User logged int');
// Set auth status
_isAuthenticated = true;
_authToken = result.value.token;
// Store in Shared preferences
return await _sharedPreferencesService.saveToken(result.value.token);
case Error<LoginResponse>():
_log.warning('Error logging in: ${result.error}');
return Result.error(result.error);
}
} finally {
notifyListeners();
}
}
@override
Future<Result<void>> logout() async {
_log.info('User logged out');
try {
// Clear stored auth token
final result = await _sharedPreferencesService.saveToken(null);
if (result is Error<void>) {
_log.severe('Failed to clear stored auth token');
}
// Clear token in ApiClient
_authToken = null;
// Clear authenticated status
_isAuthenticated = false;
return result;
} finally {
notifyListeners();
}
}
String? _authHeaderProvider() =>
_authToken != null ? 'Bearer $_authToken' : null;
}

@ -1,21 +0,0 @@
import 'package:flutter/foundation.dart';
import '../../../utils/result.dart';
/// Repository to save and get auth token.
/// Notifies listeners when the token changes e.g. user logs out.
abstract class AuthTokenRepository extends ChangeNotifier {
/// Get the token.
/// If the value is null, usually means that the user is logged out.
Future<Result<String?>> getToken();
/// Store the token.
/// Will notifiy listeners.
Future<Result<void>> saveToken(String? token);
/// Returns true when the token exists, otherwise false.
Future<bool> hasToken() async {
final result = await getToken();
return result is Ok<String?> && result.value != null;
}
}

@ -1,16 +0,0 @@
import '../../../utils/result.dart';
import 'auth_token_repository.dart';
/// Development [AuthTokenRepository] that always returns a fake token
class AuthTokenRepositoryDev extends AuthTokenRepository {
@override
Future<Result<String?>> getToken() async {
return Result.ok('token');
}
@override
Future<Result<void>> saveToken(String? token) async {
notifyListeners();
return Result.ok(null);
}
}

@ -3,44 +3,24 @@ import 'dart:io';
import 'package:compass_model/model.dart';
import '../../utils/result.dart';
import '../repositories/auth/auth_token_repository.dart';
typedef AuthTokenProvider = Future<String?> Function();
/// Adds the `Authentication` header to a header configuration.
typedef AuthHeaderProvider = String? Function();
// TODO: Configurable baseurl/host/port
class ApiClient {
ApiClient({
required AuthTokenRepository authTokenRepository,
}) : _authTokenRepository = authTokenRepository;
ApiClient();
/// Provides the auth token to be used in the request
final AuthTokenRepository _authTokenRepository;
AuthHeaderProvider? _authHeaderProvider;
Future<void> _authHeader(HttpHeaders headers) async {
final result = await _authTokenRepository.getToken();
if (result is Ok<String?>) {
if (result.value != null) {
headers.add(HttpHeaders.authorizationHeader, 'Bearer ${result.value}');
}
}
set authHeaderProvider(AuthHeaderProvider authHeaderProvider) {
_authHeaderProvider = authHeaderProvider;
}
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
final client = HttpClient();
try {
final request = await client.post('localhost', 8080, '/login');
request.write(jsonEncode(loginRequest));
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
return Result.ok(LoginResponse.fromJson(jsonDecode(stringData)));
} else {
return Result.error(const HttpException("Login error"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
Future<void> _authHeader(HttpHeaders headers) async {
final header = _authHeaderProvider?.call();
if (header != null) {
headers.add(HttpHeaders.authorizationHeader, header);
}
}

@ -0,0 +1,28 @@
// TODO: Configurable baseurl/host/port
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import '../../utils/result.dart';
class AuthApiClient {
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
final client = HttpClient();
try {
final request = await client.post('localhost', 8080, '/login');
request.write(jsonEncode(loginRequest));
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
return Result.ok(LoginResponse.fromJson(jsonDecode(stringData)));
} else {
return Result.error(const HttpException("Login error"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
}

@ -1,40 +1,36 @@
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../utils/result.dart';
import 'auth_token_repository.dart';
import '../../utils/result.dart';
/// [AuthTokenRepository] that stores the token in Shared Preferences.
/// Provided for demo purposes, consider using a secure store instead.
class AuthTokenRepositorySharedPrefs extends AuthTokenRepository {
class SharedPreferencesService {
static const _tokenKey = 'TOKEN';
String? cachedToken;
@override
Future<Result<String?>> getToken() async {
if (cachedToken != null) return Result.ok(cachedToken);
final _log = Logger('SharedPreferencesService');
Future<Result<String?>> fetchToken() async {
try {
final sharedPreferences = await SharedPreferences.getInstance();
final token = sharedPreferences.getString(_tokenKey);
return Result.ok(token);
_log.finer('Got token from SharedPreferences');
return Result.ok(sharedPreferences.getString(_tokenKey));
} on Exception catch (e) {
_log.warning('Failed to get token', e);
return Result.error(e);
}
}
@override
Future<Result<void>> saveToken(String? token) async {
try {
final sharedPreferences = await SharedPreferences.getInstance();
if (token == null) {
_log.finer('Removed token');
await sharedPreferences.remove(_tokenKey);
} else {
_log.finer('Replaced token');
await sharedPreferences.setString(_tokenKey, token);
}
cachedToken = token;
notifyListeners();
return Result.ok(null);
} on Exception catch (e) {
_log.warning('Failed to set token', e);
return Result.error(e);
}
}

@ -1,41 +0,0 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../utils/result.dart';
import '../../../data/repositories/auth/auth_token_repository.dart';
import '../../../data/services/api_client.dart';
/// Performs user login.
class AuthLoginComponent {
AuthLoginComponent({
required AuthTokenRepository authTokenRepository,
required ApiClient apiClient,
}) : _authTokenRepository = authTokenRepository,
_apiClient = apiClient;
final AuthTokenRepository _authTokenRepository;
final ApiClient _apiClient;
final _log = Logger('AuthLoginComponent');
/// Login with username and password.
/// Performs login with the server and stores the obtained auth token.
Future<Result<void>> login({
required String email,
required String password,
}) async {
final result = await _apiClient.login(
LoginRequest(
email: email,
password: password,
),
);
switch (result) {
case Ok<LoginResponse>():
_log.info('User logged int');
return await _authTokenRepository.saveToken(result.value.token);
case Error<LoginResponse>():
_log.warning('Error logging in: ${result.error}');
return Result.error(result.error);
}
}
}

@ -1,45 +0,0 @@
import 'package:compass_model/model.dart';
import 'package:logging/logging.dart';
import '../../../utils/result.dart';
import '../../../data/repositories/auth/auth_token_repository.dart';
import '../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
/// Performs user logout.
class AuthLogoutComponent {
AuthLogoutComponent({
required AuthTokenRepository authTokenRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _authTokenRepository = authTokenRepository,
_itineraryConfigRepository = itineraryConfigRepository;
final AuthTokenRepository _authTokenRepository;
final ItineraryConfigRepository _itineraryConfigRepository;
final _log = Logger('AuthLogoutComponent');
/// Perform user logout.
///
/// 1. Clears the stored ItineraryConfig.
/// 2. Clears the stored auth token.
///
/// GoRouter will automatically redirect the user to /login
Future<Result<void>> logout() async {
_log.info('User logged out');
// Clear stored ItineraryConfig
var result = await _itineraryConfigRepository
.setItineraryConfig(const ItineraryConfig());
if (result is Error<void>) {
_log.severe('Failed to clear stored ItineraryConfig');
return result;
}
// Clear stored auth token
result = await _authTokenRepository.saveToken(null);
if (result is Error<void>) {
_log.severe('Failed to clear stored auth token');
}
return result;
}
}

@ -2,8 +2,7 @@ import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../domain/components/auth/auth_login_component.dart';
import '../data/repositories/auth/auth_token_repository.dart';
import '../data/repositories/auth/auth_repository.dart';
import '../ui/activities/view_models/activities_viewmodel.dart';
import '../ui/activities/widgets/activities_screen.dart';
import '../ui/auth/login/view_models/login_viewmodel.dart';
@ -20,13 +19,13 @@ import '../ui/search_form/widgets/search_form_screen.dart';
/// Listens to changes in [AuthTokenRepository] to redirect the user
/// to /login when the user logs out.
GoRouter router(
AuthTokenRepository authTokenRepository,
AuthRepository authRepository,
) =>
GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
redirect: _redirect,
refreshListenable: authTokenRepository,
refreshListenable: authRepository,
routes: [
GoRoute(
path: '/',
@ -43,10 +42,7 @@ GoRouter router(
builder: (context, state) {
return LoginScreen(
viewModel: LoginViewModel(
authLoginComponent: AuthLoginComponent(
authTokenRepository: context.read(),
apiClient: context.read(),
),
authRepository: context.read(),
),
);
},
@ -96,7 +92,7 @@ GoRouter router(
// From https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/redirection.dart
Future<String?> _redirect(BuildContext context, GoRouterState state) async {
// if the user is not logged in, they need to login
final bool loggedIn = await context.read<AuthTokenRepository>().hasToken();
final bool loggedIn = await context.read<AuthRepository>().isAuthenticated;
final bool loggingIn = state.matchedLocation == '/login';
if (!loggedIn) {
return '/login';

@ -1,24 +1,24 @@
import 'package:logging/logging.dart';
import '../../../../domain/components/auth/auth_login_component.dart';
import '../../../../data/repositories/auth/auth_repository.dart';
import '../../../../utils/command.dart';
import '../../../../utils/result.dart';
class LoginViewModel {
LoginViewModel({
required AuthLoginComponent authLoginComponent,
}) : _authLoginComponent = authLoginComponent {
required AuthRepository authRepository,
}) : _authRepository = authRepository {
login = Command1<void, (String email, String password)>(_login);
}
final AuthLoginComponent _authLoginComponent;
final AuthRepository _authRepository;
final _log = Logger('LoginViewModel');
late Command1 login;
Future<Result<void>> _login((String, String) credentials) async {
final (email, password) = credentials;
final result = await _authLoginComponent.login(
final result = await _authRepository.login(
email: email,
password: password,
);

@ -1,15 +1,31 @@
import '../../../../domain/components/auth/auth_logout_component.dart';
import 'package:compass_model/model.dart';
import '../../../../data/repositories/auth/auth_repository.dart';
import '../../../../data/repositories/itinerary_config/itinerary_config_repository.dart';
import '../../../../utils/command.dart';
import '../../../../utils/result.dart';
class LogoutViewModel {
LogoutViewModel({
required AuthLogoutComponent authLogoutComponent,
}) : _authLogoutComponent = authLogoutComponent {
required AuthRepository authRepository,
required ItineraryConfigRepository itineraryConfigRepository,
}) : _authLogoutComponent = authRepository,
_itineraryConfigRepository = itineraryConfigRepository {
logout = Command0(_logout);
}
final AuthLogoutComponent _authLogoutComponent;
final AuthRepository _authLogoutComponent;
final ItineraryConfigRepository _itineraryConfigRepository;
late Command0 logout;
Future<Result> _logout() => _authLogoutComponent.logout();
Future<Result> _logout() async {
var result = await _authLogoutComponent.logout();
switch (result) {
case Ok<void>():
// clear stored itinerary config
return _itineraryConfigRepository
.setItineraryConfig(const ItineraryConfig());
case Error<void>():
return result;
}
}
}

@ -56,7 +56,10 @@ class AppSearchBar extends StatelessWidget {
// Display a logout button if at the root route
homeScreen
? LogoutButton(
viewModel: LogoutViewModel(authLogoutComponent: context.read()),
viewModel: LogoutViewModel(
authRepository: context.read(),
itineraryConfigRepository: context.read(),
),
)
: const HomeButton(),
],

@ -0,0 +1,98 @@
import 'package:compass_app/data/repositories/auth/auth_repository_remote.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/fakes/services/fake_api_client.dart';
import '../../../../testing/fakes/services/fake_auth_api_client.dart';
import '../../../../testing/fakes/services/fake_shared_preferences_service.dart';
void main() {
group('AuthRepositoryRemote tests', () {
late FakeApiClient apiClient;
late FakeAuthApiClient authApiClient;
late FakeSharedPreferencesService sharedPreferencesService;
late AuthRepositoryRemote repository;
setUp(() {
apiClient = FakeApiClient();
authApiClient = FakeAuthApiClient();
sharedPreferencesService = FakeSharedPreferencesService();
repository = AuthRepositoryRemote(
apiClient: apiClient,
authApiClient: authApiClient,
sharedPreferencesService: sharedPreferencesService,
);
});
test('fetch on start, has token', () async {
// Stored token in shared preferences
sharedPreferencesService.token = 'TOKEN';
// Create an AuthRepository, should perform initial fetch
final repository = AuthRepositoryRemote(
apiClient: apiClient,
authApiClient: authApiClient,
sharedPreferencesService: sharedPreferencesService,
);
final isAuthenticated = await repository.isAuthenticated;
// True because Token is SharedPreferences
expect(isAuthenticated, isTrue);
// Check auth token
await expectAuthHeader(apiClient, 'Bearer TOKEN');
});
test('fetch on start, no token', () async {
// Stored token in shared preferences
sharedPreferencesService.token = null;
// Create an AuthRepository, should perform initial fetch
final repository = AuthRepositoryRemote(
apiClient: apiClient,
authApiClient: authApiClient,
sharedPreferencesService: sharedPreferencesService,
);
final isAuthenticated = await repository.isAuthenticated;
// True because Token is SharedPreferences
expect(isAuthenticated, isFalse);
// Check auth token
await expectAuthHeader(apiClient, null);
});
test('perform login', () async {
final result = await repository.login(
email: 'EMAIL',
password: 'PASSWORD',
);
expect(result, isA<Ok>());
expect(await repository.isAuthenticated, isTrue);
expect(sharedPreferencesService.token, 'TOKEN');
// Check auth token
await expectAuthHeader(apiClient, 'Bearer TOKEN');
});
test('perform logout', () async {
// logged in status
sharedPreferencesService.token = 'TOKEN';
final result = await repository.logout();
expect(result, isA<Ok>());
expect(await repository.isAuthenticated, isFalse);
expect(sharedPreferencesService.token, null);
// Check auth token
await expectAuthHeader(apiClient, null);
});
});
}
Future<void> expectAuthHeader(FakeApiClient apiClient, String? header) async {
final header = apiClient.authHeaderProvider?.call();
expect(header, header);
}

@ -1,59 +0,0 @@
import 'package:compass_app/domain/components/auth/auth_login_component.dart';
import 'package:compass_app/data/services/api_client.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/model.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/fakes/repositories/fake_auth_token_repository.dart';
import '../../../../testing/fakes/services/fake_api_client.dart';
void main() {
group('AuthLoginComponent test', () {
late AuthLoginComponent authLoginComponent;
late FakeAuthTokenRepository authTokenRepository;
late ApiClient apiClient;
setUp(() {
authTokenRepository = FakeAuthTokenRepository();
apiClient = _ApiClient();
authLoginComponent = AuthLoginComponent(
authTokenRepository: authTokenRepository,
apiClient: apiClient,
);
});
test('should perform login', () async {
// Pass valid email and password
final result = await authLoginComponent.login(
email: 'EMAIL',
password: 'PASSWORD',
);
// Got good response
expect(result, isA<Ok<void>>());
expect(authTokenRepository.token, 'TOKEN');
});
test('should fail to login', () async {
// Pass wrong email and password
final result = await authLoginComponent.login(
email: 'WRONG',
password: 'WRONG',
);
// Got bad response
expect(result, isA<Error<void>>());
expect(result.asError.error.toString(), 'Exception: ERROR');
expect(authTokenRepository.token, null);
});
});
}
class _ApiClient extends FakeApiClient {
@override
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
if (loginRequest.email == 'EMAIL' && loginRequest.password == 'PASSWORD') {
return Result.ok(const LoginResponse(token: 'TOKEN', userId: '1234'));
} else {
return Result.error(Exception('ERROR'));
}
}
}

@ -1,36 +0,0 @@
import 'package:compass_app/domain/components/auth/auth_logout_component.dart';
import 'package:compass_model/model.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../../testing/fakes/repositories/fake_auth_token_repository.dart';
import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart';
void main() {
group('AuthLogoutComponent test', () {
late AuthLogoutComponent authLogoutComponent;
late FakeAuthTokenRepository authTokenRepository;
late FakeItineraryConfigRepository itineraryConfigRepository;
setUp(() {
authTokenRepository = FakeAuthTokenRepository();
itineraryConfigRepository = FakeItineraryConfigRepository(
itineraryConfig: const ItineraryConfig(continent: 'CONTINENT'),
);
authLogoutComponent = AuthLogoutComponent(
authTokenRepository: authTokenRepository,
itineraryConfigRepository: itineraryConfigRepository,
);
});
test('should perform logout', () async {
await authLogoutComponent.logout();
// Token should be removed
expect(authTokenRepository.token, null);
// Itinerary config should be cleared
expect(
itineraryConfigRepository.itineraryConfig,
const ItineraryConfig(),
);
});
});
}

@ -1,4 +1,3 @@
import 'package:compass_app/domain/components/auth/auth_login_component.dart';
import 'package:compass_app/ui/auth/login/view_models/login_viewmodel.dart';
import 'package:compass_app/ui/auth/login/widgets/login_screen.dart';
import 'package:flutter_test/flutter_test.dart';
@ -6,23 +5,19 @@ import 'package:mocktail/mocktail.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';
import '../../../testing/app.dart';
import '../../../testing/fakes/repositories/fake_auth_token_repository.dart';
import '../../../testing/fakes/services/fake_api_client.dart';
import '../../../testing/fakes/repositories/fake_auth_repository.dart';
import '../../../testing/mocks.dart';
void main() {
group('LoginScreen test', () {
late LoginViewModel viewModel;
late MockGoRouter goRouter;
late FakeAuthTokenRepository fakeAuthTokenRepository;
late FakeAuthRepository fakeAuthRepository;
setUp(() {
fakeAuthTokenRepository = FakeAuthTokenRepository();
fakeAuthRepository = FakeAuthRepository();
viewModel = LoginViewModel(
authLoginComponent: AuthLoginComponent(
authTokenRepository: fakeAuthTokenRepository,
apiClient: FakeApiClient(),
),
authRepository: fakeAuthRepository,
);
goRouter = MockGoRouter();
});
@ -47,14 +42,14 @@ void main() {
await loadScreen(tester);
// Repo should have no key
expect(fakeAuthTokenRepository.token, null);
expect(fakeAuthRepository.token, null);
// Perform login
await tester.tap(find.text('Login'));
await tester.pumpAndSettle();
// Repo should have key
expect(fakeAuthTokenRepository.token, 'TOKEN');
expect(fakeAuthRepository.token, 'TOKEN');
// Should navigate to home screen
verify(() => goRouter.go('/')).called(1);

@ -1,4 +1,3 @@
import 'package:compass_app/domain/components/auth/auth_logout_component.dart';
import 'package:compass_app/ui/auth/logout/view_models/logout_viewmodel.dart';
import 'package:compass_app/ui/auth/logout/widgets/logout_button.dart';
import 'package:compass_model/model.dart';
@ -6,30 +5,28 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail_image_network/mocktail_image_network.dart';
import '../../../testing/app.dart';
import '../../../testing/fakes/repositories/fake_auth_token_repository.dart';
import '../../../testing/fakes/repositories/fake_auth_repository.dart';
import '../../../testing/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../../testing/mocks.dart';
void main() {
group('LogoutButton test', () {
late MockGoRouter goRouter;
late FakeAuthTokenRepository fakeAuthTokenRepository;
late FakeAuthRepository fakeAuthRepository;
late FakeItineraryConfigRepository fakeItineraryConfigRepository;
late LogoutViewModel viewModel;
setUp(() {
goRouter = MockGoRouter();
fakeAuthTokenRepository = FakeAuthTokenRepository();
fakeAuthRepository = FakeAuthRepository();
// Setup a token, should be cleared after logout
fakeAuthTokenRepository.token = 'TOKEN';
fakeAuthRepository.token = 'TOKEN';
// Setup an ItineraryConfig with some data, should be cleared after logout
fakeItineraryConfigRepository = FakeItineraryConfigRepository(
itineraryConfig: const ItineraryConfig(continent: 'CONTINENT'));
viewModel = LogoutViewModel(
authLogoutComponent: AuthLogoutComponent(
authTokenRepository: fakeAuthTokenRepository,
itineraryConfigRepository: fakeItineraryConfigRepository,
),
authRepository: fakeAuthRepository,
itineraryConfigRepository: fakeItineraryConfigRepository,
);
});
@ -53,7 +50,7 @@ void main() {
await loadScreen(tester);
// Repo should have a key
expect(fakeAuthTokenRepository.token, 'TOKEN');
expect(fakeAuthRepository.token, 'TOKEN');
// Itinerary config should have data
expect(
fakeItineraryConfigRepository.itineraryConfig,
@ -65,7 +62,7 @@ void main() {
await tester.pumpAndSettle();
// Repo should have no key
expect(fakeAuthTokenRepository.token, null);
expect(fakeAuthRepository.token, null);
// Itinerary config should be cleared
expect(
fakeItineraryConfigRepository.itineraryConfig,

@ -1,4 +1,5 @@
import 'package:compass_app/domain/components/auth/auth_logout_component.dart';
import 'package:compass_app/data/repositories/auth/auth_repository.dart';
import 'package:compass_app/data/repositories/itinerary_config/itinerary_config_repository.dart';
import 'package:compass_app/ui/search_form/view_models/search_form_viewmodel.dart';
import 'package:compass_app/ui/search_form/widgets/search_form_screen.dart';
import 'package:flutter/material.dart';
@ -7,7 +8,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:provider/provider.dart';
import '../../../../testing/app.dart';
import '../../../../testing/fakes/repositories/fake_auth_token_repository.dart';
import '../../../../testing/fakes/repositories/fake_auth_repository.dart';
import '../../../../testing/fakes/repositories/fake_continent_repository.dart';
import '../../../../testing/fakes/repositories/fake_itinerary_config_repository.dart';
import '../../../../testing/mocks.dart';
@ -28,12 +29,12 @@ void main() {
loadWidget(WidgetTester tester) async {
await testApp(
tester,
Provider.value(
value: AuthLogoutComponent(
authTokenRepository: FakeAuthTokenRepository(),
itineraryConfigRepository: FakeItineraryConfigRepository(),
ChangeNotifierProvider.value(
value: FakeAuthRepository() as AuthRepository,
child: Provider.value(
value: FakeItineraryConfigRepository() as ItineraryConfigRepository,
child: SearchFormScreen(viewModel: viewModel),
),
child: SearchFormScreen(viewModel: viewModel),
),
goRouter: goRouter,
);

@ -0,0 +1,26 @@
import 'package:compass_app/data/repositories/auth/auth_repository.dart';
import 'package:compass_app/utils/result.dart';
class FakeAuthRepository extends AuthRepository {
String? token;
@override
Future<bool> get isAuthenticated async => token != null;
@override
Future<Result<void>> login({
required String email,
required String password,
}) async {
token = 'TOKEN';
notifyListeners();
return Result.ok(null);
}
@override
Future<Result<void>> logout() async {
token = null;
notifyListeners();
return Result.ok(null);
}
}

@ -71,7 +71,5 @@ class FakeApiClient implements ApiClient {
}
@override
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
return Result.ok(const LoginResponse(token: 'TOKEN', userId: '1234'));
}
AuthHeaderProvider? authHeaderProvider;
}

@ -0,0 +1,14 @@
import 'package:compass_app/data/services/auth_api_client.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/src/model/auth/login_request/login_request.dart';
import 'package:compass_model/src/model/auth/login_response/login_response.dart';
class FakeAuthApiClient implements AuthApiClient {
@override
Future<Result<LoginResponse>> login(LoginRequest loginRequest) async {
if (loginRequest.email == 'EMAIL' && loginRequest.password == 'PASSWORD') {
return Result.ok(const LoginResponse(token: 'TOKEN', userId: '123'));
}
return Result.error(Exception('ERROR!'));
}
}

@ -1,18 +1,17 @@
import 'package:compass_app/data/repositories/auth/auth_token_repository.dart';
import 'package:compass_app/data/services/shared_preferences_service.dart';
import 'package:compass_app/utils/result.dart';
class FakeAuthTokenRepository extends AuthTokenRepository {
class FakeSharedPreferencesService implements SharedPreferencesService {
String? token;
@override
Future<Result<String?>> getToken() async {
Future<Result<String?>> fetchToken() async {
return Result.ok(token);
}
@override
Future<Result<void>> saveToken(String? token) async {
this.token = token;
notifyListeners();
return Result.ok(null);
}
}
Loading…
Cancel
Save