diff --git a/.fvmrc b/.fvmrc index 05b430ce..6108f14a 100644 --- a/.fvmrc +++ b/.fvmrc @@ -1,3 +1,4 @@ { - "flutter": "3.13.9" + "flutter": "3.13.9", + "flavors": {} } \ No newline at end of file diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push index 9a037673..abc91f17 100755 --- a/.github/hooks/pre-push +++ b/.github/hooks/pre-push @@ -2,3 +2,6 @@ printf "\e[33;1m%s\e[0m\n" 'Running the Pre-push checks' ./scripts/checks.sh + +printf "\e[33;1m%s\e[0m\n" 'Running the Pre-push tests' +fvm flutter test diff --git a/Gemfile.lock b/Gemfile.lock index 680a80d6..52d8ef07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -276,6 +276,7 @@ GEM PLATFORMS arm64-darwin-22 arm64-darwin-21 + arm64-darwin-22 x86_64-darwin-19 x86_64-darwin-20 x86_64-linux diff --git a/analysis_options.yaml b/analysis_options.yaml index b6e00af1..fc5b6c48 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -112,18 +112,16 @@ dart_code_metrics: allowed-duplicated-chains: 3 - prefer-trailing-comma - - # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options analyzer: exclude: - - '**/*.freezed.dart' - - '**/*.g.dart' - - '**/*.gen.dart' - - '**/*.gr.dart' - - 'bricks' - - 'lib/generated_plugin_registrant.dart' + - "**/*.freezed.dart" + - "**/*.g.dart" + - "**/*.gen.dart" + - "**/*.gr.dart" + - "bricks" + - "lib/generated_plugin_registrant.dart" errors: invalid_annotation_target: ignore unused_element: ignore # https://github.com/dart-lang/sdk/issues/49025 diff --git a/lib/core/di/di_provider.dart b/lib/core/di/di_provider.dart index 92c6d4c6..de85d0f5 100644 --- a/lib/core/di/di_provider.dart +++ b/lib/core/di/di_provider.dart @@ -1,17 +1,19 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter_template/core/di/app_providers_module.dart'; import 'package:flutter_template/core/di/di_repository_module.dart'; import 'package:flutter_template/core/di/di_utils_module.dart'; import 'package:get_it/get_it.dart'; abstract class DiProvider { - static GetIt get _instance => GetIt.instance; + @visibleForTesting + static GetIt get instance => GetIt.instance; static Future init() async { // Setup app providers have to be done first - await AppProvidersModule().setupModule(_instance); - UtilsDiModule().setupModule(_instance); - RepositoryDiModule().setupModule(_instance); - await _instance.allReady(); + await AppProvidersModule().setupModule(instance); + UtilsDiModule().setupModule(instance); + RepositoryDiModule().setupModule(instance); + await instance.allReady(); } static T get({ @@ -19,5 +21,5 @@ abstract class DiProvider { dynamic param1, dynamic param2, }) => - _instance.get(instanceName: instanceName, param1: param1, param2: param2); + instance.get(instanceName: instanceName, param1: param1, param2: param2); } diff --git a/lib/core/di/di_repository_module.dart b/lib/core/di/di_repository_module.dart index e7e07f2c..13b0c421 100644 --- a/lib/core/di/di_repository_module.dart +++ b/lib/core/di/di_repository_module.dart @@ -26,7 +26,9 @@ class RepositoryDiModule { extension _GetItDiModuleExtensions on GetIt { void _setupProvidersAndUtils() { - registerLazySingleton(() => HttpServiceDio([AuthInterceptor(get())])); + registerLazySingleton( + () => HttpServiceDio([AuthInterceptor(get())]), + ); } void _setupRepositories() { diff --git a/lib/core/source/auth_remote_source.dart b/lib/core/source/auth_remote_source.dart index 0045b0d0..47d9740b 100644 --- a/lib/core/source/auth_remote_source.dart +++ b/lib/core/source/auth_remote_source.dart @@ -3,7 +3,7 @@ import 'package:flutter_template/core/model/service/service_response.dart'; import 'package:flutter_template/core/source/common/http_service.dart'; class AuthRemoteSource { - final HttpServiceDio _httpService; + final HttpService _httpService; static const _urlLogin = 'auth/v1/token'; diff --git a/lib/core/source/project_remote_source.dart b/lib/core/source/project_remote_source.dart index 9c6f6b3d..4809f08b 100644 --- a/lib/core/source/project_remote_source.dart +++ b/lib/core/source/project_remote_source.dart @@ -1,11 +1,11 @@ -import 'package:flutter_template/core/model/service/service_response.dart'; import 'package:flutter_template/core/model/project.dart'; +import 'package:flutter_template/core/model/service/service_response.dart'; import 'package:flutter_template/core/source/common/http_service.dart'; class ProjectRemoteSource { static const _urlGetProjects = 'rest/v1/projects?select=*'; - final HttpServiceDio _httpService; + final HttpService _httpService; ProjectRemoteSource(this._httpService); diff --git a/pubspec.lock b/pubspec.lock index 6e02ddef..42aab396 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.2" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "02f04270be5abae8df171143e61a0058a7acbce5dcac887612e89bb40cca4c33" + url: "https://pub.dev" + source: hosted + version: "9.1.5" boolean_selector: dependency: transitive description: @@ -224,6 +232,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" + url: "https://pub.dev" + source: hosted + version: "1.6.4" crypto: dependency: transitive description: @@ -280,6 +296,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" dio: dependency: "direct main" description: @@ -684,6 +708,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: bac151b31e4ed78bd59ab89aa4c0928f297b1180186d5daf03734519e5f596c1 + url: "https://pub.dev" + source: hosted + version: "1.0.1" mutex: dependency: "direct main" description: @@ -700,6 +732,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -816,10 +856,10 @@ packages: dependency: transitive description: name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + sha256: "9a96a0a19b594dbc5bf0f1f27d2bc67d5f95957359b461cd9feb44ed6ae75096" url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "6.1.1" pub_semver: dependency: transitive description: @@ -916,6 +956,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" shelf_web_socket: dependency: transitive description: @@ -945,6 +1001,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -1033,6 +1105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" + url: "https://pub.dev" + source: hosted + version: "1.24.3" test_api: dependency: transitive description: @@ -1041,6 +1121,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" + url: "https://pub.dev" + source: hosted + version: "0.5.3" time: dependency: transitive description: @@ -1089,6 +1177,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + url: "https://pub.dev" + source: hosted + version: "11.10.0" watcher: dependency: transitive description: @@ -1113,6 +1209,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 22c2b63f..cc79abd7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,12 @@ name: flutter_template description: A new Flutter project. -publish_to: 'none' +publish_to: "none" version: 1.0.0+1 environment: - sdk: '>=3.0.0 <4.0.0' + sdk: ">=3.0.0 <4.0.0" flutter: 3.13.9 dependencies: @@ -54,6 +54,7 @@ dev_dependencies: auto_route_generator: 7.3.1 build_runner: 2.4.6 + bloc_test: 9.1.5 dart_code_metrics: 5.7.6 flutter_flavorizr: 2.2.1 flutter_gen_runner: 5.3.1 @@ -62,6 +63,7 @@ dev_dependencies: freezed: 2.4.1 json_serializable: 6.7.1 lints: 3.0.0 + mocktail: 1.0.1 flutter: generate: true @@ -80,10 +82,10 @@ flutter_gen: flutter_launcher_icons: android: true ios: true - image_path: 'icons/ic_launcher.png' - image_path_ios: 'icons/ic_launcher_ios.png' # Transparency not supported on IOS - adaptive_icon_foreground: 'icons/ic_launcher_foreground.png' - adaptive_icon_background: '#ee1a64' + image_path: "icons/ic_launcher.png" + image_path_ios: "icons/ic_launcher_ios.png" # Transparency not supported on IOS + adaptive_icon_foreground: "icons/ic_launcher_foreground.png" + adaptive_icon_background: "#ee1a64" remove_alpha_ios: true web: generate: false @@ -91,8 +93,8 @@ flutter_launcher_icons: generate: false flutter_native_splash: - color: '#ffffff' - image: 'icons/splash_logo.png' + color: "#ffffff" + image: "icons/splash_logo.png" android_12: - image: 'icons/splash_logo_android_12.png' - branding: 'icons/splash_branding.png' + image: "icons/splash_logo_android_12.png" + branding: "icons/splash_branding.png" diff --git a/scripts/checks.sh b/scripts/checks.sh index fb6868a0..057556f1 100755 --- a/scripts/checks.sh +++ b/scripts/checks.sh @@ -29,3 +29,6 @@ echo ':: Run Catalog checks' echo ':: Run linter catalog ::' fvm flutter analyze catalog || error "Linter error - Flutter Analyze error - Catalog gallery" fvm flutter analyze catalog/gallery || error "Linter error - Flutter Analyze error - Catalog gallery" + +echo ':: Run tests ::' +fvm flutter test || error "Tests failed" diff --git a/test/common/cubit_mocks.dart b/test/common/cubit_mocks.dart new file mode 100644 index 00000000..456e439f --- /dev/null +++ b/test/common/cubit_mocks.dart @@ -0,0 +1,5 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_template/ui/section/error_handler/global_event_handler_cubit.dart'; + +class MockGlobalEventHandlerCubit extends MockCubit + implements GlobalEventHandlerCubit {} diff --git a/test/common/data_mocks.dart b/test/common/data_mocks.dart new file mode 100644 index 00000000..5c937d00 --- /dev/null +++ b/test/common/data_mocks.dart @@ -0,0 +1,29 @@ +import 'package:flutter_template/core/repository/project_repository.dart'; +import 'package:flutter_template/core/repository/session_repository.dart'; +import 'package:flutter_template/core/source/auth_local_source.dart'; +import 'package:flutter_template/core/source/auth_remote_source.dart'; +import 'package:flutter_template/core/source/common/http_service.dart'; +import 'package:flutter_template/core/source/common/local_shared_preferences_storage.dart'; +import 'package:flutter_template/core/source/project_local_source.dart'; +import 'package:flutter_template/core/source/project_remote_source.dart'; +import 'package:mocktail/mocktail.dart'; + +//* Services +class HttpServiceMock extends Mock implements HttpService {} + +//* Data sources +class ProjectLocalSourceMock extends Mock implements ProjectLocalSource {} + +class ProjectRemoteSourceMock extends Mock implements ProjectRemoteSource {} + +class AuthLocalSourceMock extends Mock implements AuthLocalSource {} + +class AuthRemoteSourceMock extends Mock implements AuthRemoteSource {} + +class LocalSharedPreferencesStorageMock extends Mock + implements LocalSharedPreferencesStorage {} + +//* Repositories +class MockSessionRepository extends Mock implements SessionRepository {} + +class MockProjectRepository extends Mock implements ProjectRepository {} diff --git a/test/common/general_helpers.dart b/test/common/general_helpers.dart new file mode 100644 index 00000000..c3b8b8e4 --- /dev/null +++ b/test/common/general_helpers.dart @@ -0,0 +1,11 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_template/core/source/common/app_database.dart'; + +Future setupFloorDatabase() => + $FloorAppDatabase.inMemoryDatabaseBuilder().build(); + +Response successResponse(List elem, RequestOptions requestOptions) => Response( + data: elem.map((e) => e.toJson()).toList(), + statusCode: 200, + requestOptions: requestOptions, + ); diff --git a/test/common/project_helpers.dart b/test/common/project_helpers.dart new file mode 100644 index 00000000..c47c7815 --- /dev/null +++ b/test/common/project_helpers.dart @@ -0,0 +1,26 @@ +import 'package:flutter_template/core/model/db/repository_db_entity.dart'; +import 'package:flutter_template/core/model/project.dart'; + +List generateProjectDbEntities(int count) => Iterable.generate( + count, + (index) => ProjectDbEntity( + id: index, + name: 'Test $index project', + description: 'Test $index project description', + url: 'test$index.com', + imageUrl: '', + language: 'Dart', + ), + ).toList(); + +List generateProjects(int count) => Iterable.generate( + count, + (index) => Project( + id: index, + name: 'Test $index project', + description: 'Test $index project description', + url: 'test$index.com', + imageUrl: '', + language: 'Dart', + ), + ).toList(); diff --git a/test/cubits/signin_cubit_test.dart b/test/cubits/signin_cubit_test.dart new file mode 100644 index 00000000..b9ed1ecb --- /dev/null +++ b/test/cubits/signin_cubit_test.dart @@ -0,0 +1,87 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_template/core/di/di_provider.dart'; +import 'package:flutter_template/core/repository/session_repository.dart'; +import 'package:flutter_template/ui/signin/signin_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../common/cubit_mocks.dart'; +import '../common/data_mocks.dart'; + +void main() { + late GetIt getIt; + late SessionRepository sessionRepository; + late SignInCubit signInCubit; + + setUp(() { + getIt = DiProvider.instance + ..registerSingleton( + sessionRepository = MockSessionRepository(), + ); + }); + tearDown(() => getIt.reset()); + + test('Create SignInCubit should return base state', () { + signInCubit = SignInCubit(MockGlobalEventHandlerCubit()); + + expect( + signInCubit.state, + equals( + const SignInBaseState.state( + email: 'hi@xmartlabs.com', + password: 'xmartlabs', + error: '', + ), + ), + ); + }); + + blocTest( + 'SignInCubit change email should return state with new email', + build: () => signInCubit = SignInCubit(MockGlobalEventHandlerCubit()), + act: (bloc) => bloc.changeEmail('hitest@xmartlabs.com'), + expect: () => [ + const SignInBaseState.state( + email: 'hitest@xmartlabs.com', + password: 'xmartlabs', + error: '', + ), + ], + ); + + blocTest( + 'SignInCubit change password should return state with new password', + build: () => signInCubit = SignInCubit(MockGlobalEventHandlerCubit()), + act: (bloc) => bloc.changePassword('xmartlabs123'), + expect: () => [ + const SignInBaseState.state( + email: 'hi@xmartlabs.com', + password: 'xmartlabs123', + error: '', + ), + ], + ); + + blocTest( + 'SignInCubit signIn method should call signInUser in repository', + setUp: () { + when( + () => sessionRepository.signInUser( + email: 'hi@xmartlabs.com', + password: 'xmartlabs', + ), + ).thenAnswer((_) async {}); + }, + build: () => signInCubit = SignInCubit(MockGlobalEventHandlerCubit()), + act: (bloc) => bloc.signIn(), + verify: (_) { + verify( + () => sessionRepository.signInUser( + email: 'hi@xmartlabs.com', + password: 'xmartlabs', + ), + ).called(1); + }, + ); +} diff --git a/test/cubits/welcome_cubit_test.dart b/test/cubits/welcome_cubit_test.dart new file mode 100644 index 00000000..425a5c7f --- /dev/null +++ b/test/cubits/welcome_cubit_test.dart @@ -0,0 +1,99 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_template/core/di/di_provider.dart'; +import 'package:flutter_template/core/repository/project_repository.dart'; +import 'package:flutter_template/core/repository/session_repository.dart'; +import 'package:flutter_template/ui/welcome/welcome_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../common/cubit_mocks.dart'; +import '../common/data_mocks.dart'; +import '../common/project_helpers.dart'; + +void main() { + late GetIt getIt; + late SessionRepository sessionRepository; + late ProjectRepository projectRepository; + late WelcomeCubit welcomeCubit; + + setUp(() { + getIt = DiProvider.instance + ..registerSingleton( + sessionRepository = MockSessionRepository(), + ) + ..registerSingleton( + projectRepository = MockProjectRepository(), + ); + }); + + tearDown(() => getIt.reset()); + test( + 'Create Welcome cubit, should return a WelcomeCubit with base state', + () { + when(() => projectRepository.getProjects()).thenAnswer( + (_) => Stream.value([]), + ); + welcomeCubit = WelcomeCubit(MockGlobalEventHandlerCubit()); + + expect(welcomeCubit.state, equals(const WelcomeBaseState.state())); + }, + ); + + group('Welcome cubit with loaded projects tests', () { + blocTest( + 'Welcome cubit is created when project repository has data, should have' + ' projects in state', + setUp: () { + when(() => projectRepository.getProjects()).thenAnswer( + (_) => Stream.value(generateProjects(1)), + ); + }, + build: () => welcomeCubit = WelcomeCubit(MockGlobalEventHandlerCubit()), + expect: () => [ + WelcomeBaseState.state( + projects: generateProjects(1), + ), + ], + ); + + blocTest( + 'Welcome cubit is created when project repository is empty, project ' + 'repository stream emits new values then WelcomeCubit should emit a new ' + 'state with projects', + setUp: () { + when(() => projectRepository.getProjects()).thenAnswer( + (_) => Stream.fromIterable([ + generateProjects(1), + generateProjects(2), + ]), + ); + }, + build: () => welcomeCubit = WelcomeCubit(MockGlobalEventHandlerCubit()), + expect: () => [ + WelcomeBaseState.state( + projects: generateProjects(1), + ), + WelcomeBaseState.state( + projects: generateProjects(2), + ), + ], + ); + + blocTest( + // ignore: lines_longer_than_80_chars + 'Welcome cubit calls logOut from sessionRepository when the screen calls logOut method from cubit', + build: () => welcomeCubit = WelcomeCubit(MockGlobalEventHandlerCubit()), + setUp: () { + when(() => sessionRepository.logOut()).thenAnswer( + (_) async {}, + ); + when(() => projectRepository.getProjects()).thenAnswer( + (_) => Stream.value([]), + ); + }, + act: (cubit) => cubit.logOut(), + verify: (_) => verify(() => sessionRepository.logOut()).called(1), + ); + }); +} diff --git a/test/repositories/project/project_local_source_test.dart b/test/repositories/project/project_local_source_test.dart new file mode 100644 index 00000000..17514a6c --- /dev/null +++ b/test/repositories/project/project_local_source_test.dart @@ -0,0 +1,69 @@ +import 'dart:math'; + +import 'package:flutter_template/core/model/db/repository_db_entity.dart'; +import 'package:flutter_template/core/source/common/app_database.dart'; +import 'package:flutter_template/core/source/project_local_source.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../common/general_helpers.dart'; +import '../../common/project_helpers.dart'; + +void main() { + late AppDatabase database; + late ProjectLocalSource projectDao; + + setUp(() async { + database = await setupFloorDatabase(); + projectDao = database.projectLocalSource; + }); + + tearDown(() async { + await database.close(); + }); + + group('Test projects db', () { + test('Get projects from empty db, should return an empty list', () async { + final projects = await projectDao.getProjects().first; + expect(projects, []); + }); + test('Insert one project to empty db, should return 1 project', () async { + final projects = generateProjectDbEntities(1); + await projectDao.insertProjects(projects); + expect( + await projectDao.getProjects().first, + projects, + ); + }); + test('get all projects from db, should return 10', () async { + final projects = generateProjectDbEntities(10); + await projectDao.insertProjects(projects); + expect(await projectDao.getProjects().first, projects); + }); + test('Delete all projects from db, should return empty list', () async { + final projects = generateProjectDbEntities(10); + await projectDao.insertProjects(projects); + await projectDao.deleteAllProjects(); + expect(await projectDao.getProjects().first, []); + }); + test('Replace 2 projects from db, should return two new projects', + () async { + final projects = generateProjectDbEntities(2); + await projectDao.insertProjects(projects); + final replacement = projects + .map( + (e) => ProjectDbEntity( + id: Random().nextInt(100), + name: 'Test replace project', + description: 'Test project description', + url: 'tes.com', + imageUrl: '', + language: 'ES', + ), + ) + .toList(); + await projectDao.replaceProjects(replacement); + final list = await projectDao.getProjects().first; + expect(list, unorderedEquals(replacement)); + }); + }); +} diff --git a/test/repositories/project/project_remote_source_test.dart b/test/repositories/project/project_remote_source_test.dart new file mode 100644 index 00000000..d8f27525 --- /dev/null +++ b/test/repositories/project/project_remote_source_test.dart @@ -0,0 +1,42 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_template/core/source/common/http_service.dart'; +import 'package:flutter_template/core/source/project_remote_source.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../common/data_mocks.dart'; +import '../../common/general_helpers.dart'; +import '../../common/project_helpers.dart'; + +void main() { + late HttpService httpService; + late ProjectRemoteSource projectRemoteSource; + + setUp(() { + httpService = HttpServiceMock(); + projectRemoteSource = ProjectRemoteSource(httpService); + }); + + test('Get projects from API should return one project', () async { + const urlGetProjects = 'rest/v1/projects?select=*'; + final requestOptions = RequestOptions(path: urlGetProjects); + final projects = generateProjects(1); + when(() => httpService.get(urlGetProjects)).thenAnswer( + (_) async => successResponse(projects, requestOptions), + ); + + final result = await projectRemoteSource.getProjects(); + expect(result, projects); + }); + test('Get projects from empty API should return empty', () async { + const urlGetProjects = 'rest/v1/projects?select=*'; + final requestOptions = RequestOptions(path: urlGetProjects); + + when(() => httpService.get(urlGetProjects)).thenAnswer( + (_) async => successResponse([], requestOptions), + ); + + final result = await projectRemoteSource.getProjects(); + expect(result.isEmpty, true); + }); +} diff --git a/test/repositories/project/project_repository_test.dart b/test/repositories/project/project_repository_test.dart new file mode 100644 index 00000000..5ead0759 --- /dev/null +++ b/test/repositories/project/project_repository_test.dart @@ -0,0 +1,69 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flutter_template/core/repository/project_repository.dart'; +import 'package:flutter_template/core/source/project_local_source.dart'; +import 'package:flutter_template/core/source/project_remote_source.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../common/data_mocks.dart'; +import '../../common/project_helpers.dart'; + +void main() { + late ProjectLocalSource projectLocalSource; + late ProjectRemoteSource projectRemoteSource; + late ProjectRepository projectRepository; + + setUp(() async { + projectRepository = ProjectRepository( + projectLocalSource = ProjectLocalSourceMock(), + projectRemoteSource = ProjectRemoteSourceMock(), + ); + }); + + test('get projects from empty state', () async { + when(() => projectLocalSource.getProjects()).thenAnswer( + (_) => Stream.value([]), + ); + when(() => projectRemoteSource.getProjects()).thenAnswer( + (_) async => [], + ); + //Default case for creating the repository + when(() => projectLocalSource.replaceProjects(any())) + .thenAnswer((_) async {}); + + final projects = await projectRepository.getProjects().first; + + verify(() => projectLocalSource.getProjects()).called(1); + verify(() => projectRemoteSource.getProjects()).called(1); + verify(() => projectLocalSource.replaceProjects(any())).called(1); + + expect(projects, []); + }); + + test( + 'get projects stream from a loaded state, should return a stream that gives 2 values', + () async { + final projects = generateProjects(2); + when(() => projectLocalSource.getProjects()).thenAnswer( + (_) => Stream.value( + generateProjectDbEntities(2), + ), + ); + when(() => projectLocalSource.replaceProjects(any())) + .thenAnswer((_) async {}); + when(() => projectRemoteSource.getProjects()).thenAnswer( + (_) async => generateProjects(2), + ); + + final list = await projectRepository.getProjects().first; + verify(() => projectLocalSource.getProjects()).called(1); + verify(() => projectRemoteSource.getProjects()).called(1); + verify(() => projectLocalSource.replaceProjects(any())).called(1); + expect( + list, + projects, + ); + }, + ); +} diff --git a/test/repositories/session/session_local_source_test.dart b/test/repositories/session/session_local_source_test.dart new file mode 100644 index 00000000..e14866f6 --- /dev/null +++ b/test/repositories/session/session_local_source_test.dart @@ -0,0 +1,108 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flutter_template/core/model/user.dart'; +import 'package:flutter_template/core/source/auth_local_source.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../common/data_mocks.dart'; + +void main() { + late LocalSharedPreferencesStorageMock storage; + late AuthLocalSource authLocalSource; + + setUp(() { + storage = LocalSharedPreferencesStorageMock(); + when(() => storage.init()).thenAnswer((_) => any()); + + authLocalSource = AuthLocalSource(storage); + }); + + group('getUserToken tests', () { + test('getUserToken when local Storage is empty, should return null', + () async { + when(() => storage.read(key: 'AuthLocalSource.token')) + .thenAnswer((_) => Future.value(null)); + expect(await authLocalSource.getUserToken().first, null); + }); + + test('getUserToken when local Storage has data, should return String token', + () async { + when(() => storage.read(key: 'AuthLocalSource.token')) + .thenAnswer((_) => Future.value('123abc')); + expect(await authLocalSource.getUserToken().first, '123abc'); + }); + }); + + test( + 'saveUserToken "123abc" when local Storage is empty, write method should be called once', + () async { + when(() => storage.write(key: 'AuthLocalSource.token', value: '123abc')) + .thenAnswer((_) => Future(() => null)); + await authLocalSource.saveUserToken('123abc'); + verify( + () => storage.write( + key: 'AuthLocalSource.token', + value: '123abc', + ), + ); + }); + + test('getUser when local storage is empty should return empty', () async { + when(() => storage.read(key: 'AuthLocalSource.user')) + .thenAnswer((_) => Future(() => null)); + expect(await authLocalSource.getUser().first, null); + verify(() => storage.read(key: 'AuthLocalSource.user')).called(1); + }); + + test('getUser when local storage has data, should return a user', () async { + when(() => storage.read(key: 'AuthLocalSource.user')).thenAnswer( + (_) => Future( + () => '{"name": "Test user","email": "test@email.com"}', + ), + ); + expect((await authLocalSource.getUser().first)?.email, 'test@email.com'); + verify(() => storage.read(key: 'AuthLocalSource.user')).called(1); + }); + + test( + 'saveUserInfo with null in local storage,' + ' should call write once and save null', () async { + when( + () => storage.write( + key: 'AuthLocalSource.user', + value: null, + ), + ).thenAnswer((_) => Future(() => null)); + + await authLocalSource.saveUserInfo( + null, + ); + verify(() => storage.write(key: 'AuthLocalSource.user', value: null)) + .called(1); + }); + + test( + 'saveUserInfo when local storage is empty,' + ' should call write once', () async { + when( + () => storage.write( + key: 'AuthLocalSource.user', + value: any(named: 'value'), + ), + ).thenAnswer((_) => Future(() => null)); + + await authLocalSource.saveUserInfo( + User( + name: 'Test user', + email: 'test@email.com', + ), + ); + verify( + () => storage.write( + key: 'AuthLocalSource.user', + value: any(named: 'value'), + ), + ).called(1); + }); +} diff --git a/test/repositories/session/session_remote_source_test.dart b/test/repositories/session/session_remote_source_test.dart new file mode 100644 index 00000000..e2c3a4d9 --- /dev/null +++ b/test/repositories/session/session_remote_source_test.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_template/core/model/service/auth_models.dart'; +import 'package:flutter_template/core/model/user.dart'; +import 'package:flutter_template/core/source/auth_remote_source.dart'; +import 'package:flutter_template/core/source/common/http_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../common/data_mocks.dart'; + +void main() { + late HttpService httpService; + late AuthRemoteSource authRemoteSource; + + setUp(() { + httpService = HttpServiceMock(); + authRemoteSource = AuthRemoteSource(httpService); + }); + + test('signIn call to API with valid values should return a token', () async { + const uri = 'auth/v1/token'; + final requestOptions = RequestOptions(path: uri); + + when( + () => httpService.post( + uri, + queryParameters: {'grant_type': 'password'}, + data: SignInRequest(email: 'test@email.com', password: '1234').toJson(), + ), + ).thenAnswer( + (_) async => Response( + data: SignInResponse( + accessToken: 'abc123', + user: User(email: 'test@email.com', name: 'test'), + ).toJson(), + statusCode: 200, + requestOptions: requestOptions, + ), + ); + + final result = await authRemoteSource.signIn('test@email.com', '1234'); + expect(result.accessToken, 'abc123'); + }); + + test('signIn call to API with invalid values should return a exception', + () async { + const uri = 'auth/v1/token'; + + when( + () => httpService.post( + uri, + queryParameters: {'grant_type': 'password'}, + data: any(named: 'data'), + ), + ).thenThrow( + const HttpException(''), + ); + + expect( + () => authRemoteSource.signIn('test@email.com', '1224'), + throwsA(isA()), + ); + }); +} diff --git a/test/repositories/session/session_repository_test.dart b/test/repositories/session/session_repository_test.dart new file mode 100644 index 00000000..bae4956b --- /dev/null +++ b/test/repositories/session/session_repository_test.dart @@ -0,0 +1,165 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'dart:io'; + +import 'package:flutter_template/core/model/authentication_status.dart'; +import 'package:flutter_template/core/model/service/auth_models.dart'; +import 'package:flutter_template/core/model/user.dart'; +import 'package:flutter_template/core/repository/session_repository.dart'; +import 'package:flutter_template/core/source/auth_local_source.dart'; +import 'package:flutter_template/core/source/auth_remote_source.dart'; +import 'package:flutter_template/core/source/common/app_database.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../common/data_mocks.dart'; +import '../../common/general_helpers.dart'; + +void main() { + late AuthLocalSource authLocalSource; + late AuthRemoteSource authRemoteSource; + late AppDatabase appDatabase; + late SessionRepository sessionRepository; + + setUp(() async { + authLocalSource = AuthLocalSourceMock(); + authRemoteSource = AuthRemoteSourceMock(); + appDatabase = await setupFloorDatabase(); + sessionRepository = + SessionRepository(appDatabase, authLocalSource, authRemoteSource); + }); + tearDown(() async { + await appDatabase.close(); + }); + + group('status stream tests', () { + test( + 'Get Authenticated status from repository with token stored, should return Authenticated', + () async { + when(() => authLocalSource.getUserToken()).thenAnswer( + (_) => Stream.value('123abc'), + ); + expect( + await sessionRepository.status.first, + AuthenticationStatus.authenticated, + ); + }, + ); + test( + 'Get Unauthenticated status from repository with null token stored, should return Unauthenticated', + () async { + when(() => authLocalSource.getUserToken()).thenAnswer( + (_) => Stream.value(null), + ); + expect( + await sessionRepository.status.first, + AuthenticationStatus.unauthenticated, + ); + }, + ); + }); + + group('getUserInfo tests', () { + test('getUserInfo when localSource is empty, should return null', () async { + when(() => authLocalSource.getUser()) + .thenAnswer((_) => Stream.value(null)); + expect(await sessionRepository.getUserInfo().first, null); + }); + test('getUserInfo when localSource has data, should return a User', + () async { + when(() => authLocalSource.getUser()).thenAnswer( + (_) => Stream.value(User(email: 'test@email', name: 'Test')), + ); + expect( + (await sessionRepository.getUserInfo().first)?.email, + 'test@email', + ); + }); + }); + group('signInUser tests', () { + test( + 'signInUser when the data is valid should consume the remote source and call saveUserToken and saveUserInfo from local source', + () async { + when( + () => authRemoteSource.signIn( + 'test@email.com', + '123456', + ), + ).thenAnswer( + (_) async => SignInResponse( + accessToken: '123abc', + user: User(email: 'test@email.com', name: 'Test'), + ), + ); + + when( + () => authLocalSource.saveUserInfo( + User(email: 'test@email.com', name: 'Test'), + ), + ).thenAnswer((_) async {}); + + when(() => authLocalSource.saveUserToken('123abc')) + .thenAnswer((_) async {}); + + await sessionRepository.signInUser( + email: 'test@email.com', + password: '123456', + ); + verify( + () => authRemoteSource.signIn('test@email.com', '123456'), + ).called(1); + verify( + () => authLocalSource + .saveUserInfo(User(email: 'test@email.com', name: 'Test')), + ).called(1); + verify( + () => authLocalSource.saveUserToken('123abc'), + ).called(1); + }); + + test( + 'signInUser when the data is invalid should throw an exception and not call saveUserToken and saveUserInfo from local source', + () async { + when( + () => authRemoteSource.signIn( + any(), + any(), + ), + ).thenThrow( + const HttpException(''), + ); + + when( + () => authLocalSource.saveUserInfo( + any(), + ), + ).thenAnswer((_) async {}); + + when(() => authLocalSource.saveUserToken(any())).thenAnswer((_) async {}); + + expect( + () => sessionRepository.signInUser( + email: 'test', + password: '123456', + ), + throwsA(isA()), + ); + verifyNever( + () => authLocalSource.saveUserInfo(any()), + ); + verifyNever( + () => authLocalSource.saveUserToken(any()), + ); + }); + }); + + test('logOut when there is data stored locally, should clean all data', + () async { + when(() => authLocalSource.saveUserToken(null)).thenAnswer((_) async {}); + when(() => authLocalSource.saveUserInfo(null)).thenAnswer((_) async {}); + + await sessionRepository.logOut(); + verify(() => authLocalSource.saveUserToken(null)).called(1); + verify(() => authLocalSource.saveUserInfo(null)).called(1); + }); +}