From 84920a8475ec8de13254dfa85d48d969313eb1f3 Mon Sep 17 00:00:00 2001 From: Sandip Date: Sun, 4 Aug 2024 12:03:52 +0530 Subject: [PATCH] feat: add firebase auth, and anonymous signin --- README.md | 19 ++ ios/Podfile.lock | 42 +++++ lib/app/view/app.dart | 8 +- lib/bootstrap.dart | 10 +- lib/main_development.dart | 18 +- lib/main_production.dart | 18 +- lib/main_staging.dart | 18 +- lib/models/models.dart | 1 + lib/models/user.dart | 30 ++++ lib/repository/authentication_repository.dart | 85 +++++++++ lib/repository/repository.dart | 1 + pubspec.lock | 36 +++- pubspec.yaml | 3 + test/app/view/app_test.dart | 4 + test/helpers/mocks.dart | 16 ++ test/helpers/pump_app.dart | 4 + test/models/user_test.dart | 39 ++++ .../authentication_repository_test.dart | 168 ++++++++++++++++++ 18 files changed, 509 insertions(+), 11 deletions(-) create mode 100644 lib/models/user.dart create mode 100644 lib/repository/authentication_repository.dart create mode 100644 test/models/user_test.dart create mode 100644 test/repository/authentication_repository_test.dart diff --git a/README.md b/README.md index a52757e..d267772 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,25 @@ _\*Sudoku works on iOS, Android, and Web._ --- +## Setup 🧩 + +The project is designed to be integrated with 3 different Firebase projects (dependeing upon flavor). + +> The default app instance can be initialized here simply by passing no "name" as an argument in both Dart & manual initialization flows. If you have a google-services.json file in your android project or a GoogleService-Info.plist file in your iOS+ project, it will automatically create a default (named "[DEFAULT]") app instance on the native platform. However, you will still need to call this method before using any FlutterFire plugins. + +Hence, every time you try to run the app in android or ios, you have to configure the `google-services.json` and/or `GoogleService-Info.plist`. To do that, choose the correct firebase project while running the below command(s): + +```sh +# Development flavor +flutterfire config --out=lib/firebase_options_development.dart --ios-bundle-id=dev.thecodexhub.sudoku.dev --android-app-id=dev.thecodexhub.sudoku.dev + +# Staging flavor +flutterfire config --out=lib/firebase_options_staging.dart --ios-bundle-id=dev.thecodexhub.sudoku.stg --android-app-id=dev.thecodexhub.sudoku.stg + +# Production flavor +flutterfire config --out=lib/firebase_options_production.dart --ios-bundle-id=dev.thecodexhub.sudoku --android-app-id=dev.thecodexhub.sudoku +``` + ## Running Tests 🧪 To run all unit and widget tests use the following command: diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9ac395f..f45b0fd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,9 +1,24 @@ PODS: + - Firebase/Auth (10.29.0): + - Firebase/CoreOnly + - FirebaseAuth (~> 10.29.0) - Firebase/CoreOnly (10.29.0): - FirebaseCore (= 10.29.0) + - firebase_auth (5.1.3): + - Firebase/Auth (= 10.29.0) + - firebase_core + - Flutter - firebase_core (3.3.0): - Firebase/CoreOnly (= 10.29.0) - Flutter + - FirebaseAppCheckInterop (10.29.0) + - FirebaseAuth (10.29.0): + - FirebaseAppCheckInterop (~> 10.17) + - FirebaseCore (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/Environment (~> 7.8) + - GTMSessionFetcher/Core (< 4.0, >= 2.1) + - RecaptchaInterop (~> 100.0) - FirebaseCore (10.29.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.12) @@ -11,24 +26,40 @@ PODS: - FirebaseCoreInternal (10.29.0): - "GoogleUtilities/NSData+zlib (~> 7.8)" - Flutter (1.0.0) + - GoogleUtilities/AppDelegateSwizzler (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy - GoogleUtilities/Environment (7.13.3): - GoogleUtilities/Privacy - PromisesObjC (< 3.0, >= 1.2) - GoogleUtilities/Logger (7.13.3): - GoogleUtilities/Environment - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.3): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability - "GoogleUtilities/NSData+zlib (7.13.3)": - GoogleUtilities/Privacy - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - PromisesObjC (2.4.0) + - RecaptchaInterop (100.0.0) - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS DEPENDENCIES: + - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - Flutter (from `Flutter`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -37,12 +68,18 @@ DEPENDENCIES: SPEC REPOS: trunk: - Firebase + - FirebaseAppCheckInterop + - FirebaseAuth - FirebaseCore - FirebaseCoreInternal - GoogleUtilities + - GTMSessionFetcher - PromisesObjC + - RecaptchaInterop EXTERNAL SOURCES: + firebase_auth: + :path: ".symlinks/plugins/firebase_auth/ios" firebase_core: :path: ".symlinks/plugins/firebase_core/ios" Flutter: @@ -54,13 +91,18 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Firebase: cec914dab6fd7b1bd8ab56ea07ce4e03dd251c2d + firebase_auth: 6a09851aca5fcbb1f3745bed7ec88f221c52c3e9 firebase_core: 57aeb91680e5d5e6df6b888064be7c785f146efb + FirebaseAppCheckInterop: 6a1757cfd4067d8e00fccd14fcc1b8fd78cfac07 + FirebaseAuth: e2ebfaf9fb4638a1c9a3b0efd17d1b90943987cd FirebaseCore: 30e9c1cbe3d38f5f5e75f48bfcea87d7c358ec16 FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5 diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 8bd432c..9000e69 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -11,12 +11,15 @@ class App extends StatelessWidget { const App({ required SudokuAPI apiClient, required PuzzleRepository puzzleRepository, + required AuthenticationRepository authenticationRepository, super.key, }) : _apiClient = apiClient, - _puzzleRepository = puzzleRepository; + _puzzleRepository = puzzleRepository, + _authenticationRepository = authenticationRepository; final SudokuAPI _apiClient; final PuzzleRepository _puzzleRepository; + final AuthenticationRepository _authenticationRepository; @override Widget build(BuildContext context) { @@ -28,6 +31,9 @@ class App extends StatelessWidget { RepositoryProvider.value( value: _puzzleRepository, ), + RepositoryProvider.value( + value: _authenticationRepository, + ), ], child: const AppView(), ); diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index d3143da..85b5228 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -2,14 +2,20 @@ import 'dart:async'; import 'dart:developer'; import 'package:bloc/bloc.dart'; +import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:sudoku/app_bloc_observer.dart'; +/// The type definition for the builder widget. +typedef BootstrapBuilder = FutureOr Function( + FirebaseAuth firebaseAuth, +); + /// Bootstrap is responsible for any common setup and calls /// [runApp] with the widget returned by [builder] in an error zone. -Future bootstrap(FutureOr Function() builder) async { +Future bootstrap(BootstrapBuilder builder) async { // Add Open Font License (OFL) for Inter google font LicenseRegistry.addLicense(() async* { final license = await rootBundle.loadString('licenses/OFL.txt'); @@ -31,7 +37,7 @@ Future bootstrap(FutureOr Function() builder) async { await runZonedGuarded( () async { Bloc.observer = const AppBlocObserver(); - runApp(await builder()); + runApp(await builder(FirebaseAuth.instance)); }, (error, stackTrace) => log(error.toString(), stackTrace: stackTrace), ); diff --git a/lib/main_development.dart b/lib/main_development.dart index 3e9b7b3..a0159cd 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -16,12 +16,11 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( - name: 'sudoku-gemini-dev', options: DefaultFirebaseOptions.currentPlatform, ); unawaited( - bootstrap(() async { + bootstrap((firebaseAuth) async { final apiClient = SudokuDioClient(baseUrl: Env.apiBaseUrl); final cacheClient = CacheClient(); @@ -29,6 +28,20 @@ void main() async { plugin: await SharedPreferences.getInstance(), ); + final authenticationRepository = AuthenticationRepository( + cache: cacheClient, + firebaseAuth: firebaseAuth, + ); + + // Check if the user is already authenticated. + var user = await authenticationRepository.user.first; + + // If the user is not already authenticated, authenticate anonymously. + if (user.isUnauthenticated) { + await authenticationRepository.signInAnonymously(); + user = await authenticationRepository.user.first; + } + final puzzleRepository = PuzzleRepository( cacheClient: cacheClient, storageClient: storageClient, @@ -37,6 +50,7 @@ void main() async { return App( apiClient: apiClient, puzzleRepository: puzzleRepository, + authenticationRepository: authenticationRepository, ); }), ); diff --git a/lib/main_production.dart b/lib/main_production.dart index 369c268..cc77439 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -16,12 +16,11 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( - name: 'sudoku-gemini', options: DefaultFirebaseOptions.currentPlatform, ); unawaited( - bootstrap(() async { + bootstrap((firebaseAuth) async { final apiClient = SudokuDioClient(baseUrl: Env.apiBaseUrl); final cacheClient = CacheClient(); @@ -29,6 +28,20 @@ void main() async { plugin: await SharedPreferences.getInstance(), ); + final authenticationRepository = AuthenticationRepository( + cache: cacheClient, + firebaseAuth: firebaseAuth, + ); + + // Check if the user is already authenticated. + var user = await authenticationRepository.user.first; + + // If the user is not already authenticated, authenticate anonymously. + if (user.isUnauthenticated) { + await authenticationRepository.signInAnonymously(); + user = await authenticationRepository.user.first; + } + final puzzleRepository = PuzzleRepository( cacheClient: cacheClient, storageClient: storageClient, @@ -37,6 +50,7 @@ void main() async { return App( apiClient: apiClient, puzzleRepository: puzzleRepository, + authenticationRepository: authenticationRepository, ); }), ); diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 51cc315..c74ee60 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -16,12 +16,11 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( - name: 'sudoku-gemini-stg', options: DefaultFirebaseOptions.currentPlatform, ); unawaited( - bootstrap(() async { + bootstrap((firebaseAuth) async { final apiClient = SudokuDioClient(baseUrl: Env.apiBaseUrl); final cacheClient = CacheClient(); @@ -29,6 +28,20 @@ void main() async { plugin: await SharedPreferences.getInstance(), ); + final authenticationRepository = AuthenticationRepository( + cache: cacheClient, + firebaseAuth: firebaseAuth, + ); + + // Check if the user is already authenticated. + var user = await authenticationRepository.user.first; + + // If the user is not already authenticated, authenticate anonymously. + if (user.isUnauthenticated) { + await authenticationRepository.signInAnonymously(); + user = await authenticationRepository.user.first; + } + final puzzleRepository = PuzzleRepository( cacheClient: cacheClient, storageClient: storageClient, @@ -37,6 +50,7 @@ void main() async { return App( apiClient: apiClient, puzzleRepository: puzzleRepository, + authenticationRepository: authenticationRepository, ); }), ); diff --git a/lib/models/models.dart b/lib/models/models.dart index fc812b5..79b26e1 100644 --- a/lib/models/models.dart +++ b/lib/models/models.dart @@ -5,3 +5,4 @@ export 'json_map.dart'; export 'position.dart'; export 'sudoku.dart'; export 'ticker.dart'; +export 'user.dart'; diff --git a/lib/models/user.dart b/lib/models/user.dart new file mode 100644 index 0000000..bde0b13 --- /dev/null +++ b/lib/models/user.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +/// {@template user} +/// User model. +/// +/// [User.unauthenticated] represents an unauthenticated user. +/// {@endtemplate} +class User extends Equatable { + /// {@macro user} + const User({ + required this.id, + }); + + /// The current user's id. + final String id; + + /// Represents an unauthenticated user. + static const unauthenticated = User(id: ''); + + /// Convenience getter to determine whether the current + /// user is unauthenticated. + bool get isUnauthenticated => this == User.unauthenticated; + + /// Convenience getter to determine whether the current + /// user is authenticated. + bool get isAuthenticated => this != User.unauthenticated; + + @override + List get props => [id]; +} diff --git a/lib/repository/authentication_repository.dart b/lib/repository/authentication_repository.dart new file mode 100644 index 0000000..3c7d7e2 --- /dev/null +++ b/lib/repository/authentication_repository.dart @@ -0,0 +1,85 @@ +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:sudoku/cache/cache.dart'; +import 'package:sudoku/models/models.dart'; + +/// {@template authentication_exception} +/// Exception thrown when an authentication process fails. +/// {@endtemplate} +class AuthenticationException implements Exception { + /// {@macro authentication_exception} + const AuthenticationException(this.error, this.stackTrace); + + /// The error that was caught. + final Object error; + + /// The stack trace associated with the error. + final StackTrace stackTrace; + + @override + String toString() { + return error.toString(); + } +} + +/// {@template authentication_repository} +/// Repository which manages user authentication. +/// {@endtemplate} +class AuthenticationRepository { + /// {@macro authentication_repository} + AuthenticationRepository({ + CacheClient? cache, + firebase_auth.FirebaseAuth? firebaseAuth, + }) : _cache = cache ?? CacheClient(), + _firebaseAuth = firebaseAuth ?? firebase_auth.FirebaseAuth.instance; + + final CacheClient _cache; + final firebase_auth.FirebaseAuth _firebaseAuth; + + /// User cache key. + /// Should only be used for testing purposes. + @visibleForTesting + static const userCacheKey = '__user_cache_key__'; + + /// Stream of [User] which will emit the current user when + /// the authentication state changes. + /// + /// Emits [User.unauthenticated] if the user is not authenticated. + Stream get user { + return _firebaseAuth.authStateChanges().map((fbUser) { + final user = fbUser == null ? User.unauthenticated : fbUser.toUser; + _cache.write(key: userCacheKey, value: user); + return user; + }); + } + + /// Returns the current cached user. + /// Defaults to [User.unauthenticated] if there is no cached user. + User get currentUser { + return _cache.read(key: userCacheKey) ?? User.unauthenticated; + } + + /// Sign in the user anonymously. + /// + /// If the sign in fails, an [AuthenticationException] is thrown. + Future signInAnonymously() async { + try { + await _firebaseAuth.signInAnonymously(); + } on Exception catch (error, stackTrace) { + throw AuthenticationException(error, stackTrace); + } + } + + /// Signs out the current user which will emit + /// [User.unauthenticated] from the [user] stream. + Future signOut() async { + await _firebaseAuth.signOut(); + } +} + +extension on firebase_auth.User { + /// Maps a [firebase_auth.User] into a [User]. + User get toUser { + return User(id: uid); + } +} diff --git a/lib/repository/repository.dart b/lib/repository/repository.dart index 878c31c..62a869a 100644 --- a/lib/repository/repository.dart +++ b/lib/repository/repository.dart @@ -1 +1,2 @@ +export 'authentication_repository.dart'; export 'puzzle_repository.dart'; diff --git a/pubspec.lock b/pubspec.lock index b4d965c..32a3230 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "67.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: b1595874fbc8f7a50da90f5d8f327bb0bfd6a95dc906c390efe991540c3b54aa + url: "https://pub.dev" + source: hosted + version: "1.3.40" analyzer: dependency: transitive description: @@ -265,6 +273,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: b0dde65595b65c0c2c2c2127da0ce7772a375fcefbea8490c1563a4aecbd0195 + url: "https://pub.dev" + source: hosted + version: "5.1.3" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "0408e2ed74b1afa0490a93aa041fe90d7573af7ffc59a641edc6c5b5c1b8d2a4" + url: "https://pub.dev" + source: hosted + version: "7.4.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: "7e0c6d0fa8c5c1b2ae126a78f2d1a206a77a913f78d20f155487bf746162dccc" + url: "https://pub.dev" + source: hosted + version: "5.12.5" firebase_core: dependency: "direct main" description: @@ -274,7 +306,7 @@ packages: source: hosted version: "3.3.0" firebase_core_platform_interface: - dependency: transitive + dependency: "direct main" description: name: firebase_core_platform_interface sha256: "3c3a1e92d6f4916c32deea79c4a7587aa0e9dbbe5889c7a16afcf005a485ee02" @@ -606,7 +638,7 @@ packages: source: hosted version: "3.1.5" plugin_platform_interface: - dependency: transitive + dependency: "direct main" description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" diff --git a/pubspec.yaml b/pubspec.yaml index 4e261fb..4cb5ca1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -12,7 +12,9 @@ dependencies: envied: ^0.5.4+1 equatable: ^2.0.5 fake_async: ^1.3.1 + firebase_auth: ^5.1.3 firebase_core: ^3.3.0 + firebase_core_platform_interface: ^5.2.0 flutter: sdk: flutter flutter_bloc: ^8.1.4 @@ -23,6 +25,7 @@ dependencies: intl: ^0.19.0 json_annotation: ^4.9.0 loading_indicator: ^3.1.1 + plugin_platform_interface: ^2.1.8 rxdart: ^0.28.0 shared_preferences: ^2.2.3 diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 6b110ed..edb598a 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -13,11 +13,14 @@ void main() { late Puzzle puzzle; late SudokuAPI apiClient; late PuzzleRepository puzzleRepository; + late AuthenticationRepository authenticationRepository; setUp(() { puzzle = MockPuzzle(); apiClient = MockSudokuAPI(); + puzzleRepository = MockPuzzleRepository(); + authenticationRepository = MockAuthenticationRepository(); when(() => puzzleRepository.getPuzzleFromLocalMemory()).thenAnswer( (_) => Stream.value(puzzle), @@ -29,6 +32,7 @@ void main() { App( apiClient: apiClient, puzzleRepository: puzzleRepository, + authenticationRepository: authenticationRepository, ), ); expect(find.byType(HomePage), findsOneWidget); diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 47d2e83..b07268a 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -1,6 +1,9 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:dio/dio.dart'; +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/cache/cache.dart'; @@ -48,3 +51,16 @@ class MockHint extends Mock implements Hint {} class MockTimerState extends Mock implements TimerState {} class MockStorageAPI extends Mock implements StorageAPI {} + +class MockAuthenticationRepository extends Mock + implements AuthenticationRepository {} + +class MockFirebaseAuth extends Mock implements firebase_auth.FirebaseAuth {} + +class MockFirebaseUser extends Mock implements firebase_auth.User {} + +class MockUserCredential extends Mock implements firebase_auth.UserCredential {} + +class MockFirebaseCore extends Mock + with MockPlatformInterfaceMixin + implements FirebasePlatform {} diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index a792b16..0022164 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -16,6 +16,7 @@ extension PumpApp on WidgetTester { Widget widget, { SudokuAPI? apiClient, PuzzleRepository? puzzleRepository, + AuthenticationRepository? authenticationRepository, HomeBloc? homeBloc, TimerBloc? timerBloc, PuzzleBloc? puzzleBloc, @@ -31,6 +32,9 @@ extension PumpApp on WidgetTester { RepositoryProvider.value( value: puzzleRepository ?? MockPuzzleRepository(), ), + RepositoryProvider.value( + value: authenticationRepository ?? MockAuthenticationRepository(), + ), ], child: MultiBlocProvider( providers: [ diff --git a/test/models/user_test.dart b/test/models/user_test.dart new file mode 100644 index 0000000..cc6aa39 --- /dev/null +++ b/test/models/user_test.dart @@ -0,0 +1,39 @@ +// ignore_for_file: prefer_const_constructors +import 'package:flutter_test/flutter_test.dart'; +import 'package:sudoku/models/models.dart'; + +void main() { + group('User', () { + const id = 'mock-id'; + + test('uses value equality', () { + expect( + User(id: id), + equals(User(id: id)), + ); + }); + + test('props are correct', () { + final user = User(id: id); + expect(user.props, equals([id])); + }); + + test('isUnauthenticated returns true for unauthenticated user', () { + expect(User.unauthenticated.isUnauthenticated, isTrue); + }); + + test('isUnauthenticated returns false for authenticated user', () { + final user = User(id: id); + expect(user.isUnauthenticated, isFalse); + }); + + test('isAuthenticated returns false for unauthenticated user', () { + expect(User.unauthenticated.isAuthenticated, isFalse); + }); + + test('isAuthenticated returns true for authenticated user', () { + final user = User(id: id); + expect(user.isAuthenticated, isTrue); + }); + }); +} diff --git a/test/repository/authentication_repository_test.dart b/test/repository/authentication_repository_test.dart new file mode 100644 index 0000000..2527ac3 --- /dev/null +++ b/test/repository/authentication_repository_test.dart @@ -0,0 +1,168 @@ +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:sudoku/cache/cache.dart'; +import 'package:sudoku/models/models.dart'; +import 'package:sudoku/repository/repository.dart'; + +import '../helpers/helpers.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const mockFirebaseUserUid = 'mock-uid'; + const user = User( + id: mockFirebaseUserUid, + ); + + group('AuthenticationRepository', () { + late CacheClient cacheClient; + late firebase_auth.FirebaseAuth firebaseAuth; + late firebase_auth.UserCredential userCredential; + late AuthenticationRepository authenticationRepository; + + setUp(() { + const options = FirebaseOptions( + apiKey: 'apiKey', + appId: 'appId', + messagingSenderId: 'messagingSenderId', + projectId: 'projectId', + ); + final platformApp = FirebaseAppPlatform(defaultFirebaseAppName, options); + final firebaseCore = MockFirebaseCore(); + + when(() => firebaseCore.apps).thenReturn([platformApp]); + when(firebaseCore.app).thenReturn(platformApp); + when( + () => firebaseCore.initializeApp( + name: defaultFirebaseAppName, + options: options, + ), + ).thenAnswer((_) async => platformApp); + + Firebase.delegatePackingProperty = firebaseCore; + + cacheClient = MockCacheClient(); + firebaseAuth = MockFirebaseAuth(); + userCredential = MockUserCredential(); + + authenticationRepository = AuthenticationRepository( + cache: cacheClient, + firebaseAuth: firebaseAuth, + ); + }); + + test('can be instantiated', () { + expect(AuthenticationRepository(), isNotNull); + }); + + group('user', () { + test('emits User.unauthenticated when firebase user is null', () async { + when(() => firebaseAuth.authStateChanges()).thenAnswer( + (_) => Stream.value(null), + ); + await expectLater( + authenticationRepository.user, + emitsInOrder(const [User.unauthenticated]), + ); + }); + + test('emits returning user when firebase user is not null', () async { + final firebaseUser = MockFirebaseUser(); + when(() => firebaseUser.uid).thenReturn(mockFirebaseUserUid); + when(() => firebaseAuth.authStateChanges()).thenAnswer( + (_) => Stream.value(firebaseUser), + ); + + await expectLater( + authenticationRepository.user, + emitsInOrder(const [User(id: mockFirebaseUserUid)]), + ); + }); + }); + + group('currentUser', () { + test('returns User.unauthenticated when cached user is null', () { + when( + () => cacheClient.read(key: AuthenticationRepository.userCacheKey), + ).thenReturn(null); + expect( + authenticationRepository.currentUser, + equals(User.unauthenticated), + ); + }); + + test('returns User when cached user is not null', () async { + when( + () => cacheClient.read( + key: AuthenticationRepository.userCacheKey, + ), + ).thenReturn(user); + expect(authenticationRepository.currentUser, equals(user)); + }); + }); + + group('signInAnonymously', () { + test('calls signInAnonymously on FirebaseAuth', () async { + when(() => firebaseAuth.signInAnonymously()).thenAnswer( + (_) async => userCredential, + ); + + await authenticationRepository.signInAnonymously(); + verify(() => firebaseAuth.signInAnonymously()).called(1); + }); + + test('throws AuthenticationException on failure', () async { + when(() => firebaseAuth.signInAnonymously()).thenThrow( + Exception('oops!'), + ); + + expect( + () => authenticationRepository.signInAnonymously(), + throwsA(isA()), + ); + }); + }); + + group('signOut', () { + setUp(() { + when(() => firebaseAuth.signOut()).thenAnswer((_) async {}); + }); + + test('calls signOut on FirebaseAuth', () async { + await authenticationRepository.signOut(); + verify(() => firebaseAuth.signOut()).called(1); + }); + + test('updates user with unauthenticated', () async { + when(() => firebaseAuth.authStateChanges()).thenAnswer( + (_) => Stream.value(null), + ); + await authenticationRepository.signOut(); + + await expectLater( + authenticationRepository.user, + emitsInOrder( + [User.unauthenticated], + ), + ); + }); + }); + }); + + group('AuthenticationException', () { + AuthenticationException createSubject() { + return AuthenticationException('oops!', StackTrace.fromString('mock-st')); + } + + test('constructor works correctly', () { + expect(createSubject, returnsNormally); + }); + + test('toString method is defined and returns error as string', () { + expect(createSubject().toString(), equals('oops!')); + }); + }); +}