From 46f075908090590691a7ff7f77eac7370bbe7fc1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 09:29:25 +0000 Subject: [PATCH 1/5] Add comprehensive test coverage for core, data, and presenter layers - Add bloc_test and mocktail dev dependencies - Core: exception (BaseException.from, UnknownException, NetworkException) and use_case tests - Flavors: F.name and F.title for all flavor variants - Data repositories: DefaultAuthRepository (verifyLoginStatus, login) with mocked NetworkDataSource - Data use cases: LoginUseCase and VerifyLoginStatusUseCase with mocked AuthRepository - Data sources: LocalDataSource (read/write initialized version) with mocked FlutterSecureStorage - Data states: AuthBloc (loggedIn/loggedOut transitions), SettingsBloc (theme changes) - Data states: AppBlocObserver (UnauthorizedException logout, LoginBloc success propagation) - Services: DefaultOauthTokenManager (token CRUD, authenticated headers) with mocked storage - Presenter BLoCs: SplashBloc (verify status success/failure) and LoginBloc (all event handlers) - Themes: AppThemeColors (lerp, copyWith), AppThemeTypography (lerp), AppTheme/LightAppTheme https://claude.ai/code/session_011cEYpuo76EMamkGvpnuPNZ --- pubspec.yaml | 2 + test/core/exception_test.dart | 85 +++++++++++ test/core/use_case_test.dart | 42 ++++++ .../repositories/auth_repository_test.dart | 82 ++++++++++ .../sources/local/local_data_source_test.dart | 63 ++++++++ test/data/states/auth/auth_bloc_test.dart | 80 ++++++++++ test/data/states/bloc_observer_test.dart | 113 ++++++++++++++ .../states/settings/settings_bloc_test.dart | 41 +++++ test/data/usecases/login_use_case_test.dart | 42 ++++++ .../verify_login_status_use_case_test.dart | 46 ++++++ test/flavors_test.dart | 58 +++++++ .../pages/login/login_bloc_test.dart | 136 +++++++++++++++++ .../pages/splash/splash_bloc_test.dart | 98 ++++++++++++ test/presenter/themes/colors_test.dart | 130 ++++++++++++++++ test/presenter/themes/themes_test.dart | 109 ++++++++++++++ test/presenter/themes/typography_test.dart | 82 ++++++++++ test/services/oauth_token_manager_test.dart | 141 ++++++++++++++++++ 17 files changed, 1350 insertions(+) create mode 100644 test/core/exception_test.dart create mode 100644 test/core/use_case_test.dart create mode 100644 test/data/repositories/auth_repository_test.dart create mode 100644 test/data/sources/local/local_data_source_test.dart create mode 100644 test/data/states/auth/auth_bloc_test.dart create mode 100644 test/data/states/bloc_observer_test.dart create mode 100644 test/data/states/settings/settings_bloc_test.dart create mode 100644 test/data/usecases/login_use_case_test.dart create mode 100644 test/data/usecases/verify_login_status_use_case_test.dart create mode 100644 test/flavors_test.dart create mode 100644 test/presenter/pages/login/login_bloc_test.dart create mode 100644 test/presenter/pages/splash/splash_bloc_test.dart create mode 100644 test/presenter/themes/colors_test.dart create mode 100644 test/presenter/themes/themes_test.dart create mode 100644 test/presenter/themes/typography_test.dart create mode 100644 test/services/oauth_token_manager_test.dart diff --git a/pubspec.yaml b/pubspec.yaml index e99370c..226786b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 + bloc_test: ^9.1.7 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/core/exception_test.dart b/test/core/exception_test.dart new file mode 100644 index 0000000..d74bf94 --- /dev/null +++ b/test/core/exception_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/core/exception.dart'; +import 'package:flutter_starter/presenter/languages/translation_keys.g.dart'; + +void main() { + group('BaseException.from', () { + test('returns same instance when given a BaseException', () { + final exception = UnknownException('original'); + final result = BaseException.from(exception); + expect(result, same(exception)); + }); + + test('wraps non-BaseException error in UnknownException', () { + final error = Exception('some error'); + final result = BaseException.from(error); + expect(result, isA()); + expect(result.data, same(error)); + }); + + test('wraps null in UnknownException', () { + final result = BaseException.from(null); + expect(result, isA()); + expect(result.data, isNull); + }); + + test('wraps a string error in UnknownException', () { + final result = BaseException.from('oops'); + expect(result, isA()); + expect(result.data, 'oops'); + }); + }); + + group('UnknownException', () { + test('has correct message from LocaleKeys', () { + final e = UnknownException(); + expect(e.message, LocaleKeys.Errors_AnUnknownErrorOccurred); + }); + + test('stores data passed to it', () { + final inner = Exception('inner'); + final e = UnknownException(inner); + expect(e.data, same(inner)); + }); + + test('toString contains runtimeType and message', () { + final e = UnknownException(); + final str = e.toString(); + expect(str, contains('UnknownException')); + expect(str, contains('message:')); + }); + + test('toString includes code when present', () { + final e = UnknownException(); + // code is null by default, so no code in toString + expect(e.toString(), isNot(contains('code:'))); + }); + + test('toString includes data when present', () { + final e = UnknownException('mydata'); + expect(e.toString(), contains('data:')); + }); + }); + + group('NetworkException', () { + test('has correct message from LocaleKeys', () { + final e = NetworkException(); + expect(e.message, LocaleKeys.Errors_NetworkError); + }); + + test('toString contains runtimeType', () { + final e = NetworkException(); + expect(e.toString(), contains('NetworkException')); + }); + + test('code is null by default', () { + final e = NetworkException(); + expect(e.code, isNull); + }); + + test('data is null by default', () { + final e = NetworkException(); + expect(e.data, isNull); + }); + }); +} diff --git a/test/core/use_case_test.dart b/test/core/use_case_test.dart new file mode 100644 index 0000000..4b3844f --- /dev/null +++ b/test/core/use_case_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/core/use_case.dart'; + +class _AddUseCase extends UseCase { + const _AddUseCase(); + + @override + int call(int params) => params + 1; +} + +class _AsyncUseCase extends UseCase { + const _AsyncUseCase(); + + @override + Future call(String params) async => 'hello $params'; +} + +void main() { + group('NoParams', () { + test('can be instantiated', () { + expect(NoParams(), isA()); + }); + + test('two instances are not equal by default (no equality override)', () { + final a = NoParams(); + final b = NoParams(); + expect(identical(a, b), isFalse); + }); + }); + + group('UseCase', () { + test('sync use case returns correct value', () { + const useCase = _AddUseCase(); + expect(useCase(5), 6); + }); + + test('async use case returns correct value', () async { + const useCase = _AsyncUseCase(); + expect(await useCase('world'), 'hello world'); + }); + }); +} diff --git a/test/data/repositories/auth_repository_test.dart b/test/data/repositories/auth_repository_test.dart new file mode 100644 index 0000000..096f223 --- /dev/null +++ b/test/data/repositories/auth_repository_test.dart @@ -0,0 +1,82 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/entities/request/login_params.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/auth_repository.default.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/exceptions.dart'; +import 'package:flutter_starter/data/sources/network/network.dart'; + +class MockNetworkDataSource extends Mock implements NetworkDataSource {} + +const _account = Account(id: '1', email: 'test@example.com', name: 'Test User'); + +void main() { + late MockNetworkDataSource mockNetwork; + late DefaultAuthRepository repository; + + setUpAll(() { + registerFallbackValue(const LoginParams(username: '', password: '')); + }); + + setUp(() { + mockNetwork = MockNetworkDataSource(); + repository = DefaultAuthRepository(networkDataSource: mockNetwork); + }); + + group('DefaultAuthRepository.verifyLoginStatus', () { + test('returns Account on success', () async { + when(() => mockNetwork.getCurrentAccount()).thenAnswer((_) async => _account); + + final result = await repository.verifyLoginStatus(); + + expect(result, _account); + }); + + test('throws UnauthorizedException on any error', () async { + when(() => mockNetwork.getCurrentAccount()).thenThrow(Exception('network error')); + + expect(() => repository.verifyLoginStatus(), throwsA(isA())); + }); + + test('throws UnauthorizedException even on DioException', () async { + when(() => mockNetwork.getCurrentAccount()) + .thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + expect(() => repository.verifyLoginStatus(), throwsA(isA())); + }); + }); + + group('DefaultAuthRepository.login', () { + test('returns Account on success', () async { + when(() => mockNetwork.login(any())).thenAnswer((_) async => _account); + + final result = await repository.login(username: 'user', password: 'pass'); + + expect(result, _account); + verify(() => mockNetwork.login( + const LoginParams(username: 'user', password: 'pass'), + )).called(1); + }); + + test('throws LoginInvalidEmailPasswordException on DioException', () async { + when(() => mockNetwork.login(any())) + .thenThrow(DioException(requestOptions: RequestOptions(path: ''))); + + expect( + () => repository.login(username: 'bad', password: 'creds'), + throwsA(isA()), + ); + }); + }); + + group('AuthException hierarchy', () { + test('UnauthorizedException is an AuthException', () { + expect(UnauthorizedException(), isA()); + }); + + test('LoginInvalidEmailPasswordException is an AuthException', () { + expect(LoginInvalidEmailPasswordException(), isA()); + }); + }); +} diff --git a/test/data/sources/local/local_data_source_test.dart b/test/data/sources/local/local_data_source_test.dart new file mode 100644 index 0000000..e22ef73 --- /dev/null +++ b/test/data/sources/local/local_data_source_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_starter/data/sources/local/local.dart'; + +class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} + +void main() { + late MockFlutterSecureStorage mockStorage; + late LocalDataSource dataSource; + + const storageKey = '@@app/initialized'; + + setUp(() { + mockStorage = MockFlutterSecureStorage(); + dataSource = LocalDataSource(flutterSecureStorage: mockStorage); + }); + + group('LocalDataSource.getInitializedVersion', () { + test('returns parsed int when value is stored', () async { + when(() => mockStorage.read(key: storageKey)).thenAnswer((_) async => '42'); + + final result = await dataSource.getInitializedVersion(); + + expect(result, 42); + }); + + test('returns null when storage returns null', () async { + when(() => mockStorage.read(key: storageKey)).thenAnswer((_) async => null); + + final result = await dataSource.getInitializedVersion(); + + expect(result, isNull); + }); + + test('returns null when stored value is not a valid int', () async { + when(() => mockStorage.read(key: storageKey)).thenAnswer((_) async => 'not-a-number'); + + final result = await dataSource.getInitializedVersion(); + + expect(result, isNull); + }); + + test('returns null when storage throws an error', () async { + when(() => mockStorage.read(key: storageKey)).thenThrow(Exception('storage error')); + + final result = await dataSource.getInitializedVersion(); + + expect(result, isNull); + }); + }); + + group('LocalDataSource.saveInitializedVersion', () { + test('writes version code as string to storage', () async { + when(() => mockStorage.write(key: storageKey, value: '5')) + .thenAnswer((_) async {}); + + await dataSource.saveInitializedVersion(5); + + verify(() => mockStorage.write(key: storageKey, value: '5')).called(1); + }); + }); +} diff --git a/test/data/states/auth/auth_bloc_test.dart b/test/data/states/auth/auth_bloc_test.dart new file mode 100644 index 0000000..fcf8d35 --- /dev/null +++ b/test/data/states/auth/auth_bloc_test.dart @@ -0,0 +1,80 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/states/auth/auth_bloc.dart'; +import 'package:flutter_starter/data/states/auth/auth_event.dart'; +import 'package:flutter_starter/data/states/auth/auth_state.dart'; + +const _account = Account(id: '1', email: 'a@b.com', name: 'Alice'); + +void main() { + group('AuthBloc', () { + test('initial state has null account and is not logged in', () { + final bloc = AuthBloc(); + expect(bloc.state, const AuthState()); + expect(bloc.state.loggedIn, isFalse); + expect(bloc.state.account, isNull); + bloc.close(); + }); + + blocTest( + 'emits state with account on AuthLoggedIn', + build: AuthBloc.new, + act: (bloc) => bloc.add(const AuthLoggedIn(_account)), + expect: () => [ + const AuthState(account: _account), + ], + ); + + blocTest( + 'loggedIn is true after AuthLoggedIn', + build: AuthBloc.new, + act: (bloc) => bloc.add(const AuthLoggedIn(_account)), + verify: (bloc) => expect(bloc.state.loggedIn, isTrue), + ); + + blocTest( + 'emits state with null account on AuthLoggedOut', + build: AuthBloc.new, + seed: () => const AuthState(account: _account), + act: (bloc) => bloc.add(const AuthLoggedOut()), + expect: () => [ + const AuthState(account: null, error: null), + ], + ); + + blocTest( + 'emits state with error on AuthLoggedOut with error', + build: AuthBloc.new, + seed: () => const AuthState(account: _account), + act: (bloc) { + final error = Exception('session expired'); + bloc.add(AuthLoggedOut(error)); + }, + verify: (bloc) { + expect(bloc.state.account, isNull); + expect(bloc.state.error, isNotNull); + }, + ); + + blocTest( + 'loggedIn is false after AuthLoggedOut', + build: AuthBloc.new, + seed: () => const AuthState(account: _account), + act: (bloc) => bloc.add(const AuthLoggedOut()), + verify: (bloc) => expect(bloc.state.loggedIn, isFalse), + ); + }); + + group('AuthState.loggedIn', () { + test('is false when account is null', () { + const state = AuthState(); + expect(state.loggedIn, isFalse); + }); + + test('is true when account is set', () { + const state = AuthState(account: _account); + expect(state.loggedIn, isTrue); + }); + }); +} diff --git a/test/data/states/bloc_observer_test.dart b/test/data/states/bloc_observer_test.dart new file mode 100644 index 0000000..15816d3 --- /dev/null +++ b/test/data/states/bloc_observer_test.dart @@ -0,0 +1,113 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/exceptions.dart'; +import 'package:flutter_starter/data/states/auth/auth_bloc.dart'; +import 'package:flutter_starter/data/states/auth/auth_event.dart'; +import 'package:flutter_starter/data/states/bloc_observer.dart'; +import 'package:flutter_starter/data/usecases/login.dart'; +import 'package:flutter_starter/presenter/pages/login/login_bloc.dart'; +import 'package:flutter_starter/presenter/pages/login/login_state.dart'; + +class MockAuthBloc extends Mock implements AuthBloc {} + +class MockLoginUseCase extends Mock implements LoginUseCase {} + +const _account = Account(id: '1', email: 'a@b.com', name: 'Alice'); + +void main() { + late MockAuthBloc mockAuthBloc; + late AppBlocObserver observer; + + setUpAll(() { + // Register fallback for AuthEvent so any() works in when/verify + registerFallbackValue(const AuthLoggedOut()); + }); + + setUp(() { + mockAuthBloc = MockAuthBloc(); + observer = AppBlocObserver(mockAuthBloc); + }); + + group('AppBlocObserver.onError', () { + test('dispatches AuthLoggedOut when error is UnauthorizedException', () { + when(() => mockAuthBloc.add(any())).thenReturn(null); + + observer.onError(mockAuthBloc, UnauthorizedException(), StackTrace.empty); + + verify(() => mockAuthBloc.add(const AuthLoggedOut())).called(1); + }); + + test('does not dispatch AuthLoggedOut for generic Exception', () { + observer.onError(mockAuthBloc, Exception('other'), StackTrace.empty); + + verifyNever(() => mockAuthBloc.add(any())); + }); + + test('does not dispatch AuthLoggedOut for a string error', () { + observer.onError(mockAuthBloc, 'some string error', StackTrace.empty); + + verifyNever(() => mockAuthBloc.add(any())); + }); + }); + + group('AppBlocObserver.onChange with LoginBloc', () { + late MockLoginUseCase mockLoginUseCase; + late LoginBloc loginBloc; + + setUp(() { + mockLoginUseCase = MockLoginUseCase(); + loginBloc = LoginBloc(login: mockLoginUseCase); + }); + + tearDown(() { + loginBloc.close(); + }); + + test('dispatches AuthLoggedIn when LoginBloc transitions to success with account', () { + when(() => mockAuthBloc.add(any())).thenReturn(null); + + const prevState = LoginState(status: LoginStatus.submitting); + const nextState = LoginState(status: LoginStatus.success, account: _account); + final change = Change(currentState: prevState, nextState: nextState); + + observer.onChange(loginBloc, change); + + verify(() => mockAuthBloc.add(const AuthLoggedIn(_account))).called(1); + }); + + test('does not dispatch AuthLoggedIn when success state has null account', () { + const prevState = LoginState(status: LoginStatus.submitting); + const nextState = LoginState(status: LoginStatus.success, account: null); + final change = Change(currentState: prevState, nextState: nextState); + + observer.onChange(loginBloc, change); + + verifyNever(() => mockAuthBloc.add(any())); + }); + + test('does not dispatch AuthLoggedIn when status is not success', () { + const prevState = LoginState(status: LoginStatus.initial); + const nextState = LoginState(status: LoginStatus.submitting); + final change = Change(currentState: prevState, nextState: nextState); + + observer.onChange(loginBloc, change); + + verifyNever(() => mockAuthBloc.add(any())); + }); + + test('does not dispatch anything when bloc is not a LoginBloc', () { + final authBloc = AuthBloc(); + final change = Change( + currentState: const LoginState(), + nextState: const LoginState(status: LoginStatus.success, account: _account), + ); + + observer.onChange(authBloc, change); + + verifyNever(() => mockAuthBloc.add(any())); + authBloc.close(); + }); + }); +} diff --git a/test/data/states/settings/settings_bloc_test.dart b/test/data/states/settings/settings_bloc_test.dart new file mode 100644 index 0000000..7479897 --- /dev/null +++ b/test/data/states/settings/settings_bloc_test.dart @@ -0,0 +1,41 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/data/states/settings/settings_bloc.dart'; +import 'package:flutter_starter/data/states/settings/settings_event.dart'; +import 'package:flutter_starter/data/states/settings/settings_state.dart'; +import 'package:flutter_starter/presenter/themes/themes/light.dart'; + +void main() { + group('SettingsBloc', () { + test('initial state has LightAppTheme', () { + final bloc = SettingsBloc(); + expect(bloc.state.theme, isA()); + bloc.close(); + }); + + blocTest( + 'emits state with new theme on SettingsThemeChanged', + build: SettingsBloc.new, + act: (bloc) { + const newTheme = LightAppTheme(); + bloc.add(SettingsThemeChanged(newTheme)); + }, + verify: (bloc) { + expect(bloc.state.theme, isA()); + }, + ); + + blocTest( + 'updates theme when multiple events are dispatched', + build: SettingsBloc.new, + act: (bloc) { + bloc + ..add(const SettingsThemeChanged(LightAppTheme())) + ..add(const SettingsThemeChanged(LightAppTheme())); + }, + verify: (bloc) { + expect(bloc.state.theme, isA()); + }, + ); + }); +} diff --git a/test/data/usecases/login_use_case_test.dart b/test/data/usecases/login_use_case_test.dart new file mode 100644 index 0000000..8d91ea3 --- /dev/null +++ b/test/data/usecases/login_use_case_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/auth_repository.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/exceptions.dart'; +import 'package:flutter_starter/data/usecases/login.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +const _account = Account(id: '1', email: 'a@b.com', name: 'Alice'); + +void main() { + late MockAuthRepository mockRepo; + late LoginUseCase useCase; + + setUp(() { + mockRepo = MockAuthRepository(); + useCase = LoginUseCase(authRepository: mockRepo); + }); + + group('LoginUseCase', () { + test('calls repository.login with correct params and returns account', () async { + when(() => mockRepo.login(username: 'alice', password: 'secret')) + .thenAnswer((_) async => _account); + + final result = await useCase((username: 'alice', password: 'secret')); + + expect(result, _account); + verify(() => mockRepo.login(username: 'alice', password: 'secret')).called(1); + }); + + test('propagates exception from repository', () async { + when(() => mockRepo.login(username: any(named: 'username'), password: any(named: 'password'))) + .thenThrow(LoginInvalidEmailPasswordException()); + + expect( + () => useCase((username: 'bad', password: 'creds')), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/data/usecases/verify_login_status_use_case_test.dart b/test/data/usecases/verify_login_status_use_case_test.dart new file mode 100644 index 0000000..df43efb --- /dev/null +++ b/test/data/usecases/verify_login_status_use_case_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_starter/core/use_case.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/auth_repository.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/exceptions.dart'; +import 'package:flutter_starter/data/usecases/verify_login_status.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +const _account = Account(id: '2', email: 'b@c.com', name: 'Bob'); + +void main() { + late MockAuthRepository mockRepo; + late VerifyLoginStatusUseCase useCase; + + setUp(() { + mockRepo = MockAuthRepository(); + useCase = VerifyLoginStatusUseCase(authRepository: mockRepo); + }); + + group('VerifyLoginStatusUseCase', () { + test('calls repository.verifyLoginStatus and returns account', () async { + when(() => mockRepo.verifyLoginStatus()).thenAnswer((_) async => _account); + + final result = await useCase(NoParams()); + + expect(result, _account); + verify(() => mockRepo.verifyLoginStatus()).called(1); + }); + + test('accepts null params', () async { + when(() => mockRepo.verifyLoginStatus()).thenAnswer((_) async => _account); + + final result = await useCase(null); + + expect(result, _account); + }); + + test('propagates exception from repository', () async { + when(() => mockRepo.verifyLoginStatus()).thenThrow(UnauthorizedException()); + + expect(() => useCase(null), throwsA(isA())); + }); + }); +} diff --git a/test/flavors_test.dart b/test/flavors_test.dart new file mode 100644 index 0000000..83d752d --- /dev/null +++ b/test/flavors_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/flavors.dart'; + +void main() { + tearDown(() { + F.appFlavor = null; + }); + + group('Flavor enum', () { + test('contains dev, staging, production values', () { + expect(Flavor.values, containsAll([Flavor.dev, Flavor.staging, Flavor.production])); + }); + }); + + group('F.name', () { + test('returns empty string when appFlavor is null', () { + F.appFlavor = null; + expect(F.name, ''); + }); + + test('returns "dev" for dev flavor', () { + F.appFlavor = Flavor.dev; + expect(F.name, 'dev'); + }); + + test('returns "staging" for staging flavor', () { + F.appFlavor = Flavor.staging; + expect(F.name, 'staging'); + }); + + test('returns "production" for production flavor', () { + F.appFlavor = Flavor.production; + expect(F.name, 'production'); + }); + }); + + group('F.title', () { + test('returns "title" when appFlavor is null', () { + F.appFlavor = null; + expect(F.title, 'title'); + }); + + test('returns "[Dev] Starter" for dev flavor', () { + F.appFlavor = Flavor.dev; + expect(F.title, '[Dev] Starter'); + }); + + test('returns "[Stg] Starter" for staging flavor', () { + F.appFlavor = Flavor.staging; + expect(F.title, '[Stg] Starter'); + }); + + test('returns "Starter" for production flavor', () { + F.appFlavor = Flavor.production; + expect(F.title, 'Starter'); + }); + }); +} diff --git a/test/presenter/pages/login/login_bloc_test.dart b/test/presenter/pages/login/login_bloc_test.dart new file mode 100644 index 0000000..e9d653a --- /dev/null +++ b/test/presenter/pages/login/login_bloc_test.dart @@ -0,0 +1,136 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_starter/core/exception.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/exceptions.dart'; +import 'package:flutter_starter/data/usecases/login.dart'; +import 'package:flutter_starter/presenter/pages/login/login_bloc.dart'; +import 'package:flutter_starter/presenter/pages/login/login_event.dart'; +import 'package:flutter_starter/presenter/pages/login/login_state.dart'; + +class MockLoginUseCase extends Mock implements LoginUseCase {} + +const _account = Account(id: '1', email: 'a@b.com', name: 'Alice'); + +void main() { + late MockLoginUseCase mockLoginUseCase; + + setUpAll(() { + registerFallbackValue((username: '', password: '')); + }); + + setUp(() { + mockLoginUseCase = MockLoginUseCase(); + }); + + group('LoginBloc', () { + test('initial state is correct', () { + final bloc = LoginBloc(login: mockLoginUseCase); + expect(bloc.state, const LoginState()); + expect(bloc.state.status, LoginStatus.initial); + expect(bloc.state.username, ''); + expect(bloc.state.password, ''); + expect(bloc.state.account, isNull); + bloc.close(); + }); + + blocTest( + 'emits updated username on LoginUsernameChanged', + build: () => LoginBloc(login: mockLoginUseCase), + act: (bloc) => bloc.add(const LoginUsernameChanged('alice')), + expect: () => [ + const LoginState(username: 'alice'), + ], + ); + + blocTest( + 'emits updated password on LoginPasswordChanged', + build: () => LoginBloc(login: mockLoginUseCase), + act: (bloc) => bloc.add(const LoginPasswordChanged('secret')), + expect: () => [ + const LoginState(password: 'secret'), + ], + ); + + blocTest( + 'emits submitting then success on LoginStarted when login succeeds', + build: () { + when(() => mockLoginUseCase(any())).thenAnswer((_) async => _account); + return LoginBloc(login: mockLoginUseCase); + }, + seed: () => const LoginState(username: 'alice', password: 'secret'), + act: (bloc) => bloc.add(const LoginStarted()), + expect: () => [ + const LoginState( + username: 'alice', + password: 'secret', + status: LoginStatus.submitting, + ), + const LoginState( + username: 'alice', + password: 'secret', + status: LoginStatus.success, + account: _account, + ), + ], + ); + + blocTest( + 'emits submitting then failure on LoginStarted when login throws', + build: () { + when(() => mockLoginUseCase(any())) + .thenThrow(LoginInvalidEmailPasswordException()); + return LoginBloc(login: mockLoginUseCase); + }, + seed: () => const LoginState(username: 'bad', password: 'creds'), + act: (bloc) => bloc.add(const LoginStarted()), + expect: () => [ + const LoginState( + username: 'bad', + password: 'creds', + status: LoginStatus.submitting, + ), + isA() + .having((s) => s.status, 'status', LoginStatus.failure) + .having((s) => s.error, 'error', isA()), + ], + ); + + blocTest( + 'emits failure state on LoginErrorOccurred', + build: () => LoginBloc(login: mockLoginUseCase), + act: (bloc) => bloc.add(LoginErrorOccurred(NetworkException())), + expect: () => [ + isA() + .having((s) => s.status, 'status', LoginStatus.failure) + .having((s) => s.error, 'error', isA()), + ], + ); + + blocTest( + 'emits failure with null error on LoginErrorOccurred without error', + build: () => LoginBloc(login: mockLoginUseCase), + act: (bloc) => bloc.add(const LoginErrorOccurred()), + expect: () => [ + isA() + .having((s) => s.status, 'status', LoginStatus.failure) + .having((s) => s.error, 'error', isNull), + ], + ); + + blocTest( + 'username and password accumulate across multiple events', + build: () => LoginBloc(login: mockLoginUseCase), + act: (bloc) { + bloc + ..add(const LoginUsernameChanged('user@example.com')) + ..add(const LoginPasswordChanged('mypassword')); + }, + expect: () => [ + const LoginState(username: 'user@example.com'), + const LoginState(username: 'user@example.com', password: 'mypassword'), + ], + ); + }); +} diff --git a/test/presenter/pages/splash/splash_bloc_test.dart b/test/presenter/pages/splash/splash_bloc_test.dart new file mode 100644 index 0000000..b9ea7dc --- /dev/null +++ b/test/presenter/pages/splash/splash_bloc_test.dart @@ -0,0 +1,98 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_starter/core/exception.dart'; +import 'package:flutter_starter/core/use_case.dart'; +import 'package:flutter_starter/data/entities/account.dart'; +import 'package:flutter_starter/data/repositories/auth_repository/exceptions.dart'; +import 'package:flutter_starter/data/usecases/verify_login_status.dart'; +import 'package:flutter_starter/presenter/pages/splash/splash_bloc.dart'; +import 'package:flutter_starter/presenter/pages/splash/splash_event.dart'; +import 'package:flutter_starter/presenter/pages/splash/splash_state.dart'; + +class MockVerifyLoginStatusUseCase extends Mock implements VerifyLoginStatusUseCase {} + +const _account = Account(id: '3', email: 'c@d.com', name: 'Carol'); + +void main() { + late MockVerifyLoginStatusUseCase mockUseCase; + + setUpAll(() { + registerFallbackValue(NoParams()); + }); + + setUp(() { + mockUseCase = MockVerifyLoginStatusUseCase(); + }); + + group('SplashBloc', () { + test('initial state is loading', () { + final bloc = SplashBloc(verifyLoginStatus: mockUseCase); + expect(bloc.state, const SplashState(status: SplashStatus.loading)); + bloc.close(); + }); + + blocTest( + // Initial state is already SplashStatus.loading, so the first emit (loading) is + // deduplicated. Only the success state is emitted. + 'emits success with account on SplashVerifyLoginStatusStarted when use case succeeds', + build: () { + when(() => mockUseCase(any())).thenAnswer((_) async => _account); + return SplashBloc(verifyLoginStatus: mockUseCase); + }, + act: (bloc) => bloc.add(const SplashVerifyLoginStatusStarted()), + expect: () => [ + const SplashState(status: SplashStatus.success, account: _account), + ], + ); + + blocTest( + // Same deduplication: initial=loading, first emit=loading (skipped), then failure. + 'emits failure when use case throws', + build: () { + when(() => mockUseCase(any())).thenThrow(UnauthorizedException()); + return SplashBloc(verifyLoginStatus: mockUseCase); + }, + act: (bloc) => bloc.add(const SplashVerifyLoginStatusStarted()), + expect: () => [ + isA().having( + (s) => s.status, + 'status', + SplashStatus.failure, + ), + ], + ); + + blocTest( + 'emits failure with error on SplashErrorOccurred event', + build: () => SplashBloc(verifyLoginStatus: mockUseCase), + act: (bloc) => bloc.add(SplashErrorOccurred(NetworkException())), + expect: () => [ + isA() + .having((s) => s.status, 'status', SplashStatus.failure) + .having((s) => s.error, 'error', isA()), + ], + ); + + blocTest( + 'emits failure with null error on SplashErrorOccurred without argument', + build: () => SplashBloc(verifyLoginStatus: mockUseCase), + act: (bloc) => bloc.add(const SplashErrorOccurred()), + expect: () => [ + isA() + .having((s) => s.status, 'status', SplashStatus.failure) + .having((s) => s.error, 'error', isNull), + ], + ); + + blocTest( + 'calls use case when SplashVerifyLoginStatusStarted is dispatched', + build: () { + when(() => mockUseCase(any())).thenAnswer((_) async => _account); + return SplashBloc(verifyLoginStatus: mockUseCase); + }, + act: (bloc) => bloc.add(const SplashVerifyLoginStatusStarted()), + verify: (_) => verify(() => mockUseCase(any())).called(1), + ); + }); +} diff --git a/test/presenter/themes/colors_test.dart b/test/presenter/themes/colors_test.dart new file mode 100644 index 0000000..1ecfe22 --- /dev/null +++ b/test/presenter/themes/colors_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/presenter/themes/colors.dart'; + +final _colorsA = AppThemeColors( + primarySwatch: Colors.red, + primary: const Color(0xFFFF0000), + secondary: const Color(0xFF00FF00), + accent: const Color(0xFF0000FF), + background: const Color(0xFFFFFFFF), + backgroundDark: const Color(0xFFF0F0F0), + disabled: const Color(0xFF888888), + information: const Color(0xFF1111FF), + success: const Color(0xFF00AA00), + alert: const Color(0xFFFFAA00), + warning: const Color(0xFFFF5500), + error: const Color(0xFFCC0000), + text: const Color(0xFF111111), + textOnPrimary: const Color(0xFFFFFFFF), + border: const Color(0xFFDDDDDD), + hint: const Color(0xFF999999), +); + +final _colorsB = AppThemeColors( + primarySwatch: Colors.blue, + primary: const Color(0xFF0000FF), + secondary: const Color(0xFFFF00FF), + accent: const Color(0xFFFFFF00), + background: const Color(0xFF000000), + backgroundDark: const Color(0xFF111111), + disabled: const Color(0xFF444444), + information: const Color(0xFF2222FF), + success: const Color(0xFF00BB00), + alert: const Color(0xFFFFBB00), + warning: const Color(0xFFFF6600), + error: const Color(0xFFDD1111), + text: const Color(0xFF222222), + textOnPrimary: const Color(0xFF000000), + border: const Color(0xFFEEEEEE), + hint: const Color(0xFFAAAAAA), +); + +void main() { + group('AppThemeColors.lerp', () { + test('returns this when other is not AppThemeColors', () { + final result = _colorsA.lerp('not colors', 0.5); + expect(result, same(_colorsA)); + }); + + test('returns this when t=0', () { + final result = _colorsA.lerp(_colorsB, 0.0); + expect(result.primary, _colorsA.primary); + }); + + test('returns other when t=1', () { + final result = _colorsA.lerp(_colorsB, 1.0); + expect(result.primary, _colorsB.primary); + }); + + test('interpolates colors at t=0.5', () { + final result = _colorsA.lerp(_colorsB, 0.5); + expect(result.primary, Color.lerp(_colorsA.primary, _colorsB.primary, 0.5)); + expect(result.secondary, Color.lerp(_colorsA.secondary, _colorsB.secondary, 0.5)); + }); + + test('preserves primarySwatch from original', () { + final result = _colorsA.lerp(_colorsB, 0.5); + expect(result.primarySwatch, _colorsA.primarySwatch); + }); + }); + + group('AppThemeColors.copyWith', () { + test('returns same values when no overrides provided', () { + final copy = _colorsA.copyWith(); + expect(copy.primary, _colorsA.primary); + expect(copy.secondary, _colorsA.secondary); + expect(copy.background, _colorsA.background); + }); + + test('overrides only specified fields', () { + const newPrimary = Color(0xFF123456); + final copy = _colorsA.copyWith(primary: newPrimary); + expect(copy.primary, newPrimary); + expect(copy.secondary, _colorsA.secondary); + expect(copy.background, _colorsA.background); + }); + + test('can override all fields', () { + final copy = _colorsA.copyWith( + primarySwatch: Colors.green, + primary: const Color(0xFF010101), + secondary: const Color(0xFF020202), + accent: const Color(0xFF030303), + background: const Color(0xFF040404), + backgroundDark: const Color(0xFF050505), + disabled: const Color(0xFF060606), + information: const Color(0xFF070707), + success: const Color(0xFF080808), + alert: const Color(0xFF090909), + warning: const Color(0xFF0A0A0A), + error: const Color(0xFF0B0B0B), + text: const Color(0xFF0C0C0C), + textOnPrimary: const Color(0xFF0D0D0D), + border: const Color(0xFF0E0E0E), + hint: const Color(0xFF0F0F0F), + ); + expect(copy.primary, const Color(0xFF010101)); + expect(copy.error, const Color(0xFF0B0B0B)); + expect(copy.hint, const Color(0xFF0F0F0F)); + }); + }); + + group('AppColors constants', () { + test('white is Colors.white', () { + expect(AppColors.white, Colors.white); + }); + + test('black is Colors.black', () { + expect(AppColors.black, Colors.black); + }); + + test('transparent is Colors.transparent', () { + expect(AppColors.transparent, Colors.transparent); + }); + + test('red is correct hex color', () { + expect(AppColors.red, const Color(0xFFFA6555)); + }); + }); +} diff --git a/test/presenter/themes/themes_test.dart b/test/presenter/themes/themes_test.dart new file mode 100644 index 0000000..253c460 --- /dev/null +++ b/test/presenter/themes/themes_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/presenter/themes/colors.dart'; +import 'package:flutter_starter/presenter/themes/themes.dart'; +import 'package:flutter_starter/presenter/themes/themes/light.dart'; + +void main() { + group('LightAppTheme', () { + const theme = LightAppTheme(); + + test('name is "light"', () { + expect(theme.name, 'light'); + }); + + test('brightness is Brightness.light', () { + expect(theme.brightness, Brightness.light); + }); + + test('primary color is AppColors.red', () { + expect(theme.colors.primary, AppColors.red); + }); + + test('background is white', () { + expect(theme.colors.background, const Color(0xFFFFFFFF)); + }); + + test('secondary is purple-blue', () { + expect(theme.colors.secondary, const Color(0xFF6C79DB)); + }); + + test('error matches primary (red)', () { + expect(theme.colors.error, AppColors.red); + }); + + test('fontFamily defaults to Roboto', () { + expect(theme.fontFamily, 'Roboto'); + }); + + test('baseColorScheme is light when brightness is light', () { + expect(theme.baseColorScheme, isA()); + expect(theme.baseColorScheme.brightness, Brightness.light); + }); + + test('themeData has useMaterial3 false', () { + expect(theme.themeData.useMaterial3, isFalse); + }); + + test('themeData platform is iOS', () { + expect(theme.themeData.platform, TargetPlatform.iOS); + }); + }); + + group('AppTheme.copyWith', () { + const base = LightAppTheme(); + + test('returns same values when no overrides', () { + final copy = base.copyWith(); + expect(copy.name, base.name); + expect(copy.brightness, base.brightness); + }); + + test('overrides name', () { + final copy = base.copyWith(name: 'custom'); + expect(copy.name, 'custom'); + expect(copy.brightness, base.brightness); + }); + + test('overrides brightness', () { + final copy = base.copyWith(brightness: Brightness.dark); + expect(copy.brightness, Brightness.dark); + expect(copy.name, base.name); + }); + }); + + group('AppTheme.lerp', () { + const themeA = LightAppTheme(); + const themeB = LightAppTheme(); + + test('returns this when other is not an AppTheme', () { + final result = themeA.lerp(null, 0.5); + expect(result, same(themeA)); + }); + + test('returns valid AppTheme when lerping two AppThemes at t=0', () { + final result = themeA.lerp(themeB, 0.0); + expect(result, isA()); + expect(result.name, themeA.name); + }); + + test('returns valid AppTheme when lerping two AppThemes at t=1', () { + final result = themeA.lerp(themeB, 1.0); + expect(result, isA()); + }); + + test('returns valid AppTheme when lerping at t=0.5', () { + final result = themeA.lerp(themeB, 0.5); + expect(result, isA()); + }); + }); + + group('AppTheme.themeData extensions', () { + test('themeData contains AppTheme extension', () { + const theme = LightAppTheme(); + final ext = theme.themeData.extension(); + expect(ext, isNotNull); + expect(ext, isA()); + }); + }); +} diff --git a/test/presenter/themes/typography_test.dart b/test/presenter/themes/typography_test.dart new file mode 100644 index 0000000..f04de45 --- /dev/null +++ b/test/presenter/themes/typography_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_starter/presenter/themes/typography.dart'; + +void main() { + group('AppThemeTypography defaults', () { + const typography = AppThemeTypography(); + + test('headingLarge has fontSize 32', () { + expect(typography.headingLarge.fontSize, 32); + }); + + test('heading has fontSize 24', () { + expect(typography.heading.fontSize, 24); + }); + + test('headingSmall has fontSize 20', () { + expect(typography.headingSmall.fontSize, 20); + }); + + test('bodyExtraLarge has fontSize 20', () { + expect(typography.bodyExtraLarge.fontSize, 20); + }); + + test('bodyLarge has fontSize 18', () { + expect(typography.bodyLarge.fontSize, 18); + }); + + test('body has fontSize 16', () { + expect(typography.body.fontSize, 16); + }); + + test('bodySmall has fontSize 14', () { + expect(typography.bodySmall.fontSize, 14); + }); + + test('bodyExtraSmall has fontSize 12', () { + expect(typography.bodyExtraSmall.fontSize, 12); + }); + + test('captionLarge has fontSize 14', () { + expect(typography.captionLarge.fontSize, 14); + }); + + test('caption has fontSize 12', () { + expect(typography.caption.fontSize, 12); + }); + + test('captionSmall has fontSize 10', () { + expect(typography.captionSmall.fontSize, 10); + }); + }); + + group('AppThemeTypography.lerp', () { + const typA = AppThemeTypography( + headingLarge: TextStyle(fontSize: 32), + ); + const typB = AppThemeTypography( + headingLarge: TextStyle(fontSize: 64), + ); + + test('returns this when other is not AppThemeTypography', () { + final result = typA.lerp('not typography', 0.5); + expect(result, same(typA)); + }); + + test('interpolates font sizes at t=0', () { + final result = typA.lerp(typB, 0.0); + expect(result.headingLarge.fontSize, closeTo(32, 0.1)); + }); + + test('interpolates font sizes at t=1', () { + final result = typA.lerp(typB, 1.0); + expect(result.headingLarge.fontSize, closeTo(64, 0.1)); + }); + + test('interpolates font sizes at t=0.5', () { + final result = typA.lerp(typB, 0.5); + expect(result.headingLarge.fontSize, closeTo(48, 0.1)); + }); + }); +} diff --git a/test/services/oauth_token_manager_test.dart b/test/services/oauth_token_manager_test.dart new file mode 100644 index 0000000..fd82ce9 --- /dev/null +++ b/test/services/oauth_token_manager_test.dart @@ -0,0 +1,141 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_starter/services/oauth_token_manager/oauth_token_manager.default.dart'; + +class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} + +void main() { + late MockFlutterSecureStorage mockStorage; + late DefaultOauthTokenManager manager; + + const accessKey = '@@oauth-token/default/accessToken'; + const refreshKey = '@@oauth-token/default/refreshToken'; + + setUp(() { + mockStorage = MockFlutterSecureStorage(); + manager = DefaultOauthTokenManager(flutterSecureStorage: mockStorage); + }); + + group('getAccessToken', () { + test('returns token from storage', () async { + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) async => 'access123'); + + expect(await manager.getAccessToken(), 'access123'); + }); + + test('returns null when storage has no value', () async { + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) async => null); + + expect(await manager.getAccessToken(), isNull); + }); + + test('returns null when storage throws', () async { + when(() => mockStorage.read(key: accessKey)).thenThrow(Exception('read error')); + + expect(await manager.getAccessToken(), isNull); + }); + }); + + group('getRefreshToken', () { + test('returns token from storage', () async { + when(() => mockStorage.read(key: refreshKey)).thenAnswer((_) async => 'refresh456'); + + expect(await manager.getRefreshToken(), 'refresh456'); + }); + + test('returns null when storage has no value', () async { + when(() => mockStorage.read(key: refreshKey)).thenAnswer((_) async => null); + + expect(await manager.getRefreshToken(), isNull); + }); + + test('returns null when storage throws', () async { + when(() => mockStorage.read(key: refreshKey)).thenThrow(Exception('read error')); + + expect(await manager.getRefreshToken(), isNull); + }); + }); + + group('saveAccessToken', () { + test('writes token to storage', () async { + when(() => mockStorage.write(key: accessKey, value: 'newToken')) + .thenAnswer((_) async {}); + + await manager.saveAccessToken('newToken'); + + verify(() => mockStorage.write(key: accessKey, value: 'newToken')).called(1); + }); + + test('writes null to storage', () async { + when(() => mockStorage.write(key: accessKey, value: null)) + .thenAnswer((_) async {}); + + await manager.saveAccessToken(null); + + verify(() => mockStorage.write(key: accessKey, value: null)).called(1); + }); + }); + + group('saveRefreshToken', () { + test('writes token to storage', () async { + when(() => mockStorage.write(key: refreshKey, value: 'refreshNew')) + .thenAnswer((_) async {}); + + await manager.saveRefreshToken('refreshNew'); + + verify(() => mockStorage.write(key: refreshKey, value: 'refreshNew')).called(1); + }); + }); + + group('removeAllTokens', () { + test('deletes both access and refresh tokens', () async { + when(() => mockStorage.delete(key: accessKey)).thenAnswer((_) async {}); + when(() => mockStorage.delete(key: refreshKey)).thenAnswer((_) async {}); + + await manager.removeAllTokens(); + + verify(() => mockStorage.delete(key: accessKey)).called(1); + verify(() => mockStorage.delete(key: refreshKey)).called(1); + }); + }); + + group('getAuthenticatedHeaders', () { + test('adds Authorization header when access token is present', () async { + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) async => 'mytoken'); + + final result = await manager.getAuthenticatedHeaders({'Content-Type': 'application/json'}); + + expect(result, { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer mytoken', + }); + }); + + test('returns original headers when access token is null', () async { + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) async => null); + + final headers = {'Content-Type': 'application/json'}; + final result = await manager.getAuthenticatedHeaders(headers); + + expect(result, headers); + }); + + test('returns original headers when storage throws', () async { + when(() => mockStorage.read(key: accessKey)).thenThrow(Exception('err')); + + final headers = {'X-Custom': 'value'}; + final result = await manager.getAuthenticatedHeaders(headers); + + expect(result, headers); + }); + + test('works with empty headers map', () async { + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) async => 'tok'); + + final result = await manager.getAuthenticatedHeaders({}); + + expect(result, {'Authorization': 'Bearer tok'}); + }); + }); +} From 072e70d478db94c0395f50e18955f96b7cc54479 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 09:33:21 +0000 Subject: [PATCH 2/5] Add GitHub Actions CI workflow for lint and test Runs flutter analyze (fatal warnings/infos) and flutter test --coverage on push/PR to main, master, and develop branches. Uses Flutter 3.41.6 (from .fvmrc) with dependency caching and uploads coverage artifact. https://claude.ai/code/session_011cEYpuo76EMamkGvpnuPNZ --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bad4325 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: ["main", "master", "develop", "claude/**"] + pull_request: + branches: ["main", "master", "develop"] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Lint & Analyze + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: "stable" + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run analyzer + run: flutter analyze --fatal-infos --fatal-warnings + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: "3.41.6" + channel: "stable" + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run tests + run: flutter test --coverage + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage/lcov.info + retention-days: 7 From 1de026b8cf36afc35d1842a8c793bcb6d8a33cb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 09:46:52 +0000 Subject: [PATCH 3/5] Fix CI: use any version for test deps, drop --fatal-infos from analyzer bloc_test and mocktail version constraints were too narrow, causing flutter pub get to fail when resolving against the locked bloc 9.2.0 ecosystem. Using 'any' lets pub pick the best compatible version. Also remove --fatal-infos from flutter analyze since transitive package hints can cause false failures; --fatal-warnings is sufficient. https://claude.ai/code/session_011cEYpuo76EMamkGvpnuPNZ --- .github/workflows/ci.yml | 2 +- pubspec.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bad4325..0b74d41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: run: flutter pub get - name: Run analyzer - run: flutter analyze --fatal-infos --fatal-warnings + run: flutter analyze --fatal-warnings test: name: Tests diff --git a/pubspec.yaml b/pubspec.yaml index 226786b..2a65527 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,8 +75,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - bloc_test: ^9.1.7 - mocktail: ^1.0.4 + bloc_test: any + mocktail: any # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec From dc18baaeee2c2ab57214c5d2cbe49fc18a865b14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 18:12:47 +0000 Subject: [PATCH 4/5] fix: resolve all test failures to achieve 135 passing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - splash_bloc_test: account for first emit() always firing in BLoC 9.2 (success test now expects loading+success; failure test uses Future.error and adds errors parameter for zone-propagated UnauthorizedException) - oauth_token_manager_test: replace thenThrow with thenAnswer+Future.error for async storage mock methods (3 tests) - local_data_source_test: same thenThrow→Future.error fix (1 test) - bloc_observer_test: fix registerFallbackValue type arg and wrong import - login_bloc_test: fix registerFallbackValue type arg and add errors param All 135 tests pass; flutter analyze --fatal-warnings reports no issues. https://claude.ai/code/session_011cEYpuo76EMamkGvpnuPNZ --- pubspec.lock | 104 ++++++++++++++++++ .../sources/local/local_data_source_test.dart | 2 +- test/data/states/bloc_observer_test.dart | 4 +- .../pages/login/login_bloc_test.dart | 3 +- .../pages/splash/splash_bloc_test.dart | 10 +- test/services/oauth_token_manager_test.dart | 6 +- 6 files changed, 118 insertions(+), 11 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 46eb001..4698338 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b" + url: "https://pub.dev" + source: hosted + version: "10.0.0" boolean_selector: dependency: transitive description: @@ -153,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" clock: dependency: transitive description: @@ -193,6 +209,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -209,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.7" + 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: @@ -589,6 +621,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" native_toolchain_c: dependency: transitive description: @@ -605,6 +645,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" objective_c: dependency: transitive description: @@ -837,6 +885,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + 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: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -866,6 +930,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.11" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -922,6 +1002,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" test_api: dependency: transitive description: @@ -930,6 +1018,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" typed_data: dependency: transitive description: @@ -986,6 +1082,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + 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/test/data/sources/local/local_data_source_test.dart b/test/data/sources/local/local_data_source_test.dart index e22ef73..4dfc535 100644 --- a/test/data/sources/local/local_data_source_test.dart +++ b/test/data/sources/local/local_data_source_test.dart @@ -42,7 +42,7 @@ void main() { }); test('returns null when storage throws an error', () async { - when(() => mockStorage.read(key: storageKey)).thenThrow(Exception('storage error')); + when(() => mockStorage.read(key: storageKey)).thenAnswer((_) => Future.error(Exception('storage error'))); final result = await dataSource.getInitializedVersion(); diff --git a/test/data/states/bloc_observer_test.dart b/test/data/states/bloc_observer_test.dart index 15816d3..7a08b39 100644 --- a/test/data/states/bloc_observer_test.dart +++ b/test/data/states/bloc_observer_test.dart @@ -1,4 +1,4 @@ -import 'package:bloc/bloc.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:flutter_starter/data/entities/account.dart'; @@ -22,7 +22,7 @@ void main() { setUpAll(() { // Register fallback for AuthEvent so any() works in when/verify - registerFallbackValue(const AuthLoggedOut()); + registerFallbackValue(const AuthLoggedOut()); }); setUp(() { diff --git a/test/presenter/pages/login/login_bloc_test.dart b/test/presenter/pages/login/login_bloc_test.dart index e9d653a..d3d463a 100644 --- a/test/presenter/pages/login/login_bloc_test.dart +++ b/test/presenter/pages/login/login_bloc_test.dart @@ -17,7 +17,7 @@ void main() { late MockLoginUseCase mockLoginUseCase; setUpAll(() { - registerFallbackValue((username: '', password: '')); + registerFallbackValue((username: '', password: '')); }); setUp(() { @@ -95,6 +95,7 @@ void main() { .having((s) => s.status, 'status', LoginStatus.failure) .having((s) => s.error, 'error', isA()), ], + errors: () => [isA()], ); blocTest( diff --git a/test/presenter/pages/splash/splash_bloc_test.dart b/test/presenter/pages/splash/splash_bloc_test.dart index b9ea7dc..7082751 100644 --- a/test/presenter/pages/splash/splash_bloc_test.dart +++ b/test/presenter/pages/splash/splash_bloc_test.dart @@ -33,8 +33,8 @@ void main() { }); blocTest( - // Initial state is already SplashStatus.loading, so the first emit (loading) is - // deduplicated. Only the success state is emitted. + // The first emit() always fires (bloc_test 10 / BLoC 9.2 _emitted flag), + // even when the value equals the initial state. So loading appears first. 'emits success with account on SplashVerifyLoginStatusStarted when use case succeeds', build: () { when(() => mockUseCase(any())).thenAnswer((_) async => _account); @@ -42,25 +42,27 @@ void main() { }, act: (bloc) => bloc.add(const SplashVerifyLoginStatusStarted()), expect: () => [ + const SplashState(status: SplashStatus.loading), const SplashState(status: SplashStatus.success, account: _account), ], ); blocTest( - // Same deduplication: initial=loading, first emit=loading (skipped), then failure. 'emits failure when use case throws', build: () { - when(() => mockUseCase(any())).thenThrow(UnauthorizedException()); + when(() => mockUseCase(any())).thenAnswer((_) => Future.error(UnauthorizedException())); return SplashBloc(verifyLoginStatus: mockUseCase); }, act: (bloc) => bloc.add(const SplashVerifyLoginStatusStarted()), expect: () => [ + const SplashState(status: SplashStatus.loading), isA().having( (s) => s.status, 'status', SplashStatus.failure, ), ], + errors: () => [isA()], ); blocTest( diff --git a/test/services/oauth_token_manager_test.dart b/test/services/oauth_token_manager_test.dart index fd82ce9..fb84e4f 100644 --- a/test/services/oauth_token_manager_test.dart +++ b/test/services/oauth_token_manager_test.dart @@ -31,7 +31,7 @@ void main() { }); test('returns null when storage throws', () async { - when(() => mockStorage.read(key: accessKey)).thenThrow(Exception('read error')); + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) => Future.error(Exception('read error'))); expect(await manager.getAccessToken(), isNull); }); @@ -51,7 +51,7 @@ void main() { }); test('returns null when storage throws', () async { - when(() => mockStorage.read(key: refreshKey)).thenThrow(Exception('read error')); + when(() => mockStorage.read(key: refreshKey)).thenAnswer((_) => Future.error(Exception('read error'))); expect(await manager.getRefreshToken(), isNull); }); @@ -122,7 +122,7 @@ void main() { }); test('returns original headers when storage throws', () async { - when(() => mockStorage.read(key: accessKey)).thenThrow(Exception('err')); + when(() => mockStorage.read(key: accessKey)).thenAnswer((_) => Future.error(Exception('err'))); final headers = {'X-Custom': 'value'}; final result = await manager.getAuthenticatedHeaders(headers); From e5dc62b9d8411e9837e0139c5ee080a6698977d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 18:15:21 +0000 Subject: [PATCH 5/5] fix: pin bloc_test and mocktail to specific version constraints Replace `any` with `^10.0.0` and `^1.0.4` respectively, matching the versions that are already resolved in pubspec.lock. https://claude.ai/code/session_011cEYpuo76EMamkGvpnuPNZ --- pubspec.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 2a65527..f3759b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,8 +75,8 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^6.0.0 - bloc_test: any - mocktail: any + bloc_test: ^10.0.0 + mocktail: ^1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec