From 9b51c8b29347ef704bd05b4aef7ced3ebbd8d210 Mon Sep 17 00:00:00 2001 From: qzl Date: Wed, 25 Feb 2026 14:59:20 +0800 Subject: [PATCH] feat(apps): add AuthBloc for global auth state --- .../auth/presentation/bloc/auth_bloc.dart | 45 +++++++++ .../auth/presentation/bloc/auth_event.dart | 22 +++++ .../auth/presentation/bloc/auth_state.dart | 26 +++++ .../presentation/bloc/auth_bloc_test.dart | 99 +++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 apps/lib/features/auth/presentation/bloc/auth_bloc.dart create mode 100644 apps/lib/features/auth/presentation/bloc/auth_event.dart create mode 100644 apps/lib/features/auth/presentation/bloc/auth_state.dart create mode 100644 apps/test/features/auth/presentation/bloc/auth_bloc_test.dart diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart new file mode 100644 index 0000000..3b3d790 --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -0,0 +1,45 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/auth_repository.dart'; +import 'auth_event.dart'; +import 'auth_state.dart'; + +class AuthBloc extends Bloc { + final AuthRepository _repository; + + AuthBloc(this._repository) : super(AuthInitial()) { + on(_onStarted); + on(_onLoggedIn); + on(_onLoggedOut); + } + + Future _onStarted(AuthStarted event, Emitter emit) async { + emit(AuthLoading()); + final refreshToken = await _repository.getRefreshToken(); + if (refreshToken != null) { + try { + final response = await _repository.refresh(refreshToken); + emit( + AuthAuthenticated( + user: AuthUser(id: response.user.id, email: response.user.email), + ), + ); + return; + } catch (_) { + await _repository.logout(); + } + } + emit(AuthUnauthenticated()); + } + + void _onLoggedIn(AuthLoggedIn event, Emitter emit) { + emit(AuthAuthenticated(user: event.user)); + } + + Future _onLoggedOut( + AuthLoggedOut event, + Emitter emit, + ) async { + await _repository.logout(); + emit(AuthUnauthenticated()); + } +} diff --git a/apps/lib/features/auth/presentation/bloc/auth_event.dart b/apps/lib/features/auth/presentation/bloc/auth_event.dart new file mode 100644 index 0000000..3b773df --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_event.dart @@ -0,0 +1,22 @@ +import 'package:equatable/equatable.dart'; +import '../../data/models/auth_response.dart'; + +abstract class AuthEvent extends Equatable { + const AuthEvent(); + + @override + List get props => []; +} + +class AuthStarted extends AuthEvent {} + +class AuthLoggedIn extends AuthEvent { + final AuthUser user; + + const AuthLoggedIn({required this.user}); + + @override + List get props => [user]; +} + +class AuthLoggedOut extends AuthEvent {} diff --git a/apps/lib/features/auth/presentation/bloc/auth_state.dart b/apps/lib/features/auth/presentation/bloc/auth_state.dart new file mode 100644 index 0000000..040d2e3 --- /dev/null +++ b/apps/lib/features/auth/presentation/bloc/auth_state.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; +import '../../data/models/auth_response.dart'; + +export '../../data/models/auth_response.dart' show AuthUser; + +abstract class AuthState extends Equatable { + const AuthState(); + + @override + List get props => []; +} + +class AuthInitial extends AuthState {} + +class AuthLoading extends AuthState {} + +class AuthAuthenticated extends AuthState { + final AuthUser user; + + const AuthAuthenticated({required this.user}); + + @override + List get props => [user]; +} + +class AuthUnauthenticated extends AuthState {} diff --git a/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart new file mode 100644 index 0000000..38244f7 --- /dev/null +++ b/apps/test/features/auth/presentation/bloc/auth_bloc_test.dart @@ -0,0 +1,99 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:social_app/features/auth/data/auth_repository.dart'; +import 'package:social_app/features/auth/data/models/auth_response.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_bloc.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_event.dart'; +import 'package:social_app/features/auth/presentation/bloc/auth_state.dart'; + +class MockAuthRepository extends Mock implements AuthRepository {} + +void main() { + late AuthBloc authBloc; + late MockAuthRepository mockRepository; + + setUp(() { + mockRepository = MockAuthRepository(); + authBloc = AuthBloc(mockRepository); + }); + + tearDown(() { + authBloc.close(); + }); + + group('AuthBloc', () { + blocTest( + 'emits [AuthLoading, AuthUnauthenticated] when AuthStarted and no refresh token', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenAnswer((_) async => null); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), AuthUnauthenticated()], + ); + + blocTest( + 'emits [AuthLoading, AuthAuthenticated] when AuthStarted with valid refresh token', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenAnswer((_) async => 'valid_refresh'); + when(() => mockRepository.refresh('valid_refresh')).thenAnswer( + (_) async => AuthResponse( + accessToken: 'new_access', + refreshToken: 'new_refresh', + expiresIn: 3600, + tokenType: 'bearer', + user: const AuthUser(id: '123', email: 'test@example.com'), + ), + ); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), isA()], + ); + + blocTest( + 'emits [AuthLoading, AuthUnauthenticated] when refresh token expired', + build: () { + when( + () => mockRepository.getRefreshToken(), + ).thenAnswer((_) async => 'expired_refresh'); + when( + () => mockRepository.refresh('expired_refresh'), + ).thenThrow(Exception('Invalid refresh token')); + when(() => mockRepository.logout()).thenAnswer((_) async {}); + return authBloc; + }, + act: (bloc) => bloc.add(AuthStarted()), + expect: () => [AuthLoading(), AuthUnauthenticated()], + ); + + blocTest( + 'emits [AuthAuthenticated] when AuthLoggedIn', + build: () => authBloc, + act: (bloc) => bloc.add( + AuthLoggedIn( + user: const AuthUser(id: '1', email: 'test@example.com'), + ), + ), + expect: () => [isA()], + ); + + blocTest( + 'emits [AuthUnauthenticated] when AuthLoggedOut', + build: () { + when(() => mockRepository.logout()).thenAnswer((_) async {}); + return authBloc; + }, + seed: () => AuthAuthenticated( + user: const AuthUser(id: '1', email: 'test@example.com'), + ), + act: (bloc) => bloc.add(AuthLoggedOut()), + expect: () => [AuthUnauthenticated()], + ); + }); +}