forked from eatvio/eatvio_app
test(session): cover avatar refresh and session teardown
Add regression tests for avatar reset and token-change refresh, stale 401 handling, idempotent session teardown and account-scoped preference cleanup. Update existing cubit tests for the new session dependency. Refs: #1113
This commit is contained in:
118
test/core/interceptors/token_interceptor_session_test.dart
Normal file
118
test/core/interceptors/token_interceptor_session_test.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../fixtures/doubles/session_doubles.dart';
|
||||
|
||||
class _RecordingErrorInterceptorHandler extends ErrorInterceptorHandler {
|
||||
int nextCalls = 0;
|
||||
DioException? nextError;
|
||||
|
||||
@override
|
||||
void next(DioException error) {
|
||||
nextCalls++;
|
||||
nextError = error;
|
||||
}
|
||||
|
||||
@override
|
||||
void resolve(Response<dynamic> response) {
|
||||
throw UnimplementedError('resolve should not be called');
|
||||
}
|
||||
|
||||
@override
|
||||
void reject(
|
||||
DioException error, [
|
||||
bool callFollowingErrorInterceptor = false,
|
||||
]) {
|
||||
throw UnimplementedError('reject should not be called');
|
||||
}
|
||||
}
|
||||
|
||||
DioException _unauthorized(RequestOptions options) {
|
||||
return DioException(
|
||||
requestOptions: options,
|
||||
response: Response<dynamic>(requestOptions: options, statusCode: 401),
|
||||
type: DioExceptionType.badResponse,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late FakeSessionLifecycleService sessionLifecycleService;
|
||||
late TokenInterceptor interceptor;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
TokenStorage.clearCache();
|
||||
await sl.reset();
|
||||
|
||||
sessionLifecycleService = FakeSessionLifecycleService();
|
||||
sl.registerSingleton<SessionLifecycleService>(sessionLifecycleService);
|
||||
interceptor = TokenInterceptor();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sl.reset();
|
||||
TokenStorage.clearCache();
|
||||
});
|
||||
|
||||
group('TokenInterceptor.onError 401', () {
|
||||
test('request without Authorization does not end the session', () async {
|
||||
await TokenStorage.saveTokenInStorage('current-token');
|
||||
|
||||
final options = RequestOptions(path: '/api/profile/index');
|
||||
final error = _unauthorized(options);
|
||||
final handler = _RecordingErrorInterceptorHandler();
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.nextCalls, 1);
|
||||
expect(handler.nextError, same(error));
|
||||
expect(sessionLifecycleService.endSessionCalls, 0);
|
||||
});
|
||||
|
||||
test('stale request token does not end the new session', () async {
|
||||
await TokenStorage.saveTokenInStorage('new-token');
|
||||
|
||||
final options = RequestOptions(path: '/api/profile/index');
|
||||
options.headers['Authorization'] = 'Bearer old-token';
|
||||
options.extra['authToken'] = 'old-token';
|
||||
|
||||
final error = _unauthorized(options);
|
||||
final handler = _RecordingErrorInterceptorHandler();
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.nextCalls, 1);
|
||||
expect(sessionLifecycleService.endSessionCalls, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'current request token ends the session once as unauthorized',
|
||||
() async {
|
||||
await TokenStorage.saveTokenInStorage('current-token');
|
||||
|
||||
final options = RequestOptions(path: '/api/profile/index');
|
||||
options.headers['Authorization'] = 'Bearer current-token';
|
||||
options.extra['authToken'] = 'current-token';
|
||||
|
||||
final error = _unauthorized(options);
|
||||
final handler = _RecordingErrorInterceptorHandler();
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
expect(handler.nextCalls, 1);
|
||||
expect(sessionLifecycleService.endSessionCalls, 1);
|
||||
expect(
|
||||
sessionLifecycleService.lastEndReason,
|
||||
SessionEndReason.unauthorized,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/interceptors/token_interceptor.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../fixtures/doubles/session_doubles.dart';
|
||||
|
||||
class MockRequestOptions extends Mock implements RequestOptions {}
|
||||
|
||||
class MockRequestInterceptorHandler extends Mock implements RequestInterceptorHandler {}
|
||||
class MockRequestInterceptorHandler extends Mock
|
||||
implements RequestInterceptorHandler {}
|
||||
|
||||
class MockErrorInterceptorHandler extends Mock implements ErrorInterceptorHandler {}
|
||||
class MockErrorInterceptorHandler extends Mock
|
||||
implements ErrorInterceptorHandler {}
|
||||
|
||||
class MockResponse extends Mock implements Response {}
|
||||
|
||||
class MockWsManager extends Mock implements WsManager {}
|
||||
|
||||
class MockDioException extends Mock implements DioException {}
|
||||
|
||||
class FakeRequestOptions extends Fake implements RequestOptions {}
|
||||
@@ -32,23 +34,18 @@ void main() {
|
||||
});
|
||||
group('TokenInterceptor', () {
|
||||
late TokenInterceptor interceptor;
|
||||
late FakeSessionLifecycleService fakeSession;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
final mockWsManager = MockWsManager();
|
||||
when(() => mockWsManager.clear()).thenAnswer((_) async {});
|
||||
|
||||
await sl.reset(); // Сбрасываем sl перед каждым тестом
|
||||
sl.registerSingleton<WsManager>(mockWsManager);
|
||||
|
||||
TokenStorage.clearCache();
|
||||
|
||||
await sl.reset();
|
||||
fakeSession = FakeSessionLifecycleService();
|
||||
sl.registerSingleton<SessionLifecycleService>(fakeSession);
|
||||
|
||||
interceptor = TokenInterceptor();
|
||||
// Сбрасываем токен перед каждым тестом
|
||||
try {
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки при очистке
|
||||
}
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
});
|
||||
|
||||
group('onRequest', () {
|
||||
@@ -59,6 +56,7 @@ void main() {
|
||||
final headers = <String, dynamic>{};
|
||||
final options = MockRequestOptions();
|
||||
when(() => options.headers).thenReturn(headers);
|
||||
when(() => options.extra).thenReturn(<String, dynamic>{});
|
||||
final handler = MockRequestInterceptorHandler();
|
||||
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
@@ -73,6 +71,7 @@ void main() {
|
||||
final headers = <String, dynamic>{};
|
||||
final options = MockRequestOptions();
|
||||
when(() => options.headers).thenReturn(headers);
|
||||
when(() => options.extra).thenReturn(<String, dynamic>{});
|
||||
final handler = MockRequestInterceptorHandler();
|
||||
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
@@ -89,6 +88,7 @@ void main() {
|
||||
final headers = <String, dynamic>{};
|
||||
final options = MockRequestOptions();
|
||||
when(() => options.headers).thenReturn(headers);
|
||||
when(() => options.extra).thenReturn(<String, dynamic>{});
|
||||
final handler = MockRequestInterceptorHandler();
|
||||
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
@@ -101,11 +101,16 @@ void main() {
|
||||
});
|
||||
|
||||
group('onError', () {
|
||||
test('удаляет токен и перенаправляет на auth при 401', () async {
|
||||
test('завершает сессию при 401 для текущего токена', () async {
|
||||
const token = 'test-token';
|
||||
await TokenStorage.saveTokenInStorage(token);
|
||||
|
||||
final options = MockRequestOptions();
|
||||
when(
|
||||
() => options.headers,
|
||||
).thenReturn({'Authorization': 'Bearer $token'});
|
||||
when(() => options.extra).thenReturn({'authToken': token});
|
||||
|
||||
final response = MockResponse();
|
||||
when(() => response.statusCode).thenReturn(401);
|
||||
|
||||
@@ -116,14 +121,10 @@ void main() {
|
||||
final handler = MockErrorInterceptorHandler();
|
||||
when(() => handler.next(any())).thenReturn(null);
|
||||
|
||||
// Проверяем, что токен был удален
|
||||
final tokenBefore = await TokenStorage.getTokenFromStorage();
|
||||
expect(tokenBefore, token);
|
||||
|
||||
await interceptor.onError(error, handler);
|
||||
|
||||
final tokenAfter = await TokenStorage.getTokenFromStorage();
|
||||
expect(tokenAfter, null);
|
||||
expect(fakeSession.endSessionCalls, 1);
|
||||
expect(fakeSession.lastEndReason, SessionEndReason.unauthorized);
|
||||
verify(() => handler.next(error)).called(1);
|
||||
});
|
||||
|
||||
|
||||
223
test/core/session/session_lifecycle_service_test.dart
Normal file
223
test/core/session/session_lifecycle_service_test.dart
Normal file
@@ -0,0 +1,223 @@
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/prefs.dart';
|
||||
import 'package:eatvio_app/core/prefs_keys.dart';
|
||||
import 'package:eatvio_app/core/session/session_lifecycle_service.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart';
|
||||
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
|
||||
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class _FakeWsManager implements WsManager {
|
||||
int clearCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
clearCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('WsManager.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeErrorReporter implements ErrorReporter {
|
||||
int reportCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> report(
|
||||
Object error,
|
||||
StackTrace? stackTrace, {
|
||||
String? origin,
|
||||
Map<String, Object?>? extras,
|
||||
}) async {
|
||||
reportCalls++;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeProfileAvatarCubit implements ProfileAvatarCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('ProfileAvatarCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeChatsCubit implements ChatsCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('ChatsCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeNotificationsCubit implements NotificationsCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('NotificationsCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeNewMessagesCubit implements NewMessagesCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('NewMessagesCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeFeedCubit implements FeedCubit {
|
||||
int resetCalls = 0;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
resetCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('FeedCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeVideoCallSessionCubit implements VideoCallSessionCubit {
|
||||
int endCallCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> endCall() async {
|
||||
endCallCalls++;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
throw UnimplementedError('VideoCallSessionCubit.${invocation.memberName}');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
late _FakeWsManager wsManager;
|
||||
late _FakeProfileAvatarCubit profileAvatarCubit;
|
||||
late _FakeChatsCubit chatsCubit;
|
||||
late _FakeNotificationsCubit notificationsCubit;
|
||||
late _FakeNewMessagesCubit newMessagesCubit;
|
||||
late _FakeFeedCubit feedCubit;
|
||||
late _FakeVideoCallSessionCubit videoCallSessionCubit;
|
||||
late SessionLifecycleService service;
|
||||
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({
|
||||
'token': 'token-a',
|
||||
'socketId': 'socket-a',
|
||||
PrefsKeys.customBaseUrl.name: 'https://old.example',
|
||||
PrefsKeys.isEmailVerificationSkipped.name: true,
|
||||
PrefsKeys.registrationDate.name: '2024-01-01',
|
||||
PrefsKeys.welcomeScreenWasShowed.name: true,
|
||||
});
|
||||
TokenStorage.clearCache();
|
||||
await sl.reset();
|
||||
|
||||
wsManager = _FakeWsManager();
|
||||
profileAvatarCubit = _FakeProfileAvatarCubit();
|
||||
chatsCubit = _FakeChatsCubit();
|
||||
notificationsCubit = _FakeNotificationsCubit();
|
||||
newMessagesCubit = _FakeNewMessagesCubit();
|
||||
feedCubit = _FakeFeedCubit();
|
||||
videoCallSessionCubit = _FakeVideoCallSessionCubit();
|
||||
|
||||
sl.registerSingleton<ProfileAvatarCubit>(profileAvatarCubit);
|
||||
sl.registerSingleton<ChatsCubit>(chatsCubit);
|
||||
sl.registerSingleton<NotificationsCubit>(notificationsCubit);
|
||||
sl.registerSingleton<NewMessagesCubit>(newMessagesCubit);
|
||||
sl.registerSingleton<FeedCubit>(feedCubit);
|
||||
sl.registerSingleton<VideoCallSessionCubit>(videoCallSessionCubit);
|
||||
sl.registerSingleton<Prefs>(Prefs(await SharedPreferences.getInstance()));
|
||||
|
||||
service = SessionLifecycleService(
|
||||
wsManager: wsManager,
|
||||
errorReporter: _FakeErrorReporter(),
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await sl.reset();
|
||||
TokenStorage.clearCache();
|
||||
});
|
||||
|
||||
group('SessionLifecycleService.endSession', () {
|
||||
test(
|
||||
'resets user-scoped state and clears only session-scoped keys',
|
||||
() async {
|
||||
await service.endSession(reason: SessionEndReason.logout);
|
||||
|
||||
expect(wsManager.clearCalls, 1);
|
||||
expect(profileAvatarCubit.resetCalls, 1);
|
||||
expect(chatsCubit.resetCalls, 1);
|
||||
expect(notificationsCubit.resetCalls, 1);
|
||||
expect(newMessagesCubit.resetCalls, 1);
|
||||
expect(feedCubit.resetCalls, 1);
|
||||
expect(videoCallSessionCubit.endCallCalls, 1);
|
||||
|
||||
expect(await TokenStorage.getTokenFromStorage(), isNull);
|
||||
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
|
||||
|
||||
final prefs = sl<Prefs>();
|
||||
expect(prefs.getString(PrefsKeys.customBaseUrl), 'https://old.example');
|
||||
expect(prefs.getBool(PrefsKeys.isEmailVerificationSkipped), isNull);
|
||||
expect(prefs.getString(PrefsKeys.registrationDate), isNull);
|
||||
expect(prefs.getBool(PrefsKeys.welcomeScreenWasShowed), isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test('is idempotent for concurrent calls', () async {
|
||||
final first = service.endSession(reason: SessionEndReason.logout);
|
||||
final second = service.endSession(reason: SessionEndReason.unauthorized);
|
||||
|
||||
await Future.wait([first, second]);
|
||||
|
||||
expect(wsManager.clearCalls, 1);
|
||||
expect(profileAvatarCubit.resetCalls, 1);
|
||||
expect(chatsCubit.resetCalls, 1);
|
||||
expect(notificationsCubit.resetCalls, 1);
|
||||
expect(newMessagesCubit.resetCalls, 1);
|
||||
expect(feedCubit.resetCalls, 1);
|
||||
expect(videoCallSessionCubit.endCallCalls, 1);
|
||||
|
||||
expect(await TokenStorage.getTokenFromStorage(), isNull);
|
||||
expect(await SocketIdStorage.getSocketIdFromStorage(), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user